diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5022faa6..0a3238d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,8 @@ jobs: run: | test -f docs/book/dist/firefly-rust-by-example.pdf test -f docs/book/dist/firefly-rust-by-example.epub + test -f docs/book/dist/firefly-rust-by-example-es.pdf + test -f docs/book/dist/firefly-rust-by-example-es.epub - name: publish GitHub release uses: softprops/action-gh-release@v2 @@ -62,3 +64,5 @@ jobs: files: | docs/book/dist/firefly-rust-by-example.pdf docs/book/dist/firefly-rust-by-example.epub + docs/book/dist/firefly-rust-by-example-es.pdf + docs/book/dist/firefly-rust-by-example-es.epub diff --git a/docs/book/book-es.yaml b/docs/book/book-es.yaml new file mode 100644 index 00000000..b872f933 --- /dev/null +++ b/docs/book/book-es.yaml @@ -0,0 +1,191 @@ +# Edición en español (ES) — misma estructura que book.yaml; arte/openers compartidos. +# Construir con: BOOK_CONFIG=book-es.yaml bash docs/book/build-book.sh +title: Firefly para Rust con Ejemplos +subtitle: Microservicios reactivos y orientados a eventos con el framework Firefly +author: Firefly Software Foundation +publisher: Firefly Software Foundation +language: es +identifier: urn:uuid:5b2e9c70-1a4d-4f88-bc31-7e0a6d2f9c14 +rights: Copyright (c) 2026 Firefly Software Foundation. Licensed under Apache-2.0. +cover_svg: art/cover.svg +trim_width: 7.5in +trim_height: 9.25in +src_dir: src-es +front: +- id: title + file: 00-front/00-title.md + nav: false +- id: copyright + file: 00-front/00-copyright.md + nav: false +- id: dedication + file: 00-front/00-dedication.md + nav: false +- id: preface + file: 00-front/00-preface.md + title: Prefacio +- id: conventions + file: 00-front/00-conventions.md + title: Convenciones +- id: primer + file: 00-front/00-rust-primer.md + title: Iniciación a Rust +parts: +- title: Parte I — Fundamentos + chapters: + - id: ch01 + file: 01-why-firefly.md + num: 1 + title: ¿Por qué Firefly para Rust? + opener: art/openers/ch01.svg + - id: ch02 + file: 02-quickstart.md + num: 2 + title: 'Inicio rápido: de cero a un servicio en marcha' + opener: art/openers/ch02.svg + - id: ch03 + file: 03-configuration.md + num: 3 + title: Configuración, perfiles y secretos + opener: art/openers/ch03.svg + - id: ch04 + file: 04a-dependency-injection.md + num: 4 + title: Inyección de dependencias y el contexto de aplicación + opener: art/openers/ch04.svg + - id: ch05 + file: 04-dependency-wiring.md + num: 5 + title: Cableado de dependencias y composición + opener: art/openers/ch05.svg + - id: ch04b + file: 04b-bootstrap.md + num: 6 + title: Arranque de la aplicación + opener: art/openers/ch04b.svg + - id: ch06 + file: 05-reactive-model.md + num: 7 + title: El modelo reactivo — Mono y Flux + opener: art/openers/ch06.svg +- title: Parte II — Modelar y persistir el dominio + chapters: + - id: ch07 + file: 06-first-http-api.md + num: 8 + title: Tu primera API HTTP + opener: art/openers/ch07.svg + - id: ch06a + file: 06a-openapi.md + num: 9 + title: OpenAPI y documentación de la API + opener: art/openers/ch06a.svg + - id: ch08 + file: 07-persistence.md + num: 10 + title: Persistencia y repositorios reactivos + opener: art/openers/ch08.svg + - id: ch09 + file: 08-domain-driven-design.md + num: 11 + title: Diseño guiado por el dominio + opener: art/openers/ch09.svg + - id: ch10 + file: 09-cqrs.md + num: 12 + title: 'CQRS: comandos y consultas' + opener: art/openers/ch10.svg +- title: Parte III — Arquitectura orientada a eventos + chapters: + - id: ch11 + file: 10-eda-messaging.md + num: 13 + title: Arquitectura orientada a eventos y mensajería + opener: art/openers/ch11.svg + - id: ch12 + file: 11-event-sourcing.md + num: 14 + title: Event sourcing del libro mayor + opener: art/openers/ch12.svg +- title: Parte IV — Hacia los microservicios + chapters: + - id: ch13 + file: 13-http-clients.md + num: 15 + title: Clientes HTTP y llamadas a otros servicios + opener: art/openers/ch13.svg + - id: ch14 + file: 20a-experience-tier.md + num: 16 + title: 'La capa de experiencia: componer un BFF' + opener: art/openers/ch14.svg + - id: ch15 + file: 12-sagas.md + num: 17 + title: 'Transacciones distribuidas: sagas, workflows y TCC' + opener: art/openers/ch15.svg + - id: ch22b + file: 22-layered-microservices.md + num: 18 + title: Microservicios por capas + opener: art/openers/ch22b.svg +- title: Parte V — Asegurar, observar y desplegar + chapters: + - id: ch16 + file: 14-security.md + num: 19 + title: Seguridad, sesiones e identidad + opener: art/openers/ch16.svg + - id: ch17 + file: 15-observability.md + num: 20 + title: Observabilidad y salud + opener: art/openers/ch17.svg + - id: ch18 + file: 17-caching.md + num: 21 + title: Caché y resiliencia + opener: art/openers/ch18.svg + - id: ch19 + file: 16-scheduling-notifications.md + num: 22 + title: Tareas programadas, notificaciones y webhooks + opener: art/openers/ch19.svg + - id: ch20 + file: 21-declarative-macros.md + num: 23 + title: Servicios declarativos con macros + opener: art/openers/ch20.svg + - id: ch21 + file: 18-testing.md + num: 24 + title: Pruebas de aplicaciones Firefly + opener: art/openers/ch21.svg + - id: ch22 + file: 19-cli.md + num: 25 + title: La CLI de firefly + opener: art/openers/ch22.svg + - id: ch23 + file: 20-production.md + num: 26 + title: Extender Firefly y llevarlo a producción + opener: art/openers/ch23.svg +- title: Apéndices + chapters: + - id: appb + file: 91-appendix-modules.md + num: A + title: Índice de crates y módulos + opener: art/openers/appb.svg + - id: glossary + file: 92-glossary.md + num: '' + title: Glosario +back: [] +pdf_name: firefly-rust-by-example-es.pdf +epub_name: firefly-rust-by-example-es.epub +labels: + chapter: Capítulo + appendix: Apéndice + contents: Contenido diff --git a/docs/book/dist/firefly-rust-by-example-es.epub b/docs/book/dist/firefly-rust-by-example-es.epub new file mode 100644 index 00000000..b6de9f0e Binary files /dev/null and b/docs/book/dist/firefly-rust-by-example-es.epub differ diff --git a/docs/book/dist/firefly-rust-by-example-es.pdf b/docs/book/dist/firefly-rust-by-example-es.pdf new file mode 100644 index 00000000..02aba2ab Binary files /dev/null and b/docs/book/dist/firefly-rust-by-example-es.pdf differ diff --git a/docs/book/src-es/00-front/00-conventions.md b/docs/book/src-es/00-front/00-conventions.md new file mode 100644 index 00000000..970281ab --- /dev/null +++ b/docs/book/src-es/00-front/00-conventions.md @@ -0,0 +1,78 @@ +## Convenciones + +Esta página explica las convenciones tipográficas y estructurales que se usan a lo largo del libro, y demuestra cada una con un ejemplo real, de modo que la primera vez que te encuentres con un aviso o con un pie de código en un capítulo ya te resulte familiar. + +### Listados de código + +Cada ejemplo de código de varias líneas es Rust real y compilable, extraído del crate complementario Lumen. Cuando resulta útil, un listado se introduce con el **fichero en el que vive** para que puedas localizarlo en `samples/lumen`, como en "`samples/lumen/src/money.rs`". Las referencias a código en línea dentro de la prosa usan `monospace`, como en "el atributo `#[rest_controller]` genera el router del monedero." + +He aquí un listado representativo: el constructor y el núcleo de aritmética exacta del objeto de valor `Money` de Lumen, copiado literalmente de `samples/lumen/src/money.rs`: + +```rust +/// An exact monetary amount, expressed in integer minor units (cents). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Money { + cents: i64, +} + +impl Money { + /// A zero amount — the opening balance of a brand-new wallet. + pub const ZERO: Money = Money { cents: 0 }; + + /// Returns a new `Money` that is `self + other` (immutable addition). + #[must_use] + pub const fn add(self, other: Money) -> Money { + Money { cents: self.cents + other.cents } + } +} +``` + +Un fragmento anotado con `rust,ignore` o `rust,no_run` omite la configuración circundante para ganar foco, pero los nombres de la API, los tipos y las firmas de los métodos son exactamente lo que exponen los crates. Un listado delimitado como `text` simple es salida de la shell, un banner o un intercambio HTTP en lugar de código fuente Rust: + +```text +$ cargo run -p firefly-sample-lumen +:: lumen :: digital-wallet & ledger (v26.6.24) +``` + +### El recordatorio de la dependencia única + +Como la propiedad que define a Lumen es su única dependencia de Firefly, cada tipo del framework que veas se alcanza a través de la fachada — `firefly::cqrs::Bus`, `firefly::eventsourcing::EventStore`, `firefly::reactive::Flux` — o, para la superficie de alta frecuencia y cada macro, a través de un único glob: + +```rust +use firefly::prelude::*; +``` + +Cuando un capítulo introduce un tipo nuevo del framework, la prosa nombra la ruta de la fachada tras la que vive, de modo que siempre sepas que llegó a través de esa única dependencia. + +### Avisos + +A lo largo del cuerpo del texto aparecen cuatro estilos de aviso. Cada uno es una cita en bloque que se abre con una etiqueta en negrita, y el tema de diseño les da un estilo diferenciado: + +> **Note.** Las notas aportan contexto complementario o aclaran una sutileza del texto principal. Vale la pena leerlas, pero no son bloqueantes. + +> **Tip.** Los consejos comparten un atajo, un idiom o una buena práctica que te ahorrará tiempo en proyectos reales; por ejemplo, mantener el dinero en céntimos enteros para que la deriva de coma flotante nunca pueda corromper un saldo. + +> **Warning.** Las advertencias señalan un error común o una arista afilada que provoca problemas difíciles de depurar si se ignora; por ejemplo, que los manejadores CQRS de función libre de Lumen publican a sus colaboradores a través de un `OnceLock` global del proceso, de modo que un segundo arranque de `build_router()` en el mismo binario de prueba conserva el *primer* cableado. + +> **Design note.** Los avisos de nota de diseño explican *por qué* Firefly hace algo de una manera concreta y señalan dónde una idea te resultará familiar si antes has usado un framework opinionado con todo incluido o una biblioteca de reactive-streams. Son orientación, planteada como las propias decisiones de diseño de Firefly, no una tabla de traducción para otro framework. Te encontrarás con ellos en casi todos los capítulos. + +### Tablas de referencia + +Cuando un capítulo introduce una familia de APIs relacionadas, una tabla de referencia las reúne en un solo lugar para que puedas asimilar toda la superficie de un vistazo: + +| Atributo declarativo | Lo que genera | +|---|---| +| `#[rest_controller]` | un router de axum a partir de los métodos manejadores anotados | +| `#[event_listener]` | una suscripción al broker ligada a un tipo de evento | +| `#[scheduled]` | una tarea registrada en el planificador | +| `#[saga]` / `Step` | una transacción distribuida orquestada y compensable | + +### Resumen y ejercicios + +Cada capítulo se cierra con dos secciones fijas: + +- Un **Resumen — qué cambió en Lumen** que enumera los ficheros añadidos o ampliados y la recompensa de una sola frase: "al terminar este capítulo, Lumen puede …". +- Un conjunto de **Ejercicios** que dan un paso más allá: por lo general, una extensión pequeña y autocontenida del código que el capítulo acaba de entregar. Son opcionales pero recomendables para cualquier cosa que pienses aplicar de inmediato. + +Pasa la página a [Por qué Firefly para Rust](../01-why-firefly.md), donde comienza el viaje de Lumen. diff --git a/docs/book/src-es/00-front/00-copyright.md b/docs/book/src-es/00-front/00-copyright.md new file mode 100644 index 00000000..ba9cdd7b --- /dev/null +++ b/docs/book/src-es/00-front/00-copyright.md @@ -0,0 +1,13 @@ +Copyright © 2026 Firefly Software Foundation. + +Licencia otorgada bajo la Licencia Apache, Versión 2.0 (la "Licencia"); no puede utilizar este material salvo en cumplimiento de la Licencia. Puede obtener una copia de la Licencia en . + +Salvo que la legislación aplicable lo exija o se acuerde por escrito, el software y la documentación distribuidos bajo la Licencia se distribuyen "TAL CUAL", SIN GARANTÍAS NI CONDICIONES DE NINGÚN TIPO, ya sean expresas o implícitas. Consulte la Licencia para conocer el régimen específico de permisos y limitaciones que rige bajo la Licencia. + +--- + +**Primera edición, 2026.** + +Todos los listados de código de este libro se escribieron y verificaron con el framework Firefly para Rust, versión 26.6.x, dirigido a Rust 1.88+ sobre la pila `tokio` + `axum`. + +Publicado por la Firefly Software Foundation. diff --git a/docs/book/src-es/00-front/00-dedication.md b/docs/book/src-es/00-front/00-dedication.md new file mode 100644 index 00000000..606cc5e3 --- /dev/null +++ b/docs/book/src-es/00-front/00-dedication.md @@ -0,0 +1,12 @@ +Para **Ángel Luis Mula**, nuestro CIO — + +quien preguntó, en cada standup, en cada revisión de roadmap y en al menos un +ascensor, «y… ¿cuándo hacemos la versión en Rust?» — hasta que, por fin, la hicimos. + +De parte del equipo que mantiene los sistemas en marcha: gracias por un stack sin +recolector de basura que vigilar, sin avisos a las 3 de la madrugada por un heap +descontrolado y con una factura de la nube que ya no parece una nota de rescate. + +El borrow checker es lo único en este edificio más estricto que tú. + +**Que tus localizadores sigan en silencio y tus p99 sigan planos.** diff --git a/docs/book/src-es/00-front/00-preface.md b/docs/book/src-es/00-front/00-preface.md new file mode 100644 index 00000000..ca2f4cd7 --- /dev/null +++ b/docs/book/src-es/00-front/00-preface.md @@ -0,0 +1,61 @@ +## Prefacio + +Rust te ofrece concurrencia sin miedo, abstracciones de coste cero y un compilador que se niega a publicar una condición de carrera de datos. Lo que no te da es *cohesión*. Cada nuevo servicio de back-office obliga a la misma cascada de decisiones antes de escribir una sola línea de lógica de negocio: qué capa HTTP, qué planteamiento de base de datos, cómo cablear las dependencias, cómo gestionar la configuración, los errores, los identificadores de correlación, las métricas y el apagado ordenado. **Firefly** cambia eso. Es un framework dogmático, de convención sobre configuración, que toma esas decisiones transversales una sola vez, de modo que todos los servicios comparten un mismo idioma — construido desde cero para Rust 1.88+ sobre `tokio` y `axum`. + +Este libro enseña Firefly **mediante ejemplos**. Construyes una aplicación real desde un crate vacío hasta un servicio asegurado, observable y basado en event sourcing — haciendo concreto cada concepto antes de pasar al siguiente. El código de estas páginas no es pseudocódigo ilustrativo: cada listado es una porción de un **proyecto real que compila, arranca y supera sus pruebas** contra la versión 26.6.x del framework. Cada fragmento se ha extraído del ejemplo en ejecución y se ha verificado contra las API de los crates, de modo que lo que lees es lo que realmente funciona. Cuando un listado se desvía de la fuente, la compilación del ejemplo se rompe y una prueba falla — esa es la garantía que respalda cada listado de este libro. + +### Para quién es este libro + +Este libro está dirigido a desarrolladores de Rust de nivel intermedio, cómodos con `async`/`await`, los traits y los fundamentos de los servicios HTTP. No necesitas experiencia previa con ningún framework — si has construido algo con `axum`, `actix` o `sqlx`, estás bien preparado. + +Si has usado antes un framework dogmático y con baterías incluidas, o una biblioteca de reactive streams, los conceptos de Firefly — beans y estereotipos, mensajería declarativa, eventos de aplicación, `Mono`/`Flux` — te resultarán rápidos de asimilar. Aparece un recuadro **Design note** allí donde una idea te resulte familiar, para que puedas apoyarte en lo que ya sabes; cada uno se plantea como una decisión de diseño propia de Firefly, no como una traducción de otro framework. + +### Lo que vas a construir: Lumen + +Cada capítulo hace avanzar **Lumen**, un servicio de monedero digital y libro mayor (ledger) — el ejemplo trabajado en torno al cual se ha construido este libro. Lumen permite a un cliente abrir un monedero, ingresar y retirar dinero, transferir fondos entre monederos y leer un saldo en vivo. Tras esa pequeña superficie se esconde todo el abanico de patrones que necesita un servicio de back-office real: un value object que hace aritmética monetaria exacta, un aggregate que impone invariantes, CQRS con una caché del lado de lectura, eventos de dominio, un libro mayor basado en event sourcing, una saga de transferencia compensatoria, endpoints asegurados con JWT, una superficie de actuator, una tarea programada y una suite de pruebas de extremo a extremo. + +La propiedad más importante de todas en Lumen es su lista de dependencias: + +```toml +[dependencies] +firefly = { version = "26.6.24" } # the whole framework — and every macro +axum = { version = "0.7" } # you author the handler functions +serde = { version = "1", features = ["derive"] } +``` + +**Una única dependencia de Firefly.** El framework completo — CQRS, inyección de dependencias, la pila web reactiva, mensajería basada en eventos, event sourcing, orquestación de sagas, planificación, resiliencia, seguridad, observabilidad — y cada macro `#[derive(...)]` / `#[...]` llegan a través de `use firefly::prelude::*;`. Los capítulos hacen hincapié deliberado en esto: incluso los enums de error tipados de Lumen escriben a mano `Display` + `std::error::Error` en lugar de incorporar `thiserror`, de modo que la promesa de una sola dependencia se mantiene de principio a fin. + +El recorrido sigue un arco deliberado, una porción de Lumen a la vez: + +- **Parte I — Fundamentos.** Montas el primer servicio de Lumen, vinculas configuración tipada y perfiles, aprendes cómo el composition root cablea los colaboradores, dominas la superficie reactiva `Mono`/`Flux` y expones tus primeros endpoints REST validados. +- **Parte II — Modelar y persistir.** Levantas un modelo de lectura tras un repositorio, modelas el dominio con un value object `Money` y un aggregate `Wallet`, y separas las lecturas de las escrituras con manejadores de comandos y consultas CQRS despachados a través de un bus. +- **Parte III — Orientado a eventos.** El aggregate emite eventos de dominio; una proyección `#[event_listener]` mantiene el modelo de lectura al día; y un **libro mayor basado en event sourcing** reconstruye cada saldo plegando (folding) su flujo de eventos — con esos mismos eventos listos para salir hacia Kafka o RabbitMQ. +- **Parte IV — Hacia los microservicios.** Lumen va más allá de su propio proceso: un esbozo de cliente HTTP tipado muestra cómo un monedero llamaría a un proveedor de pagos externo, y una **saga de transferencia** orquestada mueve dinero entre monederos y *compensa* cuando el tramo de abono falla. +- **Parte V — Asegurar · Observar · Publicar.** Aseguras los endpoints con autenticación bearer JWT y RBAC basado en rutas, haces observable el servicio con métricas, trazado y una superficie de administración de actuator, añades una caché del lado de lectura y una tarea programada de mantenimiento, pruebas la pila completa en proceso y, por último, lo publicas tras la CLI de `firefly` con apagado ordenado y un endpoint de streaming reactivo. + +Al llegar a la última página tendrás un servicio funcional, probado, observable, asegurado y basado en event sourcing — y el modelo mental para ampliarlo. + +### Cómo usar este libro + +**Lee en orden.** Cada capítulo se apoya en el anterior, y el código de Lumen crece de forma incremental; saltar adelante deja huecos. El capítulo del **Modelo reactivo** es la piedra angular — toda la superficie reactiva se construye sobre `Mono` y `Flux`, así que léelo antes de los capítulos de construcción del servicio. Los primeros capítulos (1–5) presentan el framework con pequeños fragmentos independientes; **Lumen propiamente dicho empieza en el capítulo 6** y crece a partir de ahí. Cada capítulo es *aditivo* — nunca reescribe lo que entregó un capítulo anterior, solo lo amplía — de modo que el estado final es exactamente el crate de acompañamiento. + +**Escribe tú mismo cada listado.** Leer y escribir código al mismo tiempo es la forma de fijar los patrones. Resiste la tentación de copiar y pegar hasta que hayas escrito cada listado al menos una vez. + +**Ejecútalo.** Lumen se ejecuta de verdad. Desde la raíz del workspace: + +```sh +cargo run -p firefly-sample-lumen # boot the service (API + admin) +cargo test -p firefly-sample-lumen # run the unit + HTTP test suite +``` + +Siempre que un capítulo añada una funcionalidad, arranca la aplicación o las pruebas y obsérvala funcionar. Ver volver JSON real de un endpoint real — y ver cómo una saga *compensa* una transferencia fallida — vale más que cien diagramas. + +Cada capítulo se cierra con un **Resumen** de lo que cambió en el código de Lumen y un conjunto de **Ejercicios** que dan un paso más allá. Los ejercicios son opcionales, pero recomendables para cualquier cosa que pretendas aplicar de inmediato. + +### Convenciones en breve + +Las convenciones tipográficas y estructurales — los pies de los listados de código, los tipos de recuadro y las notas de diseño — se demuestran, con ejemplos en vivo, en la sección de **Convenciones** que sigue a continuación. + +### El código de acompañamiento + +El proyecto Lumen completo y ejecutable reside en el directorio `samples/lumen` del framework. Es un único crate de Firefly limpio — un módulo por cada incumbencia (`money`, `domain`, `ledger`, `commands`, `transfer`, `security`, `web`, `housekeeping`) — que haces crecer capítulo a capítulo; el código terminado que hay allí es el destino al que este libro te lleva. Compílalo una vez con `cargo build -p firefly-sample-lumen` y úsalo para comparar tu trabajo, ponerte al día si te quedas atrás, o simplemente ejecutar las partes sobre las que estás leyendo. El mapa capítulo a capítulo de *qué código aterriza dónde* vive junto a él en `docs/book/LUMEN-ARC.md`. diff --git a/docs/book/src-es/00-front/00-rust-primer.md b/docs/book/src-es/00-front/00-rust-primer.md new file mode 100644 index 00000000..658177c9 --- /dev/null +++ b/docs/book/src-es/00-front/00-rust-primer.md @@ -0,0 +1,466 @@ +## Introducción a Rust + +Este libro construye microservicios reactivos, asíncronos y con event sourcing en Rust. Los capítulos dan por hecho que sabes *leer* Rust con comodidad; esta introducción se asegura de que así sea, incluso si tu trabajo diario es Java/Spring o Python. No es un curso completo de Rust. Enseña exactamente la porción del lenguaje en la que se apoyan los capítulos posteriores, en el orden en que se apoyan en ella, de modo que cuando el Capítulo 6 te entregue un `Mono` o el Capítulo 10 te muestre `#[derive(Command)]`, ninguna de las sintaxis te sorprenda. + +Si ya has desarrollado en Rust con `async`/`await`, traits y ownership, salta hacia delante: el Capítulo 1 es donde empieza Lumen. Si vienes de un lenguaje con recolección de basura, lee esto una vez, escribe los fragmentos a mano y mantenlo como marcador. + +> **Note** — cada bloque de código de esta introducción se compiló y ejecutó antes de imprimirse. Los fragmentos de programa completo se compilan y ejecutan tal y como se muestran; los pocos que omiten la configuración circundante están marcados y usan nombres reales y actuales de la API de Firefly. + +### Cargo, crates, módulos y `use` + +Un proyecto de Rust se construye con **Cargo**, el gestor de paquetes y herramienta de compilación, el equivalente aproximado de Maven/Gradle en el mundo Java o de pip/Poetry en Python. Una unidad de compilación es un **crate**: o bien un binario (una aplicación), o bien una biblioteca. Los crates de los que depende tu proyecto se listan en un fichero de manifiesto llamado `Cargo.toml`: + +```toml +[package] +name = "lumen" +version = "0.1.0" +edition = "2021" + +[dependencies] +firefly = { version = "26.6.24" } # the whole framework +tokio = { version = "1", features = ["full"] } # the async runtime +serde = { version = "1", features = ["derive"] } # (de)serialization +``` + +Cada entrada bajo `[dependencies]` es un crate obtenido de crates.io (el registro de paquetes de Rust, como Maven Central o PyPI). La lista `features` activa partes opcionales de un crate: un interruptor en tiempo de compilación, no una opción en tiempo de ejecución. + +Los comandos del día a día: + +```bash +cargo build # compile the project +cargo run # compile, then run the binary +cargo test # compile and run the tests +cargo check # type-check without producing a binary (fast) +``` + +Dentro de un crate, el código se organiza en **módulos**: espacios de nombres que agrupan elementos relacionados. Accedes al contenido de un módulo mediante una ruta de segmentos separados por `::`, y la palabra clave `use` trae una ruta al ámbito actual para que puedas referirte a ella por su nombre corto: + +```rust +use std::sync::Arc; // bring `Arc` into scope from the standard library +use std::collections::HashMap; + +fn main() { + let map: HashMap = HashMap::new(); // now just `HashMap` + let _shared = Arc::new(map); +} +``` + +> **Spring parity.** `use std::sync::Arc;` es el `import java.util.concurrent.atomic.*;` de Rust. Un crate es un artefacto Maven; un módulo es un paquete Java; `Cargo.toml` es tu `pom.xml`. La gran diferencia es que no hay classpath en tiempo de ejecución: todo se resuelve y enlaza en tiempo de compilación en un único binario. + +A lo largo del libro, todo el framework llega mediante una única importación glob que verás al principio de casi todos los listados: + +```rust,ignore +use firefly::prelude::*; +``` + +Esa única línea trae `Mono`, `Flux`, `Bus`, las macros y el resto de la superficie de uso frecuente. Cuando un capítulo introduce un nuevo tipo del framework, la prosa nombra la ruta de fachada tras la que vive (`firefly::cqrs::Bus`, `firefly::eventsourcing::EventStore`), de modo que siempre sabes que entró a través de esa única dependencia. + +### Variables y tipos básicos + +Las variables se declaran con `let`. Son **inmutables por defecto**; añade `mut` para que una vinculación pueda reasignarse. Los tipos suelen inferirse, pero puedes anotarlos tras dos puntos: + +```rust +fn main() { + let count = 3; // immutable; inferred as i32 + let mut total = 0_i64; // `mut` makes it reassignable; i64 + total += count as i64; // `as` is an explicit numeric cast + println!("total = {total}"); + + let active: bool = true; + let ratio: f64 = 0.5; // 64-bit float + let initial: char = 'L'; +} +``` + +Los tipos numéricos explicitan su tamaño y signo: `i32`/`i64` (con signo), `u32`/`u64` (sin signo), `usize` (del tamaño de un puntero, usado para longitudes e índices), `f32`/`f64` (en coma flotante). Lumen guarda el dinero en céntimos `i64`, nunca en `f64`, de modo que el redondeo nunca puede corromper un saldo. + +> **Spring parity.** `let` declara una variable local como el `var` de Java, pero la inmutabilidad es la opción por defecto, lo contrario de Java, donde debes escribir `final` para optar *por* ella. En Rust escribes `mut` para optar *en contra* de la inmutabilidad. Este valor por defecto es estructural: el compilador lo usa para razonar sobre quién tiene permiso para cambiar qué. + +#### `String` frente a `&str` + +Esta distinción hace tropezar a todos los recién llegados, así que vamos a conocerla pronto. Hay dos tipos de cadena: + +- **`String`** — una cadena en propiedad, asignada en el heap y ampliable. *Posees* el búfer; cuando la `String` sale de ámbito, su memoria se libera. +- **`&str`** — una *vista prestada* (borrowed) de datos de cadena que no posees (una "porción de cadena", o "string slice"). Los literales de cadena como `"wallet"` son `&str`. + +El idioma habitual: **acepta `&str` como parámetro y devuelve `String` cuando produces un nuevo valor en propiedad.** Un `&str` puede tomar prestado de una `String`, de un literal o de cualquier sitio, así que aceptar `&str` hace que una función sea lo más flexible posible. + +```rust +fn greet(name: &str) -> String { // borrow input, return a fresh owned String + format!("hello, {name}") +} + +fn main() { + let owned: String = String::from("Lumen"); + let literal: &str = "wallet"; + println!("{}", greet(&owned)); // pass a &str borrowed from the String + println!("{}", greet(literal)); // or a literal directly +} +``` + +### Ownership, préstamos y referencias + +Este es el concepto que hace que Rust sea *Rust*. Es también el que no tiene equivalente en Java ni en Python, así que lee esta sección despacio. + +Cada valor tiene exactamente un **owner** (propietario): la variable responsable de liberarlo. Cuando el propietario sale de ámbito, el valor se descarta (su memoria y recursos se liberan). No hay ningún recolector de basura que decida *cuándo* ocurre eso; ocurre de forma determinista, al final del ámbito del propietario. + +Cuando asignas un valor a otra variable o lo pasas a una función, la propiedad **se mueve** (move). La vinculación original ya no puede usarse: + +```rust,ignore +let a = String::from("Lumen"); +let b = a; // ownership MOVES from `a` to `b` +println!("{a}"); // COMPILE ERROR: value borrowed here after move +``` + +Si quieres *usar* un valor sin tomar su propiedad, lo tomas prestado (**borrow**) con una referencia. Una referencia se escribe `&T` (un préstamo compartido/inmutable) o `&mut T` (un préstamo exclusivo/mutable). El préstamo permite a una función leer o modificar un valor y después devolverlo: + +```rust +fn len_of(s: &String) -> usize { // shared borrow: may read, not modify + s.len() +} + +fn push_bang(s: &mut String) { // exclusive borrow: may modify + s.push('!'); +} + +fn main() { + let mut name = String::from("Lumen"); + let n = len_of(&name); // &name — a shared borrow + push_bang(&mut name); // &mut name — an exclusive borrow + println!("{name} now has length {}", n + 1); +} +``` + +El compilador impone una regla, la invariante central del **borrow checker**: en cualquier momento puedes tener **o bien** cualquier número de referencias compartidas (`&T`) **o bien** exactamente una referencia exclusiva (`&mut T`) a un valor, nunca ambas. Esa única regla es lo que hace imposibles las condiciones de carrera de datos: nunca puedes tener un hilo leyendo mientras otro escribe, porque el sistema de tipos no permitirá que coexistan dos de tales referencias. + +> **Spring parity.** En Java o Python, los objetos viven en el heap, cada variable es una referencia a ellos y un recolector de basura los reclama en algún momento posterior impredecible. El aliasing es libre y sin verificar: dos hilos pueden sostener alegremente el mismo `ArrayList` y corromperlo. Rust cambia esa libertad por garantías: la propiedad se rastrea en tiempo de compilación, la memoria se libera en el instante en que termina el ámbito del propietario (sin pausas del GC, sin `finalize`), y las reglas de préstamo convierten las condiciones de carrera de datos en un *error de compilación* en lugar de un incidente de producción a las 2 de la madrugada. La frase que oirás es "concurrencia sin miedo" (fearless concurrency): eso es lo que significa. + +> **Tip** — cuando un fragmento hace `Arc::clone(&repo)` o `&self` en lugar de mover un valor, está tomando prestado para no ceder la propiedad. Leer "¿esto es un move o un borrow?" a partir del `&` es la mayor parte de lo que hace falta para seguir el resto del libro. + +### `Arc` — propiedad compartida entre hilos + +A veces un único propietario no basta: un servicio, un repositorio o un objeto de configuración pueden necesitar compartirse entre muchas tareas que se ejecutan de forma concurrente. `Arc` — *Atomically Reference-Counted* (recuento de referencias atómico) — es la respuesta, y lo verás en casi todas las páginas de este libro. Un `Arc` es un manejador compartido y seguro entre hilos a un `T`; clonarlo es barato (incrementa un recuento de referencias, no copia el `T`), y el `T` se descarta solo cuando desaparece el último manejador `Arc`. + +```rust +use std::sync::Arc; + +fn main() { + let config = Arc::new(String::from("lumen-config")); + let handle2 = Arc::clone(&config); // a second handle to the SAME value + println!("{config} / {handle2}"); // both point at one allocation +} +``` + +El framework se apoya en esto con una forma concreta: **`Arc`**, un manejador compartido a *algún* tipo que implementa un trait, donde el tipo concreto queda oculto tras la interfaz. Así es como Firefly hace circular servicios y "puertos" hexagonales, de modo que un handler de wallet puede depender de un `WalletRepository` sin saber si está respaldado por Postgres, MongoDB o un mapa en memoria: + +```rust +use std::sync::Arc; + +trait WalletRepository: Send + Sync { // a "port" + fn balance(&self, id: u64) -> i64; +} + +struct InMemoryRepo; // one "adapter" +impl WalletRepository for InMemoryRepo { + fn balance(&self, _id: u64) -> i64 { 0 } +} + +fn main() { + // The caller holds the interface, not the concrete type: + let repo: Arc = Arc::new(InMemoryRepo); + let also_repo = Arc::clone(&repo); // share it with another task + println!("{}", repo.balance(1)); + println!("{}", also_repo.balance(2)); +} +``` + +`Arc` comparte acceso de *lectura*. Para compartir estado *mutable*, envuelve el valor interno en un cerrojo (lock): `Mutex` (un único accesor a la vez) o `RwLock` (muchos lectores o un único escritor), dando lugar al familiar `Arc>` / `Arc>`. El cerrojo impone la regla del "único escritor" en tiempo de ejecución para datos que el borrow checker no puede rastrear entre hilos: + +```rust +use std::sync::{Arc, Mutex}; + +fn main() { + let counter = Arc::new(Mutex::new(0_u64)); + *counter.lock().unwrap() += 1; // lock, mutate, unlock at end of statement + println!("{}", *counter.lock().unwrap()); +} +``` + +### Structs, enums y `match` + +Un **struct** agrupa campos con nombre: tus registros de datos y objetos de valor. Los métodos se definen en un bloque `impl`; `&self` toma prestado el receptor (lectura), `&mut self` lo toma prestado de forma exclusiva (mutación), y un método sin `self` es una función asociada (el "método estático" de Rust), usada comúnmente para constructores: + +```rust +struct Wallet { + id: u64, + balance: i64, +} + +impl Wallet { + fn new(id: u64) -> Self { // associated fn (constructor by convention) + Wallet { id, balance: 0 } + } + fn deposit(&mut self, cents: i64) { // mutating method + self.balance += cents; + } +} + +fn main() { + let mut w = Wallet::new(1); + w.deposit(500); + println!("wallet {} has {} cents", w.id, w.balance); +} +``` + +Un **enum** es mucho más potente que un enum de Java: cada variante puede llevar sus propios datos. Los enums modelan "una de varias formas", que es exactamente como el libro representa comandos, eventos y estados: + +```rust +enum Command { + Open { id: u64 }, // a struct-like variant + Deposit { id: u64, cents: i64 }, // with named fields + Close, // a unit variant, no data +} +``` + +Descompones un enum con `match`: un switch exhaustivo y con valor de expresión. **Exhaustivo** significa que el compilador se niega a compilar a menos que gestiones cada variante, de modo que añadir un nuevo comando más adelante te obliga a actualizar cada lugar que haga match sobre él: + +```rust,ignore +fn describe(cmd: &Command) -> String { + match cmd { + Command::Open { id } => format!("open wallet {id}"), + Command::Deposit { id, cents } => format!("deposit {cents} into {id}"), + Command::Close => "close".to_string(), + } +} +``` + +`match` es una forma de **coincidencia de patrones** (pattern matching), que también aparece en la desestructuración con `let`, en `if let` y en los parámetros de función. Leerás patrones constantemente; reconocer que el lado izquierdo de un `=>` *vincula nombres desestructurando el valor* es la clave. + +### `Option`, `Result` y el operador `?` + +Rust no tiene `null` ni excepciones. La ausencia y el fallo son valores ordinarios, expresados mediante dos enums estándar. + +**`Option`** dice "quizá un `T`": es o bien `Some(value)` o bien `None`. Esto sustituye a `null`/`None`/`nil`, y como es un tipo distinto, no puedes usar accidentalmente un valor ausente como si estuviera presente. + +```rust +fn first_char(s: &str) -> Option { + s.chars().next() // None if the string is empty +} + +fn main() { + match first_char("Lumen") { + Some(c) => println!("first char is {c}"), + None => println!("string was empty"), + } +} +``` + +**`Result`** dice "un `T` o un error `E`": es o bien `Ok(value)` o bien `Err(error)`. Así es como cada operación que puede fallar informa del fallo; no hay excepciones lanzadas que capturar: + +```rust +#[derive(Debug)] +struct ParseError; + +fn parse_amount(s: &str) -> Result { + s.parse::().map_err(|_| ParseError) // convert the std error into ours +} + +fn main() { + println!("{:?}", parse_amount("500")); // Ok(500) + println!("{:?}", parse_amount("oops")); // Err(ParseError) +} +``` + +Gestionar cada `Result` a mano sería tedioso, así que Rust te ofrece el **operador `?`**. Aplicado a un `Result`, `?` desenvuelve el valor `Ok` o, en caso de `Err`, *devuelve ese error de la función actual de inmediato*. Propaga los fallos hacia arriba por la pila de llamadas sin excepciones y sin código repetitivo: + +```rust +#[derive(Debug)] +struct ParseError; + +fn parse_amount(s: &str) -> Result { + s.parse::().map_err(|_| ParseError) +} + +fn double_amount(s: &str) -> Result { + let n = parse_amount(s)?; // on Err, return early; on Ok, bind n + Ok(n * 2) +} + +fn main() { + println!("{:?}", double_amount("21")); // Ok(42) + println!("{:?}", double_amount("nope")); // Err(ParseError) +} +``` + +> **Spring parity.** Donde Java lanza y captura, y Python eleva (raise) y captura (except), Rust *devuelve* los errores como valores y los propaga con `?`. La firma de una función te dice de antemano si puede fallar (`-> Result`) y con qué; el fallo forma parte del tipo, no es una ruta de control de flujo oculta. Firefly fija el tipo de error de sus capas reactiva y web a un único `FireflyError`, de modo que el operador `?` propaga los fallos directamente a través de un pipeline y los saca como una respuesta de problema según RFC 9457. + +### Traits e `impl` — las interfaces de Rust + +Un **trait** define comportamiento compartido: un conjunto de firmas de método que un tipo puede prometer proporcionar. Es lo más parecido que tiene Rust a una interfaz de Java o a un protocolo/ABC de Python. Implementas un trait para un tipo con `impl Trait for Type`, y un trait puede aportar cuerpos de método por defecto: + +```rust +trait Greeter { + fn greet(&self) -> String; // required method + fn shout(&self) -> String { // default method, built on the required one + self.greet().to_uppercase() + } +} + +struct English; +impl Greeter for English { + fn greet(&self) -> String { + "hello".to_string() + } +} + +fn main() { + let e = English; + println!("{} / {}", e.greet(), e.shout()); // hello / HELLO +} +``` + +Cuando quieres un valor cuyo tipo concreto se decide en tiempo de ejecución ("cualquier `Greeter`, me da igual cuál"), usas un **trait object** (objeto de trait), escrito `dyn Trait` y siempre tras un puntero como `Box` o el `Arc` que conociste antes. Esto es despacho dinámico, el mismo mecanismo que una referencia a interfaz de Java: + +```rust +trait Greeter { + fn greet(&self) -> String; +} +struct English; +impl Greeter for English { + fn greet(&self) -> String { "hello".to_string() } +} + +fn main() { + let greeters: Vec> = vec![Box::new(English)]; + for g in &greeters { + println!("{}", g.greet()); + } +} +``` + +#### `#[derive(...)]` — macros que escriben los bloques `impl` por ti + +Un atributo `#[derive(...)]` es una **macro** que autogenera una implementación de trait en tiempo de compilación, ahorrándote el código repetitivo. `#[derive(Debug)]` escribe una impl de `Debug` (para que `{:?}` pueda imprimir el valor); `#[derive(Clone)]` escribe `Clone`; `#[derive(PartialEq)]` escribe `==`: + +```rust +#[derive(Debug, Clone, PartialEq)] +struct Money { + cents: i64, +} + +fn main() { + let a = Money { cents: 500 }; + let b = a.clone(); // Clone derived + assert_eq!(a, b); // PartialEq derived + println!("{a:?}"); // Debug derived -> Money { cents: 500 } +} +``` + +Esta es exactamente la maquinaria que hay detrás de las propias macros de Firefly. Cuando un capítulo posterior escribe `#[derive(Command)]` sobre un struct, la macro derive `Command` del framework genera el cableado que permite que ese struct se despache a través del `Bus` de CQRS: tú escribes los datos, la macro escribe la fontanería. Los atributos `#[rest_controller]`, `#[event_listener]` y `#[saga]` que conocerás son la misma idea: código que escribe código en tiempo de compilación. + +### Genéricos y lifetimes — lo justo + +Los **genéricos** permiten que una pieza de código funcione sobre muchos tipos, escritos con parámetros de tipo `` y acotados por los traits que el código necesita. Ya has usado *tipos* genéricos: `Vec`, `Option`, `Mono`. Aquí tienes una *función* genérica; la cota `T: PartialOrd + Copy` significa "cualquier `T` que puedas comparar y copiar": + +```rust +fn largest(items: &[T]) -> T { + let mut max = items[0]; + for &x in items { + if x > max { max = x; } + } + max +} + +fn main() { + println!("{}", largest(&[3, 7, 2])); // works on i32 + println!("{}", largest(&[1.0, 9.5])); // and on f64 +} +``` + +Los **lifetimes** (tiempos de vida) son la parte del sistema de tipos que rastrea *cuánto tiempo es válida una referencia*, de modo que el compilador pueda demostrar que un préstamo nunca sobrevive a los datos a los que apunta. Se escriben con un apóstrofo inicial, como `'a`, y la mayoría de las veces el compilador los infiere y nunca escribes uno. Cuando sí ves uno —por ejemplo en una función que devuelve una referencia tomada prestada de sus entradas—, lee `'a` como "vive al menos tanto como": + +```rust +fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { + if a.len() >= b.len() { a } else { b } +} + +fn main() { + println!("{}", longest("wallet", "ledger")); // -> "wallet" +} +``` + +> **Note** — no necesitas *escribir* genéricos ni lifetimes para leer este libro; necesitas no alarmarte por `` ni por `'a` cuando aparezcan en una firma. `'static` es el único lifetime con nombre que merece la pena recordar: significa "válido durante toda la ejecución del programa", y lo verás como una cota (`T: Send + 'static`) en valores que se entregan a tareas asíncronas. + +### Closures + +Un **closure** (cierre) es una función anónima escrita con `|params| body`. Puede capturar variables del ámbito circundante. Los closures están por todas partes en los pipelines reactivos: cada `.map(...)`, `.filter(...)` y callback de paso recibe uno: + +```rust +fn main() { + let add_one = |x: i32| x + 1; // a closure bound to a name + println!("{}", add_one(41)); // 42 + + let xs = vec![1, 2, 3, 4]; + let evens: Vec = xs.iter() + .copied() + .filter(|x| x % 2 == 0) // a closure passed to filter + .collect(); + println!("{evens:?}"); // [2, 4] +} +``` + +Por defecto, un closure *toma prestado* lo que captura. Prefíjalo con `move` para que *tome la propiedad* de sus capturas en su lugar, algo esencial cuando el closure sobrevive al ámbito actual, como cuando se envía a otro hilo o a una tarea asíncrona: + +```rust +fn main() { + let label = String::from("amount"); + let print_it = move || println!("{label}"); // `move`: closure now owns `label` + print_it(); +} +``` + +> **Spring parity.** Un closure es la lambda de Rust: `|x| x + 1` es el `x -> x + 1` de Java o el `lambda x: x + 1` de Python. El matiz es la captura: `move` es como dices "captura por valor", algo a lo que recurres constantemente al entregar trabajo a tareas de `tokio`, porque la tarea puede ejecutarse después de que la función que la lanzó haya retornado. + +### Async, `await` y `tokio` + +Todo en Firefly es **asíncrono**. Una `async fn` no se ejecuta cuando la llamas; en su lugar devuelve un **`Future`**: una descripción perezosa (lazy) de un trabajo que produce un valor más tarde. No ocurre nada hasta que ese future se *conduce* (driven), lo cual haces con `.await`. Esperar un future con `await` suspende la tarea actual hasta que el valor está listo, liberando el hilo para hacer otro trabajo entretanto: así es como un puñado de hilos puede atender miles de conexiones concurrentes. + +```rust,ignore +async fn fetch_balance(id: u64) -> i64 { // returns a Future + // ... awaits a database, an HTTP call, etc. ... + id as i64 * 100 +} + +async fn report(id: u64) { + let bal = fetch_balance(id).await; // suspend until the value is ready + println!("balance: {bal}"); +} +``` + +Los futures necesitan un **runtime** que los conduzca. Firefly usa **`tokio`**, el runtime asíncrono de facto para Rust. Rara vez lo tocas directamente, pero verás sus macros de atributo marcando los puntos de entrada: `#[tokio::main]` convierte una `async fn main` en un programa real, y `#[tokio::test]` hace lo mismo para un test asíncrono: + +```rust +async fn fetch_balance(id: u64) -> i64 { + id as i64 * 100 +} + +#[tokio::main] +async fn main() { + let bal = fetch_balance(7).await; + println!("balance: {bal}"); // balance: 700 +} +``` + +> **Spring parity.** Si `Mono`/`Flux` y los reactive streams te resultan familiares, ya tienes la intuición correcta. El `Future` de Rust es la primitiva de más bajo nivel que produce una `async fn`, y `tokio` es el planificador que los ejecuta; piensa en él como el bucle de eventos. Firefly superpone sus propios tipos reactivos, **`Mono`** (0 o 1 valor) y **`Flux`** (0..N valores), *encima de* esta base de `async`/`await`: un `Mono` es el análogo reactivo de "una función asíncrona que devuelve un `T`", y un `Flux`, el de "un flujo asíncrono de `T`". Son perezosos, componibles y conscientes de la contrapresión (backpressure), y son la piedra angular del framework. El capítulo del Modelo Reactivo es donde los conocerás en su totalidad. + +### Cómo leer el resto del libro + +Ese es todo el conjunto de herramientas. Con ownership y préstamos, `Option`/`Result` y `?`, traits y `#[derive]`, closures, y `async`/`await` a tu disposición, todo listado posterior es legible. + +Unos cuantos recordatorios válidos para todo el libro: + +- **Los listados son reales.** Cada fragmento a partir del Capítulo 1 está extraído del crate complementario ejecutable de Lumen en `samples/lumen`; la compilación se rompe si un listado se desvía del fuente. Lo que lees es lo que compila y se ejecuta. +- **Una sola importación hace casi todo el trabajo.** Los listados empiezan con `use firefly::prelude::*;`, que trae toda la superficie de uso frecuente —`Mono`, `Flux`, `Bus` y las macros— a través de la única dependencia `firefly` de Lumen. +- **Un fragmento marcado con `ignore` omite la configuración por enfoque.** Cuando un listado omite el código circundante para mantener tu atención en una sola idea, se marca como tal; los nombres de la API, los tipos y las firmas que contiene son exactamente lo que exponen los crates. + +Pasa a [Convenciones](./00-conventions.md) para los detalles tipográficos, y después comienza el viaje de Lumen en [Por qué Firefly para Rust](../01-why-firefly.md). diff --git a/docs/book/src-es/00-front/00-title.md b/docs/book/src-es/00-front/00-title.md new file mode 100644 index 00000000..773c420f --- /dev/null +++ b/docs/book/src-es/00-front/00-title.md @@ -0,0 +1,5 @@ +# Firefly para Rust con ejemplos {.chtitle} + +### Microservicios Rust orientados a eventos con el framework Firefly + +**Firefly Software Foundation** diff --git a/docs/book/src-es/01-why-firefly.md b/docs/book/src-es/01-why-firefly.md new file mode 100644 index 00000000..21253eca --- /dev/null +++ b/docs/book/src-es/01-why-firefly.md @@ -0,0 +1,526 @@ +# Por qué Firefly para Rust + +Cada servicio de este libro es **Lumen**: el servicio de monedero digital y libro +mayor que harás crecer, capítulo a capítulo, hasta convertirlo en el crate +completo +[`samples/lumen`](https://github.com/fireflyframework/fireflyframework-rust/tree/main/samples/lumen). +Antes de andamiarlo en el siguiente capítulo, este responde a la pregunta que +subyace a todo el proyecto: *¿por qué un servicio en Rust necesita un framework +siquiera, y por qué este?* Todavía no aterriza nada de código en Lumen. Al +terminar entenderás el problema que resuelve Firefly, la única dependencia a +través de la cual llega, las capas que viven detrás de esa dependencia y la +decisión de diseño concreta —el intercambio de adaptadores de en memoria a +producción— en torno a la cual se construye el resto del libro. + +Este es un capítulo para leer y orientarse, no para teclear a la par, pero no es +vago: cada término que conocerás durante los próximos diecinueve capítulos se +define aquí desde primeros principios, y cada afirmación es algo que puedes +verificar contra el crate real `samples/lumen` en los ejercicios finales. + +Al terminar este capítulo, serás capaz de: + +- Explicar el **problema de cohesión** que un framework con criterio existe para + resolver, y por qué Rust en particular acusa su ausencia. +- Describir lo que Firefly *es* —un framework cohesivo, reactivo y nativo de + async— y nombrar las bibliotecas probadas en producción a las que delega por + debajo. +- Leer el `Cargo.toml` real de Lumen y explicar por qué un servicio tan rico + depende de exactamente **un** crate de Firefly: la fachada `firefly`. +- Mapear las cuatro **capas** que hay tras la fachada (fundacional → plataforma → + adaptadores → starters) y decir dónde vive cada capacidad. +- Describir el **intercambio de adaptadores**: cómo Lumen pasa de una línea base + en memoria a un despliegue de producción cambiando el cableado, no la lógica de + negocio. + +## Conceptos que conocerás + +Antes de la prosa, aquí tienes las cuatro ideas en las que se apoya este +capítulo. Cada una se reintroduce en su contexto allí donde aparece por primera +vez; esta es la versión breve, para que las secciones posteriores se lean rápido. + +> **Note** **Término clave — framework frente a biblioteca.** Una *biblioteca* es +> código que tú llamas: mantienes el control del flujo de ejecución y recurres a +> la biblioteca cuando la necesitas. Un *framework* es código que te llama a ti: +> posee el ciclo de vida —arranque, despacho de peticiones, apagado— e invoca las +> piezas pequeñas que tú aportas. Esta inversión es la razón de ser de Firefly, y +> es exactamente la relación que Spring Boot tiene con un servicio Java. + +> **Note** **Término clave — crate fachada.** Una *fachada* es un único crate que +> reexporta toda una familia de crates (y sus macros) de modo que dependes de un +> solo nombre en vez de muchos. Firefly distribuye todo su framework tras la +> fachada `firefly`. El equivalente en Spring es un *starter* de Spring Boot, +> salvo que aquí hay esencialmente una única puerta de entrada que lo cubre todo. + +> **Note** **Término clave — puerto y adaptador.** Un *puerto* es una capacidad +> abstracta expresada como un trait —"algo que almacena eventos", "algo que +> publica mensajes"— sin implementación. Un *adaptador* es una implementación +> concreta de ese puerto: un almacén en memoria, un almacén PostgreSQL, un broker +> Kafka. Escribes tu código contra el puerto; eliges el adaptador en el momento +> del cableado. Este es el vocabulario de la arquitectura hexagonal y se +> corresponde con el modismo de interfaz-más-bean de Spring. + +> **Note** **Término clave — bean y cableado.** Un *bean* es un objeto que el +> framework construye y gestiona por ti, y luego entrega a quien lo necesite. El +> *cableado* es el acto de conectar beans entre sí, dando a cada uno los +> colaboradores de los que depende. Tú declaras los beans; el framework los +> descubre y los cablea en el arranque. Es exactamente la noción de bean de Spring +> dentro de un contexto de aplicación. + +## Paso 1 — Reconocer el problema de cohesión + +Imagina tu primer día en un nuevo microservicio en Rust. Antes de escribir una +sola línea de lógica de negocio, te enfrentas a una cascada de decisiones. ¿Qué +capa HTTP: axum, actix, warp, poem? ¿Qué planteamiento de base de datos: sqlx, +SeaORM, diesel, `tokio-postgres` en crudo? ¿Cómo cableas las dependencias: un +`AppState` hecho a mano, un crate de DI, statics perezosos? ¿Cómo gestionas la +configuración, los errores, los identificadores de correlación, las métricas, el +apagado ordenado? Cada equipo inventa su propia respuesta. + +Ensamblas una pila a medida, la pegas con buenas intenciones y la despliegas. +Seis meses después un segundo equipo arranca un segundo servicio y toma +decisiones completamente distintas. Ahora tienes dos bases de código con +convenciones incompatibles, formas de error distintas, enfoques de observabilidad +distintos y ningún entendimiento compartido de cómo funciona nada. + +**Rust te da elección infinita. Lo que no te da es cohesión.** + +Lo que acaba de ocurrir: has nombrado el problema. El impuesto de ensamblar la +pila no es un fallo de competencias, es una carencia de herramientas. Los +ecosistemas maduros la cerraron con un único framework con criterio y con pilas +incluidas que toma decisiones sensatas, te deja anular lo que importa e impone un +modismo coherente en todos los servicios. + +> **Design note.** Esta es la inversión framework-frente-a-biblioteca en la +> práctica. Un montón de bibliotecas te deja a *ti* sosteniendo el ciclo de vida: +> tú decides cuándo se enlaza el servidor HTTP, cómo se carga la configuración, +> dónde se convierten los errores en respuestas. Un framework toma esas decisiones +> transversales una sola vez, de modo que cada servicio que lo usa comparte un +> mismo modismo, y un operador que aprende un servicio Firefly puede leerlos +> todos. + +Firefly es ese framework para Rust. Toma las decisiones transversales una sola +vez, de modo que cada servicio comparte un mismo modismo, y el coste de arrancar +el servicio número dos deja de ser una nueva ronda de debates de arquitectura. + +> **Tip** **Punto de control.** Puedes enunciar el problema en una frase: *Rust +> ofrece elección infinita pero ninguna cohesión integrada, y un framework con +> criterio aporta la cohesión que falta.* Si esa frase te parece obvia, el resto +> del libro se leerá como "así es como Firefly la aporta". + +## Paso 2 — Entender qué es Firefly (y a qué delega) + +Firefly es un **framework cohesivo, reactivo y nativo de async** para construir +servicios en Rust de nivel de producción. Toma las decisiones transversales por +ti —middleware HTTP, configuración, caché, segregación de responsabilidades entre +comandos y consultas (CQRS), mensajería, seguridad, observabilidad—, todo +integrado, todo coherente, con valores por defecto listos para producción desde +el primer `cargo run`. + +> **Note** **Término clave — reactivo (`Mono` / `Flux`).** *Reactivo* significa +> aquí un modelo de streaming perezoso, componible y consciente de la +> contrapresión. Un `Mono` es un cómputo asíncrono que produce *como mucho un* +> valor; un `Flux` produce *cero o más* a lo largo del tiempo. Están +> construidos de forma nativa sobre Tokio y funcionan de extremo a extremo: desde +> endpoints reactivos, pasando por repositorios reactivos, el cliente HTTP +> reactivo y la mensajería reactiva. Si has usado Project Reactor en el mundo de +> Spring, estos son los mismos dos tipos con los mismos nombres. Los dominarás en +> [el modelo reactivo](./05-reactive-model.md). + +Firefly no reinventa la rueda por debajo. **Delega en bibliotecas probadas en +producción**: `tokio` para el runtime, `axum`/`tower` para HTTP, `serde` para la +serialización, `tracing` para el logging estructurado, RustCrypto para la +criptografía. El giro está en la dirección en que dependes de ellas: + +- Dependes de los **puertos de Firefly** —traits `async_trait` seguros para + objetos (object-safe)— para capacidades transversales como el almacenamiento de + eventos y la mensajería. +- Seleccionas **adaptadores concretos** en el momento del cableado, como un + `Arc`. + +Gracias a esa indirección puedes cambiar un almacén de eventos en memoria por +PostgreSQL, o el broker en proceso por Kafka, sin tocar una sola línea de lógica +de negocio: exactamente el intercambio que Lumen está estructurado para hacer y +al que vuelve el Paso 5. + +Los principios definitorios de Firefly, cada uno de los cuales un capítulo +posterior concreta: + +- **Compuesto, no construido.** Una línea arranca todo el servicio. + `FireflyApplication::new("lumen").run()` escanea por componentes tus beans, + autocablea y automonta los controladores, handlers, listeners y tareas + programadas, autoaloja un panel de administración y sirve los puertos público y + de gestión con apagado ordenado: el framework ensambla el grafo de objetos en + lugar de que tú lo deletrees a mano. Tú escribes comandos, consultas, handlers y + rutas; nada más. [Inicio rápido](./02-quickstart.md) recorre esta línea etapa + por etapa. +- **Primero el contrato e interoperable.** El contrato de cable —la forma de error + `application/problem+json` (RFC 9457), la semántica de `Idempotency-Key`, las + definiciones de los pasos de la saga, los sobres de eventos— es una + especificación estable, versionada y neutral respecto al lenguaje. Cualquier + servicio que lo respete interopera con un servicio Firefly byte a byte, de modo + que Firefly encaja en una flota políglota sin pegamento a medida. +- **Enchufable en la capa de adaptadores.** Cada punto de integración (caché, + broker, proveedor de identidad, almacén de contenidos, canal de notificación) es + un puerto con múltiples implementaciones de adaptador, seleccionadas en el + momento del cableado como un `Arc`. +- **Observable por defecto.** El logging estructurado con `tracing` y + enriquecimiento con identificador de correlación, los endpoints de salud y + métricas del actuator, los sobres de error RFC 9457 y un banner de arranque + están todos activados desde el primer momento. +- **Reactivo hasta el núcleo.** La superficie `Mono`/`Flux` corre desde los + endpoints hasta los repositorios, el cliente HTTP y la mensajería: perezosa, + componible y consciente de la contrapresión. + +> **Note** **Término clave — respuestas de problema RFC 9457.** El RFC 9457 (que +> deja obsoleto al RFC 7807) define `application/problem+json`: una forma JSON +> estándar para errores HTTP con un `type`, un `title`, un `status` y un `detail`. +> Firefly renderiza automáticamente todo error de handler con esta forma, de modo +> que tu API habla un único dialecto de error desde el primer endpoint. Lo +> conocerás de verdad en [Tu primera API HTTP](./06-first-http-api.md). + +> **Design note.** `FireflyApplication::new(name).run()` es la raíz de composición +> de Firefly, el equivalente en Rust de `SpringApplication.run(App.class, args)` +> de Spring Boot. Levanta el middleware, el bus, el broker, la salud y las +> métricas, y luego escanea por componentes y cablea tus beans, todo desde una +> línea. La configuración se superpone por defectos → perfil → entorno, y +> cualquier handler puede devolver un `Mono` / `Flux`. Si has usado antes un +> framework con pilas incluidas, esto te resultará familiar. + +> **Tip** **Punto de control.** Puedes nombrar dos cosas a la vez: *qué* te da +> Firefly (una pila cohesiva, reactiva y observable) y *sobre qué se apoya* +> (tokio, axum, serde, tracing, RustCrypto). Firefly es la capa de cohesión, no +> una reimplementación desde cero. + +## Paso 3 — Leer la fachada de una sola dependencia + +Aquí está la parte que sorprende a la gente. Lumen —un servicio con segregación +de responsabilidades entre comandos y consultas (CQRS), event sourcing, una saga, +seguridad JWT, programación de tareas y una superficie de actuator— declara +exactamente una dependencia de Firefly. Esta es la forma de su `Cargo.toml` real: + +```toml +[dependencies] +# The whole framework AND every `#[derive(...)]` / `#[...]` macro. The `admin` +# feature pulls in the self-hosted admin dashboard the management port mounts. +firefly = { version = "26.6.28", features = ["admin"] } + +# The two ecosystem crates a Firefly service still writes against directly: +# axum (you author the controller handlers) and serde (your messages and +# event payloads are Serialize/Deserialize); serde_json encodes the event +# payloads. +axum = { version = "0.7" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Async runtime, plus the id/clock crates the wallet domain uses. +tokio = { version = "1" } +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } + +# Backs the `async fn` trait methods the domain ports implement. +async-trait = { version = "0.1" } +``` + +Lo que acaba de ocurrir, bloque a bloque: + +- La primera línea —`firefly = { version = "26.6.28", features = ["admin"] }`— es + el *framework entero*. Cada capacidad y cada macro llega a través de ella. +- El bloque `axum` / `serde` / `serde_json` es la pequeña superficie contra la que + todavía escribes *directamente*: tú redactas las funciones handler de los + controladores sobre `axum`, y tus mensajes y payloads de eventos derivan + `Serialize`/`Deserialize` de `serde`. +- El bloque `tokio` / `uuid` / `chrono` es el runtime y los crates de + id/reloj a los que recurre el dominio del monedero: ids de monedero y marcas de + tiempo de eventos. +- `async-trait` respalda los métodos `async fn` de los traits de puerto del + dominio. + +Fíjate en lo que *no* está ahí: ningún `firefly-web`, ningún `firefly-cqrs`, +ningún `firefly-security`. Nunca listas a mano un subcrate `firefly-*`. + +> **Note** **Término clave — glob del prelude.** Un *prelude* es un módulo con los +> elementos más usados que un crate te invita a importar de una sola vez con un +> glob (`use … ::*`). La superficie de alta frecuencia de Firefly —más todas sus +> macros— entra a través de una única línea: +> +> ```rust,ignore +> use firefly::prelude::*; +> ``` +> +> Esa única importación da a Lumen el `Bus` de CQRS, el `Container` de inyección +> de dependencias, el `Scheduler`, los tipos de orquestación `Saga`/`Step`, la +> `Application` del ciclo de vida, los `Mono`/`Flux` reactivos, los tipos web +> `WebResult`/`WebError`, el error de kernel `FireflyError` y cada macro +> `#[derive(...)]` / `#[...]` que el servicio usa. Los desarrolladores de Spring +> reconocerán el movimiento: una sola importación en lugar de una página entera de +> ellas. + +Lumen lleva la disciplina un paso más allá. Incluso sus enums de error tipados +—`MoneyError`, `DomainError` y el mapeo `CqrsError`— escriben a mano `Display` y +`std::error::Error` en lugar de recurrir a `thiserror`. La promesa de una sola +dependencia se mantiene de extremo a extremo, y los capítulos lo señalan allí +donde importa. + +> **Design note.** La fachada `firefly` es un único crate de puerta de entrada: +> una sola coordenada en tu lista de dependencias arrastra una pila curada y +> alineada por versión de calendario, y `use firefly::prelude::*;` trae toda la +> superficie de alta frecuencia y cada macro al ámbito de una vez. Muchos +> frameworks te obligan a ensamblar una constelación de artefactos de starter o +> plugin y a mantener sus versiones alineadas a mano. Firefly colapsa todo eso en +> una línea: no hay starter que olvidar ni desfase de versiones entre subsistemas +> como `firefly-web` y `firefly-cqrs`, porque cada crate `firefly-*` se distribuye +> como una única release versionada por calendario —aquí `26.6.28`— y tú dependes +> de la fachada. + +> **Tip** **Punto de control.** Puedes señalar la única línea de Firefly en un +> `Cargo.toml` real y explicar las demás entradas como el puñado de crates del +> ecosistema contra los que un servicio Firefly escribe directamente. El Ejercicio +> 1 te hace confirmar esto tú mismo contra `samples/lumen/Cargo.toml`. + +## Paso 4 — Mapear las capas que hay tras la fachada + +Detrás de ese único crate, el framework está organizado en capas estrictamente +estratificadas, con una dirección de dependencia de izquierda a derecha. Cada capa +puede depender de las capas a su izquierda, nunca a su derecha; el grafo de crates +de Cargo impone la estratificación. Rara vez nombras estos crates directamente +—la fachada los reexporta— pero conocer la forma te dice dónde vive cada capacidad +y *qué* capítulo del libro la desbloquea. + +
+ +firefly + firefly-macrosone dependency · use firefly::prelude::*; + + +Tier 1 +Foundational +kernel +reactive +web +config +container +i18n + + + + +Tier 2 +Platform +cqrs +eda +event-sourcing +orchestration +cache +security + + + + +Tier 3 +Adapters +data-sqlx +data-mongodb +eda-kafka +cache-redis +idp-* +notif-* + + + + +Tier 4 +Starters +starter-core +starter-web +starter-domain +starter-data +admin +cli + +firefly-reactivethe Mono / Flux core every tier rests on (tokio · axum) + +
Las cuatro capas. Un servicio depende únicamente de la fachada firefly (la puerta de entrada). Las capas se construyen de izquierda a derecha: el vocabulario Fundacional, los motores de Plataforma que definen los puertos, los Adaptadores que los implementan y los Starters que componen y distribuyen, cada uno dependiendo solo de las capas a su izquierda, todos apoyados sobre el núcleo firefly-reactive.
+
+ +Un servicio depende únicamente de la fachada `firefly` (la puerta de entrada). +Las cuatro capas se construyen de izquierda a derecha —cada una dependiendo solo +de las capas a su izquierda— todas apoyadas sobre el núcleo `Mono`/`Flux` de +`firefly-reactive`. + +- Los crates **Fundacionales** son el vocabulario: `firefly-kernel` (errores, + reloj, ámbitos de correlación, el kit de DDD), `firefly-reactive` + (`Mono`/`Flux`), `firefly-web` (middleware), `firefly-config`, + `firefly-validators`, `firefly-i18n` y `firefly-container` —un motor completo de + inyección de dependencias con escaneo de componentes y derives de estereotipo, + tratado en profundidad en [Cableado de dependencias](./04-dependency-wiring.md). +- Los crates de **Plataforma** son las capacidades: caché, segregación de + responsabilidades entre comandos y consultas (CQRS), arquitectura orientada a + eventos, event sourcing, orquestación, programación de tareas, resiliencia, + seguridad, observabilidad. Lumen recurre a `firefly::cqrs`, + `firefly::eventsourcing`, `firefly::orchestration`, `firefly::scheduling` y + `firefly::security`. Y crucial: esta capa *define los puertos* —los traits + `EventStore`, `Broker`, `cache::Adapter` y `security::Verifier`— que implementa + la capa siguiente. +- Los **Adaptadores** son las integraciones concretas: el cliente HTTP + REST/reactivo, los proveedores de identidad de terceros, los almacenes de + contenidos, las notificaciones, los transportes de eventos (Kafka, RabbitMQ, + outbox de Postgres, Redis Streams) y los adaptadores de persistencia + —`firefly-data-sqlx` para almacenes relacionales, `firefly-data-mongodb` para + documentos. Este es un enfoque enchufable y multibase de datos sobre el que se + construye [Persistencia](./07-persistence.md). Lumen se distribuye sobre los + adaptadores en memoria y apunta a los intercambios de producción en sus + llamadas de atención. +- Los **Starters** empaquetan una pila por defecto sensata para que un servicio + dependa de un solo crate. La capa web de Lumen es + `firefly::starter_web::WebStack`, que cablea el núcleo + (`firefly::starter_core`) más el middleware web: la pila que + `FireflyApplication` construye por ti en el arranque. + +> **Note** **Término clave — superficie de actuator / gestión.** La *superficie de +> gestión* es un conjunto de endpoints HTTP operativos —comprobaciones de salud, +> información de build, métricas, introspección de configuración y de beans— que +> existen para los operadores y las herramientas, no para los usuarios finales. +> Firefly los sirve en un puerto *separado* de tu API de negocio, de modo que los +> endpoints operativos nunca se filtran a la red pública. Esto refleja Spring Boot +> Actuator, y lo alcanzas por primera vez en [Inicio rápido](./02-quickstart.md). + +Para el catálogo completo por crate, consulta el +[Índice de módulos](./91-appendix-modules.md). + +> **Tip** **Punto de control.** Dada una capacidad —"¿dónde vive el almacenamiento +> de eventos?"— puedes situarla en una capa (es un puerto de *plataforma*, +> implementado por un *adaptador*) y nombrar el capítulo que la introduce. Las +> capas son un mapa; el resto del libro es un recorrido por él. + +## Paso 5 — Entender el intercambio de adaptadores + +Esta es la única decisión de diseño sobre la que gira todo el libro, así que +merece su propio paso. Lumen funciona con **cero infraestructura externa**: eso es +lo que lo hace una buena línea base didáctica y un objetivo de pruebas rápido. +Arranca sobre el `MemoryEventStore` en proceso y el broker en proceso, de modo que +`cargo run` y `cargo test` no necesitan nada más que el crate. Ningún Postgres que +arrancar, ningún Kafka que aprovisionar. + +Cuando estés listo para producción, cambias el *cableado*, no los handlers. Cada +uno de los intercambios siguientes es una edición en un solo sitio, en la costura +donde se construye el `Arc`: + +- **Almacén de eventos.** Cambia `MemoryEventStore` por un adaptador duradero + donde se construye el `Arc`; el `Ledger`, la proyección y cada + handler de comando quedan intactos. +- **Transporte de eventos.** El broker en proceso que transporta los eventos de + dominio de Lumen implementa el mismo puerto `Broker` que `firefly-eda-kafka`, + `-rabbitmq`, `-postgres` y `-redis`. Cambia el constructor, conserva tu + `#[event_listener]`. +- **Caché, identidad, notificaciones.** Programa contra el trait del puerto padre + (`cache::Adapter`, `security::Verifier`, `notifications::Channel`) e incorpora + el crate del adaptador concreto en el momento del cableado, de modo que los SDK + pesados se queden fuera de los servicios que no los usan. + +> **Note** **Término clave — factoría `#[bean]`.** Una factoría `#[bean]` es una +> función que el framework llama en el arranque para *construir* un bean, y donde +> tú decides qué adaptador concreto satisface un puerto. Es el único sitio donde +> ocurre el intercambio anterior: el cuerpo de la función devuelve +> `Arc::new(MemoryEventStore::new())` en desarrollo y +> `Arc::new(SqlEventStore::new(pool))` en producción, y nada aguas abajo se entera. +> El equivalente en Spring es un método `@Bean` en una clase `@Configuration`. +> Escribes tu primera en [Cableado de dependencias](./04-dependency-wiring.md). + +Lo que acaba de ocurrir: has visto por qué la línea base en memoria no es un +juguete. Como Lumen programa contra puertos, la build en memoria y el despliegue +de producción difieren *únicamente* en una factoría `#[bean]` —el cableado que el +framework escanea, no el código de negocio. Este es el hilo que recorre todo el +libro. + +> **Tip** **Punto de control.** Puedes terminar esta frase: *para llevar Lumen a +> producción cambias una factoría `#[bean]`, no un handler.* Si eso cala, las +> llamadas de atención del libro del tipo "Lumen se distribuye en memoria; aquí +> está el intercambio de producción" se leerán como algo rutinario en lugar de +> mágico. El Ejercicio 4 te hace localizar los tres traits de puerto tras estos +> intercambios. + +## El camino por delante: Lumen, capítulo a capítulo + +El resto del libro es el crecimiento de Lumen, aditivo y en orden. Los primeros +capítulos presentan el framework con pequeños fragmentos autónomos; **Lumen +propiamente dicho comienza en [Tu primera API HTTP](./06-first-http-api.md)**. + +- **Fundamentos** — andamiar y arrancar Lumen, vincular su configuración y + perfiles, entender cómo `FireflyApplication` cablea los beans que escanea, + dominar `Mono`/`Flux` y exponer los primeros endpoints REST validados. +- **Modelar y persistir** — un modelo de lectura tras un repositorio, el objeto de + valor `Money` y el agregado `Wallet`, y la división CQRS de comando/consulta + sobre un bus. +- **Orientado a eventos** — eventos de dominio, una proyección que mantiene + actualizado el modelo de lectura y el libro mayor con event sourcing que pliega + su stream. +- **Hacia los microservicios** — un esbozo de cliente HTTP y la saga de + transferencia compensatoria. +- **Asegurar, observar, distribuir** — autenticación bearer JWT y control de + acceso basado en roles, la superficie de actuator, la caché, una tarea + programada, la suite de pruebas y el punto de entrada de producción con apagado + ordenado y un endpoint de streaming reactivo. + +Para la última página, Lumen es el crate completo `samples/lumen`, y habrás +escrito cada una de sus líneas. + +## Resumen — qué cambió en Lumen + +Nada en código todavía. Este capítulo encuadró el viaje y abasteció tu +vocabulario: + +- El **problema de cohesión** que Firefly existe para resolver —Rust ofrece + elección infinita pero ninguna cohesión integrada— y la inversión + framework-frente-a-biblioteca que permite que un framework con criterio la + aporte. +- Qué **es Firefly** (un framework cohesivo, reactivo y nativo de async) y a qué + *delega* (tokio, axum/tower, serde, tracing, RustCrypto), dependiendo tú de sus + **puertos** y seleccionando **adaptadores** en el momento del cableado. +- La **fachada de una sola dependencia** —Lumen depende de un único + `firefly = { version = "26.6.28", features = ["admin"] }`, y + `use firefly::prelude::*;` trae toda la superficie de alta frecuencia y cada + macro. Incluso los errores tipados evitan `thiserror`, de modo que la promesa se + mantiene de extremo a extremo. +- Las **cuatro capas** tras esa fachada (fundacional → plataforma → adaptadores → + starters) apoyadas sobre el núcleo `firefly-reactive`, y dónde vive cada + capacidad. +- El **intercambio de adaptadores** que Lumen está construido para hacer —pasar de + la línea base en memoria a producción cambiando una única factoría `#[bean]`, + nunca un handler. + +## Ejercicios + +1. **Confirma la única dependencia.** Abre `samples/lumen/Cargo.toml` y confirma la + lista de dependencias: un `firefly` (con la feature `admin`), más + `axum`/`serde`/`serde_json`/`tokio`/`uuid`/`chrono`/`async-trait`. Fíjate en que + no se lista directamente ningún subcrate `firefly-*`. +2. **Encuentra el `main` de una sola línea.** Hojea `samples/lumen/src/main.rs` —la + raíz del crate de binario único. Lista los diez módulos que declara (`commands`, + `compliance`, `domain`, `housekeeping`, `ledger`, `money`, `security`, + `tcc_transfer`, `transfer`, `web`) y predice qué parte del libro introduce cada + uno. Confirma que `main` es genuinamente una línea sobre + `FireflyApplication::new("lumen")`. +3. **Lee la documentación del crate.** Ejecuta `cargo doc -p firefly-sample-lumen --open` + y lee la documentación a nivel de crate. Contiene la misma tabla "bloque de + construcción → módulo → superficie de Firefly" en torno a la cual se organiza el + libro. +4. **Localiza los traits de puerto.** Para cada uno de estos intercambios de + producción, encuentra el trait de puerto que implementaría en la fachada: un + almacén de eventos Postgres, un broker Kafka, una caché Redis. (Pista: + `firefly::eventsourcing::EventStore`, `firefly::eda::Broker`, + `firefly::cache::Adapter`.) Estas son las costuras que describió el Paso 5. +5. **Rastrea el prelude.** Abre el módulo `prelude` de la fachada `firefly` (o su + documentación) y encuentra cinco tipos que usarás repetidamente: el `Bus` de + CQRS, el `Container`, `Mono`/`Flux`, `WebResult` y `FireflyError`. Confirma que + todos llegan a través del único glob `use firefly::prelude::*;`. + +## Adónde ir después + +- Pon Lumen en marcha por primera vez en **[Inicio rápido](./02-quickstart.md)**: + andamia el crate, escribe el `main` de una línea y alcanza sus dos puertos. +- Añade configuración tipada, estratificada y consciente de perfiles en + **[Configuración](./03-configuration.md)**. +- Aprende cómo el framework cablea el grafo de objetos que escanea —incluida tu + primera factoría `#[bean]`— en + **[Cableado de dependencias](./04-dependency-wiring.md)**. diff --git a/docs/book/src-es/02-quickstart.md b/docs/book/src-es/02-quickstart.md new file mode 100644 index 00000000..b67c1005 --- /dev/null +++ b/docs/book/src-es/02-quickstart.md @@ -0,0 +1,514 @@ +# Inicio rápido + +Aquí es donde **Lumen** —el servicio de monedero digital y libro mayor que harás +crecer a lo largo del resto del libro— cobra vida por primera vez. Al terminar +este capítulo, Lumen existe como un crate real: compila, imprime un banner, sirve +una superficie de gestión en vivo y se apaga de forma ordenada. Todavía no hace +casi nada más, y eso es deliberado. Todo a partir de aquí es *aditivo*: cada +capítulo posterior recorta un poco más del crate terminado +[`samples/lumen`](https://github.com/fireflyframework/fireflyframework-rust/tree/main/samples/lumen) +y lo reincorpora a la narrativa, y nada de lo que escribas ahora se descarta. + +Abordaremos el mismo objetivo en dos pasadas. Primero generaremos el andamiaje del +crate con la CLI `firefly` (la vía rápida), y luego construiremos el crate idéntico +a mano para que cada línea sea algo que hayas tecleado y comprendas. Ambas pasadas +llegan a la misma forma de binario único que el resto del libro da por supuesta. + +Al terminar este capítulo, serás capaz de: + +- Generar el andamiaje de un proyecto Firefly de dos maneras: con la CLI + `firefly new` y a partir de un `cargo new` desnudo. +- Comprender por qué un servicio Firefly depende de un *único* crate, la fachada + `firefly`, en lugar de una constelación de artefactos de arranque. +- Escribir el `main` de una sola línea que arranca y sirve el servicio completo, y + explicar qué hace cada etapa de `run()`. +- Ejecutar Lumen y alcanzar sus dos puertos: la API pública en el `8080` y la + superficie de gestión (actuator, panel de administración, documentación de la + API) en el `8081`. +- Leer el informe de arranque y confirmar el estado de salud y los metadatos de + compilación de Lumen con `curl`. + +## Conceptos que conocerás + +Antes del primer comando, aquí tienes las tres ideas en las que se apoya este +capítulo. Cada una se reintroduce en contexto donde se usa por primera vez; esta +es la versión breve. + +> **Note** **Término clave — facade crate.** Una *facade* es un único crate que +> reexporta toda una familia de crates (y sus macros) para que dependas de un +> solo nombre en lugar de muchos. Firefly distribuye todo su framework detrás de +> la fachada `firefly`. El equivalente en Spring es un *starter* de Spring Boot, +> salvo que aquí hay exactamente uno y lo cubre todo. + +> **Note** **Término clave — bean.** Un *bean* es un objeto que el framework +> construye y gestiona por ti, y luego entrega a quien lo necesite. Tú declaras +> los beans; el framework los descubre en el arranque y los conecta entre sí. +> Esto es exactamente la noción de Spring de un bean gestionado por el contexto de +> la aplicación. + +> **Note** **Término clave — actuator / superficie de gestión.** La *superficie +> de gestión* es un conjunto de endpoints HTTP operativos —comprobaciones de +> salud, información de compilación, métricas, introspección de configuración— que +> existen para operadores y herramientas, no para usuarios finales. Firefly los +> sirve en un puerto distinto del de tu API de negocio. Esto refleja Spring Boot +> Actuator. + +## Paso 1 — Comprueba tu toolchain + +Necesitas un toolchain estable y reciente de Rust, y nada más. La pila por +defecto de Lumen **no requiere infraestructura externa**: su almacén de eventos, +su event broker y su read model son todos Rust puro ejecutándose en proceso. + +```bash +rustc --version # 1.88 or later +cargo --version +``` + +> **Tip** **Punto de control.** Ambos comandos imprimen una versión. Si `rustc` +> reporta algo por debajo de 1.88, actualiza con `rustup update stable` antes de +> continuar. + +Más adelante intercambiarás las piezas en proceso por infraestructura real +(Postgres, Kafka) en [Producción y despliegue](./20-production.md), pero nunca +antes de estar preparado: todo el libro se ejecuta contra los valores por defecto +en proceso. + +## Paso 2 — Genera el andamiaje con la CLI `firefly` (Vía A) + +La forma más rápida de llegar a un servicio en ejecución es la CLI de desarrollo. +Instálala una vez y luego pídele que genere el proyecto. + +> **Note** **Término clave — archetype.** Un *archetype* es una plantilla de +> proyecto que decide la forma inicial de tu crate: qué módulos existen, qué +> características de Firefly están activadas y cómo es el código de ejemplo. La CLI +> distribuye varios (`core`, `web-api`, `web`, `hexagonal`, `library`, `cli`). El +> equivalente en Spring es un «tipo de proyecto» de Spring Initializr más sus +> dependencias preseleccionadas. + +```bash +cargo install --path crates/cli # from a checkout of the framework +# or, once published: cargo install firefly-cli + +firefly new lumen --archetype web-api --features web,cqrs --git +cd lumen +cargo run +``` + +Qué acaba de pasar: `firefly new` escribió un crate de Cargo con un árbol `src/`, +un `firefly.yaml`, un `.gitignore`, un `README.md`, un `Dockerfile` y un +directorio `tests/`, y luego (por el `--git`) inicializó un repositorio Git con un +primer commit. El archetype `web-api` es la forma de partida adecuada para Lumen +—un servicio web con el bus de CQRS ya conectado— y `--features web,cqrs` activa +exactamente esos dos subsistemas. `cargo run` compila y arranca el servicio. + +> **Note** **Término clave — CQRS.** *Command/Query Responsibility Segregation* es +> un patrón que enruta los **comandos** que modifican estado y las **consultas** +> de solo lectura a través de manejadores separados sobre un *bus* compartido. +> Construirás los manejadores de comandos y consultas de Lumen en capítulos +> posteriores; por ahora basta con que la característica `cqrs` reserve el +> cableado. + +> **Tip** Ejecuta `firefly new --list` para imprimir cada archetype y feature +> flag, o `firefly new lumen --dry-run` para previsualizar el plan exacto de +> archivos sin escribir ni un solo archivo. Consulta [La CLI](./19-cli.md) para el +> catálogo completo del generador. + +> **Tip** **Punto de control.** Tras `cargo run` deberías ver el banner de Firefly +> seguido de un informe de arranque con prefijo `::` y dos URLs (el panel de +> administración y la documentación de la API). Si has llegado hasta ahí, salta al +> [Paso 7](#step-7--run-it). Si quieres entender cada línea generada, haz en su +> lugar los Pasos 3 a 6 a mano. + +## Paso 3 — Construye el crate a mano (Vía B) + +La CLI es cómoda, pero el resto del libro se corresponde con `samples/lumen` +listado por listado, y la forma más segura de seguirlo es teclear el crate tú +mismo. Parte de un binario Cargo desnudo. + +```bash +cargo new lumen +cd lumen +``` + +Qué acaba de pasar: `cargo new` creó un crate binario —un `Cargo.toml` y un +`src/main.rs` de relleno—. A lo largo de los tres pasos siguientes reemplazarás +ambos por el contenido real de Lumen. + +> **Tip** **Punto de control.** `ls` muestra un `Cargo.toml` y un directorio +> `src/`. `cargo run` imprime `Hello, world!`. Ese relleno es el último código de +> este libro que Firefly *no* gestiona por ti. + +## Paso 4 — Depende del único crate que es el framework + +Abre `Cargo.toml`. Aquí es donde la historia de la dependencia única se vuelve +concreta. Todo el framework —CQRS, inyección de dependencias, la pila web +reactiva, event sourcing, orquestación de sagas, planificación, seguridad, +observabilidad— y *cada* macro `#[derive(...)]` / `#[...]` llegan a través de un +único crate. + +```toml +# Cargo.toml +[dependencies] +# The one-dependency front door: the `firefly` facade re-exports the whole +# framework AND every macro. Generated code resolves runtime types through the +# facade, so Lumen never lists the underlying `firefly-*` crates. The `admin` +# feature pulls in the self-hosted admin dashboard the management port mounts. +firefly = { version = "26.6.28", features = ["admin"] } +``` + +Qué acaba de pasar: esa única línea es el framework entero. Cada capítulo +posterior añade *código*, no dependencias: no volverás a editar esta línea +`firefly`. + +> **Design note.** Muchos frameworks te obligan a ensamblar una constelación de +> artefactos de starter o plugin y a mantener sus versiones alineadas a mano. +> Firefly colapsa todo eso en una sola línea `firefly`: no hay starter que olvidar +> ni desfase de versiones entre subsistemas como `firefly-web` y `firefly-cqrs`; +> cada crate `firefly-*` se distribuye como una única release versionada por +> calendario (aquí `26.6.28`), y tú dependes de la fachada. + +Un servicio Firefly aún escribe directamente contra unos pocos crates del +ecosistema: `axum` (tú redactas los manejadores de los controladores), `serde` / +`serde_json` (tus mensajes y payloads de eventos son serializables), el runtime +asíncrono y los crates de id/reloj que usa el dominio. Añádelos, junto con la +feature flag que controla el endpoint de streaming: + +```toml +# The ecosystem crates a Firefly service still uses directly. +axum = "0.7" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# The async runtime for `#[tokio::main]`, and the id/clock crates the domain +# uses for wallet ids and event timestamps. +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] } +uuid = { version = "1", features = ["v4"] } +chrono = "0.4" +async-trait = "0.1" + +[features] +# The reactive streaming endpoint is feature-gated so the teaching baseline +# stays lean; the production chapter turns it on. It needs nothing beyond the +# `firefly` facade. +default = [] +streaming = [] +``` + +Qué acaba de pasar: declaraste el puñado de crates contra los que escribirás +código directamente, y una feature flag `streaming` que permanece desactivada por +defecto. Todo lo demás fluye a través de `firefly`. + +> **Tip** **Punto de control.** Ejecuta `cargo build`. Descarga y compila el +> framework (la primera compilación es la lenta). Una compilación limpia aquí +> significa que la fachada y tus dependencias directas se resuelven todas +> correctamente. + +## Paso 5 — Escribe el `main` de una sola línea + +Un servicio Firefly tiene exactamente un punto de entrada: `main`. **No hay +composition root, ni `build_app`, ni una struct de aplicación** que ensamblar a +mano. Lumen es un crate de binario único, así que `src/main.rs` es la raíz del +crate: unas cuantas declaraciones `mod` y un `main` que entrega el servicio +completo al framework. + +> **Note** **Término clave — composition root.** El *composition root* es el único +> lugar de un programa donde se ensambla el grafo de objetos: donde cada +> componente se construye y se conecta. En muchos frameworks escribes esto a mano. +> En Firefly el framework *es* el composition root: escanea tus beans y los +> conecta, de modo que nunca deletreas el grafo en una función. + +Reemplaza el contenido de `src/main.rs` por la lista de módulos y el punto de +entrada: + +```rust,ignore +// src/main.rs +#![allow(dead_code)] + +mod commands; +mod compliance; +mod domain; +mod housekeeping; +mod ledger; +mod money; +mod security; +mod tcc_transfer; +mod transfer; +mod web; + +#[tokio::main] +async fn main() -> Result<(), firefly::BoxError> { + firefly::FireflyApplication::new("lumen").run().await +} +``` + +Qué acaba de pasar, línea por línea: + +- Las declaraciones `mod` nombran los módulos en los que Lumen irá creciendo. Se + listan ahora para que `main.rs` no vuelva a cambiar nunca; irás rellenando cada + uno a lo largo del libro. Hasta que exista el archivo de un módulo, esta lista no + compilará, así que cuando lo sigas de verdad añades la línea `mod` en el mismo + capítulo que añade el módulo. Para este inicio rápido, el único que necesitas es + el que decidas conservar: lo importante es la forma de `main`. +- `#[tokio::main]` convierte `async fn main` en un `main` normal respaldado por el + runtime de Tokio, que Firefly necesita porque toda la pila es asíncrona. +- `Result<(), firefly::BoxError>` es el tipo de retorno. `BoxError` es el tipo de + error encajado de Firefly (`Box`); + devolverlo te permite usar `?` en el arranque y hace que un fallo de arranque se + manifieste como un código de salida distinto de cero. +- `firefly::FireflyApplication::new("lumen").run().await` es el servicio entero. + `new("lumen")` nombra la aplicación (el nombre aparece en el banner y en + `/actuator/info`); `.run().await` la arranca y la sirve. + +> **Design note.** `FireflyApplication::new(name).run()` es el análogo en Rust de +> `SpringApplication.run(App.class, args)` de Spring Boot. Esa única llamada *es* +> el composition root: el framework ensambla el grafo de objetos a partir de los +> beans que escanea en lugar de que tú lo deletrees en una función. Nada es +> reflexivo ni oculto: el informe de arranque (Paso 7) registra exactamente qué se +> conectó, de modo que «qué está corriendo» se imprime línea por línea en el +> arranque. + +Si quieres seguirlo con lo más pequeño que compile, elimina las líneas `mod` y +conserva solo la función `main` y el atributo `#![allow(dead_code)]`. La lista +completa de módulos de arriba es la forma real de Lumen que el resto del libro da +por supuesta. + +> **Note** **Término clave — nombre y versión de la aplicación.** Lumen mantiene +> su nombre y su versión en dos constantes junto a su superficie HTTP, en +> `src/web.rs`. La versión procede del propio framework, de modo que sigue la +> release de la que dependes: +> +> ```rust,ignore +> // src/web.rs +> /// Lumen's application name (banner + `/actuator/info`). +> pub const APP_NAME: &str = "lumen"; +> +> /// The released framework version, surfaced in the banner. +> pub const VERSION: &str = firefly::VERSION; +> ``` + +## Paso 6 — Entiende qué hace `run()` + +`run()` es una línea en tu código y un pipeline de arranque completo por debajo: +el trabajo que un servicio solía cocinar a mano en un composition root. Conocer +las etapas rinde dividendos en cada capítulo posterior, porque cada capítulo añade +un bean que una de estas etapas descubre. En orden, `run()`: + +- **Construye la pila web** — el renderizador de problemas RFC 9457, la propagación + de correlation-id, la repetición de idempotencia, la caché en proceso, el bus de + CQRS, el event broker, los registries de salud y métricas, el scheduler y las + «pilas» web (CORS, cabeceras de seguridad, métricas de petición, el access log). +- **Hace component-scan del contenedor de DI** — autorregistra los beans de + infraestructura del framework, y luego descubre y conecta cada bean de aplicación + que declaraste: factorías `#[derive(Configuration)]` + `#[bean]`, controladores + `#[derive(Controller)]` y campos `#[autowired]`. Cualquier factoría de bean + `async fn` (un pool de BD, una conexión a un broker) se await aquí, de modo que + los beans asíncronos están vivos antes de que algo los resuelva, y un error de + construcción aborta el arranque (fail-fast). +- **Autoconfigura el bus de CQRS** — propagación de correlation siempre; el + middleware de read-cache siempre que esté presente un bean `QueryCache`. +- **Autodescubre la seguridad** — los beans de DI `FilterChain` y `BearerLayer` + (el `SecurityFilterChain` de Spring), superpuestos sobre la API sin necesidad de + ninguna llamada `.security(...)`. +- **Automonta cada controlador** — cada `#[rest_controller]` se monta desde el + contenedor con su estado resuelto automáticamente, y las rutas de cada bean + `RouteContributor` se fusionan. +- **Drena los manejadores descubiertos** — los manejadores de comandos y consultas + de CQRS registrados por inventario, los listeners de eventos de EDA y las tareas + `#[scheduled]`, incluidas las declaradas como métodos de bean que autoconectan + sus colaboradores. +- **Construye la documentación OpenAPI** a partir del inventario en vivo y aloja él + mismo el panel de administración, ambos en el puerto de gestión, conectados a los + componentes reales. +- **Imprime un informe de arranque al estilo Spring** — los perfiles activos, cada + bean descubierto, la tabla de rutas montadas y los recuentos de + manejadores/listeners/tareas programadas— y luego **sirve los puertos público + + de gestión con apagado ordenado**. + +Unas cuantas propiedades se repiten en cada capítulo, así que fíjate en ellas +ahora: + +- **Sin trasiego en `main`.** A medida que Lumen adquiere un controlador, un bus de + CQRS, un libro mayor basado en event sourcing y una cadena de seguridad, `main` + nunca cambia: los nuevos beans se *descubren*, no se enhebran a través de un punto + de entrada. +- **Dos puertos.** La API pública sirve en el `8080`; la superficie de gestión + (`/actuator/*` más el panel `/admin` autoalojado más la documentación de la API) + en el `8081` por defecto, de modo que los endpoints operativos nunca se filtran a + la red pública. + +
+ +Public API :8080client-facing +Management :8081operator-facing +#[rest_controller]your routesSecurityJWT · roles · sessionsRFC 9457 404problem+json fallback +/actuator/*health · info · metrics/adminself-hosted dashboard/swagger-ui · /redoc/v3/api-docs +FIREFLY_SERVER_ADDR · FIREFLY_MANAGEMENT_ADDR override the binds + +
Dos listeners, un proceso. La API pública (:8080) sirve tus controladores, la seguridad y el fallback 404 de RFC 9457; la superficie de gestión (:8081) sirve el actuator, el panel /admin autoalojado y la documentación OpenAPI, de modo que los endpoints operativos nunca se filtran a la red pública.
+
+- **`FIREFLY_SERVER_ADDR` / `FIREFLY_MANAGEMENT_ADDR`** sobrescriben las direcciones + de bind desde el entorno (por defecto `0.0.0.0:8080` / `0.0.0.0:8081`). Ese es tu + primer contacto con la historia de configuración tipada de + [Configuración](./03-configuration.md). +- **El apagado ordenado viene de serie.** `run()` captura SIGINT/SIGTERM y drena + las peticiones en vuelo antes de salir; una ejecución cancelada es un apagado + limpio, no un error. + +> **Note** **Costura de pruebas.** `bootstrap()` es el hermano de `run()`: ensambla +> la misma aplicación pero devuelve un valor `Bootstrapped` *sin servir*, de modo +> que las pruebas pueden manejar el router público totalmente conectado +> (`Bootstrapped::api_router`) en proceso sin ningún socket vinculado. Te apoyarás +> mucho en eso en [Tu primera API HTTP](./06-first-http-api.md) y +> [Pruebas](./18-testing.md). + +## Paso 7 — Ejecútalo + +```bash +cargo run +``` + +Verás el banner de Firefly (arte ASCII más la versión del framework, el nombre de +tu aplicación y el perfil activo), luego el informe de arranque línea por línea, +seguido de las URLs del panel de administración y de la documentación de la API: + +```text +:: admin dashboard :: http://0.0.0.0:8081/admin/ +:: api docs (management) :: swagger-ui http://0.0.0.0:8081/swagger-ui | redoc http://0.0.0.0:8081/redoc | spec http://0.0.0.0:8081/v3/api-docs +:: active profiles :: default +:: beans (…) :: +:: routes (…) :: +:: cqrs handlers: … | event listeners: … | scheduled tasks: … | controllers: … :: +:: openapi :: … operations | … component schemas (served at /v3/api-docs) :: +``` + +Qué acaba de pasar: el framework arrancó todo el pipeline del Paso 6 y ahora está +sirviendo ambos puertos. Las líneas `:: beans ::`, `:: routes ::` y los recuentos +son el inventario que el framework conectó: ahora mismo son pequeños porque Lumen +todavía no tiene lógica de negocio, y crecen a medida que añades capítulos. + +> **Tip** **Punto de control.** El proceso permanece en ejecución y las últimas +> líneas muestran las dos URLs de arriba. Abre `http://localhost:8081/admin/` en un +> navegador para ver el panel autoalojado. Deja `cargo run` ejecutándose en este +> terminal y usa un segundo terminal para las comprobaciones con `curl` de abajo. + +## Paso 8 — Confirma el estado de salud y los metadatos de compilación + +Incluso sin rutas de negocio propias, el actuator está vivo en el puerto de +gestión. Desde un segundo terminal: + +```bash +# Liveness / readiness — on the management port, never the public one. +curl localhost:8081/actuator/health +# {"status":"UP", ...} +``` + +Qué acaba de pasar: `/actuator/health` agrega cada indicador de salud que el +framework registró y reporta el `status` general. Con los valores por defecto en +proceso, todo está `"UP"`. + +```bash +# Build metadata — the app name and version flow straight from +# `FireflyApplication::new("lumen")` and the framework version. +curl localhost:8081/actuator/info +# {"app":{"name":"lumen","version":"26.6.28"},"runtime":{...},"build":{...}} +``` + +Qué acaba de pasar: `/actuator/info` hace eco del nombre de aplicación que pasaste +a `new(...)` y de la versión, junto a los detalles de runtime y de compilación. +Cambia el nombre en `main` y este endpoint lo seguirá en la siguiente ejecución. + +> **Tip** **Punto de control.** Ambos `curl` devuelven JSON: health reporta +> `"status":"UP"` e info reporta `"app":{"name":"lumen", ...}`. Si `curl` puede +> conectar pero a ninguna de las dos rutas, confirma que estás apuntando al `8081` +> (gestión), no al `8080` (público). El puerto público no tiene `/actuator/*`. + +## Lo que obtuviste gratis + +Sin escribir nada de ello tú mismo, Lumen ya tiene: + +- **Respuestas de problema RFC 9457.** Cualquier error de un manejador se renderiza + como `application/problem+json`, una ruta sin coincidencia devuelve un documento + de problema 404 en condiciones (no un cuerpo en blanco) y un panic se captura y se + renderiza como un problema 500. Usarás esto desde el primer endpoint del + capítulo 6. +- **Correlation IDs.** Cada respuesta hace eco de un `X-Correlation-Id`; uno + entrante se respeta y se mantiene en el ámbito de toda la petición. +- **Idempotencia.** Cada `POST`/`PUT`/`PATCH` que porte una cabecera + `Idempotency-Key` se registra; repetir la petición reproduce la respuesta + almacenada, y reutilizar la clave con un cuerpo distinto es un `409`. +- **Una superficie de gestión.** `/actuator/{health,info,metrics,env,beans,mappings, + conditions,...}` (los informes `beans` / `mappings` / `conditions` reflejan la + introspección de DI de Spring Boot Actuator) más un panel `/admin` autoalojado, en + un listener aparte. +- **Documentación de la API autogenerada.** Swagger UI (`/swagger-ui`), ReDoc + (`/redoc`) y la especificación OpenAPI 3.1 (`/v3/api-docs`) se sirven + automáticamente en el puerto de **gestión** (junto al actuator y al admin, no en + la API pública), con cero código de aplicación. +- **Apagado ordenado.** `run()` captura SIGINT/SIGTERM y drena las peticiones en + vuelo. + +> **Design note.** Salud, info y métricas en un puerto de gestión dedicado, un panel +> de administración autoalojado, documentación de la API autogenerada y middleware +> de petición de calidad de producción: todo levantado por una sola +> `FireflyApplication::new(...).run()`, sin ningún archivo de configuración que +> redactar primero y sin anotaciones que recordar. Esta es la superficie de actuator +> de Firefly, activada por defecto. + +## Resumen — qué cambió en Lumen + +| Antes | Después de este capítulo | +|--------|--------------------| +| directorio vacío | un crate que compila cuya única dependencia de Firefly es la fachada `firefly` | +| sin punto de entrada | un `main` de una sola línea sobre `FireflyApplication::new("lumen").run()` | +| nada que ejecutar | un actuator + admin vivo en el `:8081`, una API pública en el `:8080`, documentación autogenerada, apagado ordenado | +| — | constantes `APP_NAME` / `VERSION` que nombran el servicio y alimentan `/actuator/info` | + +Ahora también sabes: + +- Por qué un servicio Firefly depende de un solo crate —la fachada `firefly`— en + lugar de muchos starters, y cómo eso evita el desfase de versiones. +- Que `run()` es un pipeline de arranque completo: construir la pila web, hacer + component-scan del contenedor de DI, autoconfigurar CQRS, autodescubrir la + seguridad, automontar controladores, drenar manejadores, autoalojar admin y + documentación, y luego servir dos puertos. +- Que `bootstrap()` es la costura de pruebas que devuelve la aplicación conectada + sin servir. + +Lumen es ahora un servicio real y ejecutable que da la casualidad de que no tiene +lógica de negocio. Cada capítulo posterior rellena ese vacío, nunca reescribiendo +`main`, solo declarando más beans para que el framework los descubra. + +## Ejercicios + +1. **Mueve los puertos.** Arranca Lumen con `FIREFLY_SERVER_ADDR=127.0.0.1:9090 + FIREFLY_MANAGEMENT_ADDR=127.0.0.1:9091 cargo run`, luego + `curl localhost:9091/actuator/health`. Confirma que las superficies pública y de + gestión se movieron de forma independiente: esta es la costura sobre la que + construye [Configuración](./03-configuration.md). +2. **Lee tus propios metadatos.** Ejecuta `curl localhost:8081/actuator/info` y + encuentra los valores `app.name` / `app.version`. Cambia el nombre pasado a + `FireflyApplication::new(...)`, vuelve a ejecutar y observa cómo el banner y + `/actuator/info` lo siguen ambos. +3. **Lee el informe de arranque.** Ejecuta Lumen y lee el log de arranque línea por + línea: los perfiles activos, los beans descubiertos, las rutas automontadas y los + recuentos de manejadores/listeners/tareas programadas. Este es el inventario que + el framework conectó: fíjate en lo corto que es hoy, y luego vuelve a visitarlo + tras un capítulo posterior. +4. **Provoca un apagado ordenado.** Ejecuta Lumen y luego pulsa `Ctrl-C`. Observa + que el proceso sale limpiamente sin ningún stack trace: `run()` trató la señal + como un apagado, no como un fallo. +5. **Previsualiza el andamiaje.** Aunque hayas tomado la Vía B, ejecuta + `firefly new lumen2 --archetype web-api --features web,cqrs --dry-run` y compara + el plan generado con el `Cargo.toml` y el `main.rs` que escribiste a mano. + +## Adónde ir después + +- Añade configuración tipada, en capas y consciente de perfiles en + **[Configuración](./03-configuration.md)**, y reemplaza esos overrides crudos de + variables de entorno `FIREFLY_*` por propiedades reales. +- Aprende cómo el framework conecta el grafo de objetos que escanea en + **[Cableado de dependencias](./04-dependency-wiring.md)**. +- Dale a Lumen sus primeros endpoints reales en + **[Tu primera API HTTP](./06-first-http-api.md)**. diff --git a/docs/book/src-es/03-configuration.md b/docs/book/src-es/03-configuration.md new file mode 100644 index 00000000..edc4359b --- /dev/null +++ b/docs/book/src-es/03-configuration.md @@ -0,0 +1,858 @@ +# Configuración + +En el [Inicio rápido](./02-quickstart.md), Lumen se nombró a sí mismo con dos +cadenas `pub const`, y `FireflyApplication` tomó sus direcciones de enlace +directamente del entorno (`FIREFLY_SERVER_ADDR` / `FIREFLY_MANAGEMENT_ADDR`). Ese +es el punto de partida correcto, pero un servicio de monedero real se ejecuta en +desarrollo, en CI y en producción, y cada entorno quiere puertos, niveles de log +y (con el tiempo) URLs de base de datos distintos. Los literales codificados a +mano no sobreviven a ese recorrido. + +En este capítulo es donde esos literales dejan de ser literales y empiezan a +provenir de archivos y del entorno, de una forma tipada, en capas y consciente de +los perfiles: la misma forma que `@ConfigurationProperties` de Spring Boot da a +un servicio Java, portada a structs `serde` corrientes. Todo lo de aquí es +*aditivo*: el `main` de una sola línea del Inicio rápido no cambia, y las +constantes que escribiste siguen funcionando mientras aprendes la maquinaria que +acabará reemplazándolas. + +Al terminar este capítulo, serás capaz de: + +- Definir una configuración como un struct `serde` corriente y **enlazar** sobre + él valores planos con clave por puntos mediante el binder dirigido por tipos. +- Cargar configuración desde `application.yaml`, una superposición específica de + perfil y el entorno, y explicar la **cadena de precedencia** que decide quién + gana. +- Resolver marcadores de posición `${...}` y razonar sobre el orden en que el + entorno vence a la configuración. +- Convertir un struct de configuración en un **bean inyectable** con + `#[derive(ConfigProperties)]`, opcionalmente validado al arranque. +- Levantar la datasource y la capa de seguridad de Lumen **desde + `application.yaml`** con una única llamada de autoconfiguración esperada para + cada una: sin contenedor, sin cadenas de builders. +- Enmascarar secretos, recargar en tiempo de ejecución y obtener configuración + desde un servidor de configuración. + +## Conceptos que conocerás + +Antes del primer struct, aquí están las ideas en las que se apoya este capítulo. +Cada una se reintroduce en su contexto donde se usa por primera vez; esta es la +versión corta. + +> **Note** **Término clave — propiedad de configuración.** Una *propiedad de +> configuración* es un único valor con nombre que tu programa lee al arranque: +> `web.addr`, `cache.ttl`, `datasource.url`. Firefly representa todo el conjunto +> como un **mapa plano de cadenas con clave por puntos** +> (`{"web.addr": "127.0.0.1:8080", ...}`) y luego lo *enlaza* sobre tu struct +> tipado. El análogo en Spring es una propiedad en `application.properties` / +> `application.yaml`. + +> **Note** **Término clave — source.** Una *source* es cualquier cosa que produce +> algunas de esas entradas planas: un archivo YAML, el entorno del proceso, +> valores por defecto codificados a mano, flags de CLI, un servidor de +> configuración remoto. El trait `Source` de Firefly tiene una sola tarea: +> devolver un `HashMap`. El análogo en Spring es un +> `PropertySource`. + +> **Note** **Término clave — perfil.** Un *perfil* nombra un entorno — +> `dev`, `test`, `staging`, `prod` — y selecciona una superposición YAML extra +> (`application-prod.yaml`) que se apila sobre el archivo base. Esto es +> exactamente la noción de perfil activo de Spring, hasta la sintaxis de comas +> `dev,cloud`. + +> **Note** **Término clave — binding.** El *binding* (enlace) es el acto de +> decodificar el mapa plano de cadenas sobre un struct tipado: `"9090"` se +> convierte en un `u16`, `"alpha,beta"` se convierte en un `Vec`, +> `"true"` se convierte en un `bool`. El binder está **dirigido por tipos**: el +> tipo del campo destino decide cómo se parsea cada cadena. Spring llama a la +> misma idea relaxed binding sobre una clase `@ConfigurationProperties`. + +> **Design note.** Firefly enlaza una jerarquía consciente de perfiles +> `application.yaml` → perfil → entorno sobre structs tipados, y las reglas de +> aplanamiento y enlace están especificadas con precisión, de modo que el mismo +> `application.yaml` produce las mismas claves de forma determinista. Firefly +> trata este determinismo como una garantía, no como un accidente: no hay un +> motor YAML de propósito general decidiendo cosas a tus espaldas. + +## Paso 1 — Ve dónde está Lumen hoy: la identidad de la app como configuración + +No tienes que escribir ninguna config para seguir este paso: ya tienes config, +solo que la deletreaste como constantes. Recuerda el bootstrap de Lumen. El +`main` del Inicio rápido era la forma desnuda; `src/web.rs` conserva un ayudante +`bootstrap` más completo que además estampa la versión: + +```rust,ignore +// src/web.rs — the two values that name the service +pub const APP_NAME: &str = "lumen"; +pub const VERSION: &str = firefly::VERSION; + +firefly::FireflyApplication::new(APP_NAME) + .version(VERSION) + .run() + .await +``` + +Lo que acaba de ocurrir: esos dos valores se convierten en `CoreConfig.app_name` +/ `CoreConfig.app_version` dentro del framework, configuración corriente. +`FireflyApplication::new(name)` escribe `app_name`; `.version(v)` escribe +`app_version`. Cada uno de los demás campos de `CoreConfig` es también un mando, y +los dos que Lumen establece son exactamente los valores que `/actuator/info` +reporta y que el banner imprime. + +> **Note** **Término clave — `CoreConfig`.** `CoreConfig` es el propio struct de +> configuración del framework (CORS, cabeceras de seguridad, idempotencia, el +> nombre y la versión de la app, …). `FireflyApplication` lleva uno y te permite +> ajustarlo con `.configure(|c| ...)`. Los campos restantes toman valores por +> defecto —una caché en memoria, un broker en proceso, un bus CQRS recién +> creado—, razón por la cual un `cargo run` desnudo no necesita infraestructura. +> El análogo en Spring es el paquete de propiedades `server.*` / `spring.*` que +> Spring Boot enlaza por ti. + +Promover cualquiera de esos valores por defecto a infraestructura real es un +cambio de un solo campo que harás en +[Producción y despliegue](./20-production.md). La historia de configuración de +*este* capítulo es la maquinaria general que hay debajo: cómo un valor como una +dirección deja de ser un literal en Rust y empieza a llegar desde un archivo o el +entorno. + +> **Tip** **Punto de control.** Ya puedes demostrar que la identidad es +> configuración: `curl localhost:8081/actuator/info` (puerto de management) y lee +> de vuelta `"app":{"name":"lumen","version":"..."}`. Cambia la cadena que pasas +> a `new(...)`, vuelve a ejecutar, y tanto el banner como ese endpoint lo siguen. + +## Paso 2 — Define un struct de configuración + +Un struct de configuración es `serde` corriente. No hay un tipo base especial del +que heredar ni un atributo que recordar: los structs anidados simplemente se +convierten en secciones anidadas con clave por puntos (`web.addr`, +`web.admin_addr`). Aquí está la forma que Lumen adoptaría a medida que crece más +allá de las dos constantes. + +```rust +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct Web { + /// Public API bind address — the typed home of FIREFLY_SERVER_ADDR. + addr: String, + /// Admin/management bind address — the typed home of FIREFLY_MANAGEMENT_ADDR. + admin_addr: String, +} + +#[derive(Debug, Deserialize)] +struct LumenConfig { + name: String, + web: Web, + tags: Vec, +} +``` + +Lo que acaba de ocurrir: declaraste tres claves de nivel superior —`name`, la +sección `web` y una lista `tags`— puramente con `serde`. El binder alcanza +`web.addr` recorriendo `LumenConfig.web` → `Web.addr`, y alcanza cada elemento de +`tags` dividiendo una cadena unida por comas. + +Por qué importa: el binder está **dirigido por tipos**, así que rara vez +necesitas `#[serde(default)]`. Una clave ausente produce el valor cero del tipo +—`0` para un entero, `""` para un `String`, `false` para un `bool`, un `Vec` +vacío para una lista—, exactamente como un struct con valores a cero. Esa es una +decisión deliberada de paridad con los ports de Go y pyfly. + +> **Note** **Término clave — clave relajada.** Las claves se normalizan en la +> puerta: pasadas a minúsculas, con los guiones kebab-case plegados a guiones +> bajos snake_case. De modo que `admin-addr:` escrito en YAML enlaza el campo +> serde `admin_addr`, y `WEB.ADDR` del entorno aterriza en la misma clave +> `web.addr` que un `web.addr` de YAML. Spring lo llama relaxed binding. + +El catálogo completo de hojas que el binder soporta: `String`, `bool` (acepta las +formas `1`/`0`, `t`/`f` y `true`/`false`), todos los anchos de entero, +`f32`/`f64`, `char`, enums unitarios (emparejados por el nombre de la variante), +`Option` (`None` cuando la clave y todo su subárbol están ausentes), secuencias +de escalares (separadas por comas, recortadas) y subárboles +`HashMap` (cada segmento hijo inmediato se convierte en una clave del +mapa). Para una duración, enlaza un `i64`/`u64` de milisegundos y convierte: +`Duration::from_millis(cfg.cache.ttl_ms)`. + +## Paso 3 — Enlaza valores sobre el struct + +Un struct por sí solo no hace nada; enlazas un mapa plano sobre él. El punto de +entrada de más bajo nivel es `bind`, que toma un `HashMap` y lo +decodifica sobre un `T` recién creado. + +```rust,ignore +use std::collections::HashMap; +use firefly::config::{bind, ConfigError}; + +let flat = HashMap::from([ + ("name".to_string(), "lumen".to_string()), + ("web.addr".to_string(), "127.0.0.1:8080".to_string()), + ("web.admin-addr".to_string(), "127.0.0.1:8081".to_string()), + ("tags".to_string(), "wallet, ledger, demo".to_string()), +]); + +let cfg: LumenConfig = bind(&flat)?; +assert_eq!(cfg.web.addr, "127.0.0.1:8080"); +assert_eq!(cfg.tags, vec!["wallet", "ledger", "demo"]); +# Ok::<(), ConfigError>(()) +``` + +Lo que acaba de ocurrir: `bind` recorrió el tipo de tu struct, buscó cada clave +con puntos y parseó la cadena sobre el campo destino. Fíjate en tres cosas que el +tipo dirigió por sí solo: `web.admin-addr` (kebab) enlazó el campo `admin_addr` +(snake), `"wallet, ledger, demo"` se dividió y recortó sobre un `Vec`, y +nada requirió `#[serde(default)]`. + +> **Note** **Término clave — import de fachada.** `firefly::config` es el crate +> `firefly-config` reexportado a través de la fachada de dependencia única, así +> que sigues dependiendo solo de `firefly`. A lo largo de este capítulo +> `firefly::config::X` y `firefly_config::X` nombran el mismo elemento; el libro +> prefiere la ruta de la fachada para mantener honesta la historia de la +> dependencia única. + +En código real casi nunca construyes ese mapa a mano: las sources lo construyen +por ti. El loader canónico, `load`, toma una lista de sources, las fusiona, +resuelve los marcadores de posición y enlaza en una sola llamada: + +```rust,ignore +use firefly::config::{load, Source}; + +let cfg: LumenConfig = load(&sources)?; +``` + +El siguiente paso es de dónde sale `sources`. + +> **Tip** **Punto de control.** Mete el ejemplo de `bind` en un test unitario y +> ejecútalo. Un test en verde significa que la forma de tu struct y las claves +> con puntos encajan: esta es la forma más rápida de depurar un enlace antes de +> que el YAML y el entorno entren en la mezcla. + +## Paso 4 — Carga con perfiles + +El bootstrap más común es una llamada a un ayudante. `load_from_profile` lee +`application.yaml`, luego el `application-{profile}.yaml` específico del perfil, +luego las variables de entorno `FIREFLY_*`, las fusiona en ese orden y enlaza el +resultado: + +```rust,ignore +use firefly::config::{load_from_profile, ConfigError}; + +fn main() -> Result<(), ConfigError> { + // dir, app basename, fallback profile (FIREFLY_PROFILE overrides at runtime). + let cfg: LumenConfig = load_from_profile("/etc/lumen", "application", "dev")?; + println!("public API on {}", cfg.web.addr); + Ok(()) +} +``` + +Lo que acaba de ocurrir, argumento a argumento: + +- `"/etc/lumen"` es el directorio donde viven los archivos YAML. +- `"application"` es el *basename* del archivo, así que lee `application.yaml` y + `application-{profile}.yaml`. (Pasa `"lumen"` para leer `lumen.yaml` en su + lugar.) +- `"dev"` es el perfil de **reserva** (fallback), usado solo cuando + `FIREFLY_PROFILE` no está establecido. + +Se tolera la ausencia de ambos archivos YAML: un servicio que codifica todo a +mano en Rust puede no enviar ningún YAML y esta llamada sigue teniendo éxito solo +contra el entorno. + +> **Note** **Término clave — `FIREFLY_PROFILE`.** Esta variable de entorno +> selecciona el o los perfiles activos en tiempo de ejecución. +> `FIREFLY_PROFILE=prod` lee `application-prod.yaml`; un valor separado por comas +> (`FIREFLY_PROFILE=dev,cloud`) superpone un archivo por perfil, en orden +> (`application-dev.yaml` y luego `application-cloud.yaml`, gana el posterior). +> Así es como Lumen llevaría un almacén de eventos en memoria en `dev` y uno de +> Postgres en `prod` sin un solo `if` en el código de cableado. + +> **Warning** `load_from_profile` siempre añade `from_env("FIREFLY")` como su capa +> superior, así que sus overrides de entorno se deletrean `FIREFLY_*` +> (`FIREFLY_WEB_ADDR`), *no* `LUMEN_*`. Si quieres una capa de entorno con prefijo +> `LUMEN_`, construye la cadena tú mismo (Paso 6) con `from_env("LUMEN")`. + +## Paso 5 — Entiende la precedencia de sources + +Todo el sistema descansa sobre una regla: **`Layered::new(vec![s1, s2, ...])` +fusiona sus sources de izquierda a derecha, y gana la última escritura.** Las +filas más altas de la tabla siguiente se sitúan más tarde en la lista y, por +tanto, sobrescriben a las más bajas. + +| Orden | Source | Vence a | +|-------|-----------------------------------------------------|--------------| +| 1 | Defaults — `StaticSource::new(name, entries)` | nada | +| 2 | YAML base — `from_optional_yaml("application.yaml")` | defaults | +| 3 | YAML de perfil — `from_optional_yaml("application-prod.yaml")` | base | +| 4 | Entorno — `from_env("FIREFLY")` | archivos YAML | +| 5 | Flags de CLI — `FlagSource::new().set("web.addr", "0.0.0.0:80")` | todo | + +
+ +defaultsStaticSource + +base YAMLapplication.yaml + +profile YAMLapplication-prod.yaml + +environmentFIREFLY_* + +CLI flagsFlagSource +merged left → right · last write wins +beats nothing +beats everything +an env override beats a YAML file; a CLI flag beats both + +
Layered::new(...) fusiona sus sources de izquierda a derecha y gana la última escritura. Los defaults se sitúan los primeros y no vencen a nada; un YAML base vence a los defaults; una superposición de perfil vence a la base; el entorno vence a los archivos YAML; y un flag de CLI vence a todo: un único artefacto, desplegable en cualquier lugar.
+
+ +Así, un override de entorno (`FIREFLY_WEB_ADDR=0.0.0.0:80`) siempre vence a un +archivo YAML, y un flag de CLI vence a ambos. Esa misma precedencia es +exactamente la razón por la que `FireflyApplication` deja que +`FIREFLY_SERVER_ADDR` / `FIREFLY_MANAGEMENT_ADDR` ganen sobre cualquier dirección +de enlace por defecto codificada: la capa de entorno tiene más rango que el +default. + +Por qué importa: la precedencia es lo que hace que un único artefacto sea +desplegable en cualquier lugar. Confirmas valores por defecto sensatos y un +`application.yaml` base, envías una fina superposición `application-prod.yaml` y +dejas que la plataforma inyecte secretos y overrides de última milla a través del +entorno: cada capa solo declara lo que necesita cambiar. + +> **Tip** **Punto de control.** Puedes razonar sobre cualquier valor leyendo la +> tabla de arriba abajo y tomando la primera source que lo define. Si `web.addr` +> aparece tanto en `application.yaml` como en `FIREFLY_WEB_ADDR`, gana el entorno +> porque la fila 4 es posterior a la fila 2. + +## Paso 6 — Construye la cadena de sources de forma explícita + +`load_from_profile` es el valor por defecto cómodo. Cuando necesitas control +total —un prefijo de entorno distinto, valores por defecto codificados a mano, una +source remota intercalada— ensamblas tú mismo el `Vec>` y se lo +pasas a `load`: + +```rust,ignore +use std::collections::HashMap; +use firefly::config::{from_env, from_optional_yaml, load, Source, StaticSource}; + +let sources: Vec> = vec![ + // 1. Defaults at the bottom — overridden by anything below. + Box::new(StaticSource::new( + "defaults", + HashMap::from([("web.addr".to_string(), "127.0.0.1:8080".to_string())]), + )), + // 2. Base YAML beats defaults. + Box::new(from_optional_yaml("application.yaml")), + // 3. A LUMEN_*-prefixed environment layer beats the YAML. + Box::new(from_env("LUMEN")), +]; +let cfg: LumenConfig = load(&sources)?; +``` + +Lo que acaba de ocurrir: deletreaste la cadena de precedencia en el orden de la +lista. `StaticSource::new` toma un nombre y un `HashMap` de entradas codificadas a +mano: se sitúa en el fondo. `from_optional_yaml` lee un archivo si está presente +(y queda silenciosamente vacío si no). `from_env("LUMEN")` mapea +`LUMEN_WEB_ADDR` → `web.addr`. Como la source de entorno es la *última*, +`LUMEN_WEB_ADDR=0.0.0.0:80` sobrescribe tanto el YAML como el default. + +> **Note** **Término clave — `StaticSource` / `from_env` / `from_optional_yaml` / +> `FlagSource`.** Estas son las cuatro sources integradas. `StaticSource` envuelve +> un mapa en memoria (defaults). `from_env(prefix)` lee `PREFIX_FOO_BAR` → +> `foo.bar` del entorno del proceso. `from_optional_yaml(path)` lee un archivo +> YAML, tolerando la ausencia. `FlagSource` recopila overrides de CLI +> establecidos con `.set("web.addr", "...")`. Las cuatro implementan el mismo +> trait `Source`, así que el orden en el `vec!` *es* la precedencia. + +## Paso 7 — Escribe el YAML y conoce las reglas de los valores + +Los archivos YAML los parsea un pequeño escáner de un subconjunto de YAML, línea +a línea —no un motor YAML de propósito general—, de modo que la salida aplanada es +determinista y estable para cualquier archivo dado: + +```yaml +# application.yaml +name: lumen +web: + addr: 127.0.0.1:8080 + admin-addr: 127.0.0.1:8081 +tags: wallet, ledger, demo # a comma-joined scalar binds a Vec +``` + +Las reglas que el escáner garantiza: + +- los mappings anidados se convierten en claves **unidas por puntos y en + minúsculas** (`web.admin-addr` → la clave plana `web.admin_addr` tras la + normalización relajada); +- los lexemas escalares se **preservan literalmente** (`1.10` sigue siendo + `"1.10"`) hasta que el binder los parsea contra el tipo del campo destino; +- las claves duplicadas siguen la regla de **gana la última escritura**; +- los aliases, anclas, archivos multidocumento, tags y secuencias en flujo + **deliberadamente no se interpretan**: trae tu propio parser si los necesitas. + +Lo que acaba de ocurrir: este archivo base declara la identidad de Lumen y sus dos +direcciones de enlace en el hogar tipado que esas direcciones siempre quisieron. +La línea `tags` muestra la única sutileza: una secuencia se escribe como un +escalar unido por comas, y el binder la vuelve a dividir en el campo +`Vec`. + +> **Tip** **Punto de control.** Coloca este archivo junto a un test que llame a +> `load_from_profile(".", "application", "dev")` y afirme +> `cfg.web.admin_addr == "127.0.0.1:8081"`. Que pase demuestra que tanto la +> normalización kebab→snake como la división por comas de la lista funcionan de +> extremo a extremo. + +## Paso 8 — Resuelve los marcadores de posición `${...}` + +`load` (y `bind`) ejecutan una pasada posterior a la fusión que resuelve los +marcadores de posición `${...}` dentro de los valores: la misma sintaxis `${...}` +que usa Spring. También se expone de forma independiente como +`resolve_placeholders(&flat)`. + +```yaml +name: lumen +datasource: + url: ${DATABASE_URL:postgres://localhost/lumen} # env var, else default + pool: ${name}-pool # config reference +``` + +El orden de resolución, de mayor prioridad primero: + +- `${ENV_VAR}` — una variable de entorno literal, leída tal cual; +- la **forma relajada `FIREFLY_*`** de una clave de config: `${name}` también + honra `FIREFLY_NAME` antes de consultar el mapa fusionado, así que **el entorno + vence a la configuración**; +- `${name}` — una referencia de config al propio mapa fusionado, resuelta de forma + recursiva con una protección de profundidad 10 contra ciclos; +- `${key:default}` — el texto tras el primer `:` es una reserva cuando ni el + entorno ni la config resuelven `key`. + +Lo que acaba de ocurrir: `datasource.url` lee `DATABASE_URL` del entorno cuando +está presente y, en caso contrario, recurre al default local: una sola línea que +es correcta tanto en dev como en prod. `datasource.pool` interpola otro valor de +config (`name` → `lumen`) para producir `lumen-pool`. + +> **Warning** Un marcador de posición irresoluble *sin* un default lanza +> `ConfigError::Placeholder`, y lo mismo hace una referencia circular (`a: ${b}` / +> `b: ${a}`) en cuanto activa la protección de profundidad 10. Un +> `${DATBASE_URL}` con una errata y sin `:default` falla la carga ruidosamente en +> lugar de enlazar una cadena vacía. + +## Paso 9 — Enlaza la config directamente en un bean con `#[derive(ConfigProperties)]` + +Cargar un struct a mano en `main` está bien, pero los servicios de Lumen quieren +su configuración *inyectada*, no enhebrada a través de cada constructor. +`#[derive(ConfigProperties)]` convierte un struct `serde` en un bean gestionado +por el contenedor y enlazado por prefijo: el patrón exacto sobre el que construye +el siguiente capítulo. + +```rust,ignore +use firefly::prelude::*; +use serde::Deserialize; + +/// Binds the `lumen.web.*` config subtree into an injectable bean. +#[derive(Deserialize, ConfigProperties, Default)] +#[firefly(prefix = "lumen.web")] +pub struct WebProperties { + pub addr: String, + #[serde(default)] + pub admin_addr: String, +} +``` + +Lo que acaba de ocurrir: el derive registra `WebProperties` como un singleton cuya +factoría enlaza la porción `lumen.web.*` del mapa de config fusionado, resuelto +por perfil y con los marcadores de posición expandidos. El contenedor lo calienta +con avidez al arranque, de modo que cualquier bean puede recibirlo después por +tipo. + +> **Note** **Término clave — bean / autowiring.** Un *bean* es un objeto que el +> framework construye y gestiona por ti; el *autowiring* es que el framework +> entrega un bean a quienquiera que declare un campo para él. Un bean +> `#[derive(Service)]` escribe `#[autowired] props: Arc` y recibe +> los valores enlazados: sin `load` manual, sin global. Cablearás uno en +> [Cableado de dependencias](./04-dependency-wiring.md). Esto es el bean +> `@ConfigurationProperties` de Spring inyectado con `@Autowired`. + +Para escalares aislados hay un toque más ligero: inyecta un único valor resuelto +sobre un campo con un default: + +```rust,ignore +#[firefly(value = "${lumen.web.addr:127.0.0.1:8080}")] +addr: String, +``` + +Para *validar* un bean de propiedades tras el enlace —el `@Validated` de Spring +sobre una clase `@ConfigurationProperties`— añade `#[firefly(validate)]` y +`#[derive(Validate)]`. La macro ejecuta las restricciones declarativas del struct +una vez que la config está enlazada, y una violación **hace fallar la creación del +bean** en el refresco del contexto con los errores estructurados por campo, en +lugar de dejar arrancar una configuración malformada: + +```rust,ignore +use firefly::prelude::*; +use serde::Deserialize; + +#[derive(Deserialize, ConfigProperties, Validate, Default)] +#[firefly(prefix = "lumen.web", validate)] // @ConfigurationProperties @Validated +pub struct WebProperties { + #[validate(not_empty)] + pub addr: String, + #[serde(default)] + pub admin_addr: String, +} +``` + +Lo que acaba de ocurrir: un `lumen.web.addr` vacío ahora aborta el arranque con +una violación clara por campo (`addr: must not be empty (not_empty)`) en lugar de +enlazar `""` y fallar más tarde cuando algo intente enlazar un socket. + +> **Design note.** Firefly ofrece dos estilos de enlace contra el *mismo* mapa +> fusionado, resuelto por perfil y con los marcadores de posición expandidos. Un +> bean enlazado por prefijo (`#[derive(ConfigProperties)]` + +> `#[firefly(prefix = "...")]`) tira de todo un subárbol de config a un único +> struct inyectable; la inyección de un solo valor (`#[firefly(value = "${...}")]`) +> cablea un único escalar resuelto sobre un campo. Usa el primero para un grupo de +> ajustes cohesivo, el segundo para un mando suelto. + +## Paso 10 — Autoconfigura la datasource y la seguridad desde `application.yaml` + +La maquinaria de propiedades hasta ahora te entrega un struct tipado. Los crates +de infraestructura de Firefly dan el siguiente paso: un puñado de subsistemas son +**dirigidos por config y libres de DI**: enlazas un struct `serde` corriente desde +`application.yaml`/entorno, luego haces `await` de una única llamada de +autoconfiguración al arranque, y el subsistema se levanta a sí mismo. Sin +contenedor, sin cadenas de builders manuales, sin ramificación +`if scheme == "postgres"` en tu cableado. Dos subsistemas en los que Lumen se +apoya de esta manera son su datasource y su capa de seguridad. + +Ambos se alimentan de un único árbol YAML. `firefly.datasource.*` se enlaza sobre +`DataSourceProperties` y `firefly.security.*` sobre `SecurityProperties`: + +```yaml +firefly: + datasource: + url: ${DATABASE_URL:postgres://localhost/lumen} # scheme picks the backend + max-connections: 16 + min-connections: 2 + acquire-timeout-ms: 5000 + idle-timeout-ms: 600000 + max-lifetime-ms: 1800000 + security: + jwt: + jwk-set-uri: https://idp.example.com/.well-known/jwks.json + issuer-uri: https://idp.example.com/ + audience: lumen-api + bearer: + header-name: Authorization + allow-anonymous: false +``` + +### La datasource — `DataSourceProperties` → pool → gestor de transacciones + +`DataSourceProperties` es un struct `serde` corriente con los campos `{ url, +max_connections, min_connections, acquire_timeout_ms, idle_timeout_ms, +max_lifetime_ms }`. El **esquema de la URL selecciona el backend**, cada uno tras +su propia feature de cargo: `postgres://` / `postgresql://` → PostgreSQL, +`mysql://` → MySQL, `sqlite:` → SQLite. Un `0` en cualquier ajuste del pool deja en +su lugar el valor por defecto de `sqlx`. + +`firefly::data_sqlx::auto_configure(&props)` hace lo único que quieres al +arranque: construye el pool de conexiones **y** registra un +`SqlxTransactionManager` sobre él, de modo que `#[transactional]` se resuelve más +tarde sin cableado manual. El `Db` devuelto es el mismo pool, listo para construir +repositorios tipados. (Para un control más fino, `Db::connect(url)` y +`Db::connect_with(&props)` construyen solo el pool.) + +```rust,ignore +use firefly::data_sqlx::{auto_configure, DataSourceProperties}; + +// `Db` carries the pool; auto_configure also registers the tx manager. +let db = auto_configure(&props).await?; // Result +``` + +> **Note** **Término clave — gestor de transacciones.** Un *gestor de +> transacciones* abre, confirma y revierte transacciones de base de datos en +> nombre del atributo `#[transactional]`. Al registrar uno, `auto_configure` hace +> que `#[transactional]` funcione en todo el proceso sin que tú construyas ni +> enhebres el gestor en ningún sitio: el análogo en Rust de que Spring Boot +> autoconfigure un `DataSourceTransactionManager`. Lo usarás en +> [Persistencia](./07-persistence.md). + +### La capa de seguridad — `SecurityProperties` → verifier → capa bearer + +`SecurityProperties` anida `{ jwt: JwtProperties, bearer: BearerProperties }`. +`JwtProperties` contiene `{ jwk_set_uri, issuer_uri, audience, secret, algorithm, +expiration_seconds }`; `BearerProperties` contiene `{ header_name, allow_anonymous }`. +Dos funciones lo convierten en middleware en ejecución: + +- `verifier_from_config(&props.jwt)` devuelve + `Result>, SecurityError>`. Un `jwk_set_uri` no vacío + construye un verifier de servidor de recursos JWKS (RS256); en caso contrario, + un `secret` no vacío construye un verifier HMAC (`HS256`/`HS384`/`HS512`); en + caso contrario, `None`. +- `bearer_layer_from_config(&props)` devuelve + `Result, SecurityError>`: la capa lista para montar con el + nombre de cabecera configurado y la política anónima ya aplicados, o `None` + cuando no hay ningún verifier configurado. + +> **Note** **Término clave — verifier / capa bearer.** Un *verifier* comprueba la +> firma y los claims de un JWT entrante; una *capa bearer* es el middleware HTTP +> que extrae el token de la cabecera de la petición y ejecuta el verifier. Juntos +> son el análogo en Rust de una cadena de filtros de servidor de recursos de +> Spring Security. La historia completa de seguridad está en +> [Seguridad](./14-security.md); aquí solo estás aprendiendo que ambos pueden +> *configurarse*, no construirse a mano. + +### El cableado de arranque de una sola llamada + +Enlaza un único struct de config y luego dirige ambos subsistemas desde él. Todo +el cableado es una carga más dos llamadas esperadas: + +```rust,ignore +use firefly::config::{load_from_profile, ConfigError}; +use firefly::data_sqlx::{auto_configure, DataSourceProperties}; +use firefly::security::{bearer_layer_from_config, SecurityProperties}; +use serde::Deserialize; + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct Firefly { + datasource: DataSourceProperties, + security: SecurityProperties, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct LumenConfig { + firefly: Firefly, // binds the `firefly.datasource.*` / `firefly.security.*` subtree +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // 1. Load + merge + profile-resolve + placeholder-expand, then bind. + let cfg: LumenConfig = load_from_profile("/etc/lumen", "application", "dev")?; + + // 2. Build the pool AND register the transaction manager in one await. + let db = auto_configure(&cfg.firefly.datasource).await?; + + // 3. Build the ready-to-mount bearer layer (None if no JWT settings). + let bearer = bearer_layer_from_config(&cfg.firefly.security)?; + + // `db` builds typed repositories; mount `bearer` on the web stack. + // ... + let _ = (db, bearer); + Ok(()) +} +``` + +Lo que acaba de ocurrir: esta es la cadena de precedencia del Paso 5 haciendo +trabajo real. `DATABASE_URL` en el entorno sobrescribe el default del YAML para el +pool, y el endpoint JWKS puede reapuntarse por perfil sin tocar el código. Tanto +la maquinaria de `#[transactional]` como el middleware bearer recogen lo que +`auto_configure` y `bearer_layer_from_config` registraron: sin globales +enhebrados a través de tus constructores. + +> **Design note.** Firefly mantiene deliberadamente esta ruta libre de DI: los +> structs de config son tipos `serde` corrientes y las llamadas de +> autoconfiguración son `async fn`s corrientes de las que haces `await` al +> arranque. Puedes adoptar más tarde el estilo completo de contenedor +> `#[derive(ConfigProperties)]` sin reescribir nada de esto: los mismos valores +> enlazados fluyen de cualquiera de las dos maneras. + +> **Tip** **Punto de control.** Incluso sin una base de datos real, este `main` +> compila: `auto_configure` contra `sqlite::memory:` (establece +> `firefly.datasource.url` a `sqlite::memory:`) devuelve un `Db` vivo que puedes +> conservar, y un `firefly.security.*` vacío hace que `bearer_layer_from_config` +> devuelva `Ok(None)`. + +## Paso 11 — Condiciona beans por expresión de perfil + +A veces un valor no basta: quieres que todo un bean exista solo en algunos +entornos. `accepts_profiles(&active, &exprs)` evalúa una gramática de expresiones +de perfil contra una lista de perfiles activos: AND (`&`), OR (`|`), negación +(`!`) y agrupación con paréntesis. + +```rust,ignore +use firefly::config::{accepts_profiles, active_profiles}; + +let active = active_profiles("dev"); // e.g. ["prod", "cloud"] +accepts_profiles(&active, &["prod & cloud"]); // AND +accepts_profiles(&active, &["prod | qa"]); // OR +accepts_profiles(&active, &["!test"]); // negation +accepts_profiles(&active, &["(prod & cloud) | qa"]); // grouping +``` + +Lo que acaba de ocurrir: `active_profiles("dev")` lee el `FIREFLY_PROFILE` +separado por comas (recurriendo a `"dev"`), y `accepts_profiles` responde si +*alguna* de las expresiones dadas coincide con ese conjunto activo. Devuelve +`true` ante una coincidencia; una expresión malformada evalúa a `false` y nunca +provoca panic. + +Por qué importa: el siguiente capítulo muestra un bean que declara +`#[firefly(profile = "prod")]`, y el contenedor aplica exactamente esta regla en +tiempo de escaneo, de modo que un bean exclusivo de Postgres simplemente no existe +en el perfil `dev`. + +## Paso 12 — Recarga en tiempo de ejecución y enmascara secretos + +Dos preocupaciones operativas redondean el cuadro. + +**Recarga en tiempo de ejecución.** `ReloadableConfig` mantiene viva la cadena +de sources tras el primer enlace. `reload()` reproduce el pipeline completo de +fusión → resolución de marcadores de posición → enlace y cambia atómicamente el +snapshot; una recarga fallida conserva el anterior. Este es el gancho que cablea +un endpoint `POST /actuator/refresh`, de modo que un operador podría reapuntar la +datasource de Lumen sin reiniciar. + +```rust,ignore +use firefly::config::{ReloadableConfig, Source}; + +let cfg: ReloadableConfig = ReloadableConfig::load(sources)?; +let snapshot = cfg.get(); // Arc — read per use +let mut rx = cfg.subscribe(); // tokio watch receiver +let changed: Vec = cfg.reload()?; // sorted, changed top-level keys +``` + +`Arc>` se convierte en `Arc`: el trait +object-safe del que depende el endpoint de refresco del actuator. + +> **Note** **Término clave — refresh scope.** Un lector *con ámbito de refresco* +> llama a `cfg.get()` en cada uso en lugar de cachear el valor interno, de modo +> que siempre ve el snapshot más reciente tras una recarga. Este es el análogo en +> Rust del `@RefreshScope` de Spring Cloud más su contrato +> `POST /actuator/refresh`. + +**Enmascarado de secretos.** `Layered::property_sources()` devuelve +`PropertySourceView`s ordenados y atribuidos a su origen (mayor precedencia +primero): los datos que renderiza la vista `/actuator/env` de Firefly, con los +secretos enmascarados. Las claves que nombran secretos (`password`, `secret`, +`token`, `credential`, `*key`, …) se enmascaran como `******`, y una contraseña +incrustada en el userinfo de una URI se redacta +(`postgresql://user:******@host`). El módulo `mask` expone directamente +`mask_value`, `is_sensitive_key` y `sanitize_uri`. + +Por qué importa para Lumen: en el momento en que sostenga una clave de firma JWT +(capítulo 14) y una URL de datasource, ninguna debería aparecer jamás en texto +plano en `/actuator/env`, y con el enmascarado activado por defecto, ninguna lo +hace. + +> **Tip** **Punto de control.** Añade una clave `datasource.password` a un +> `StaticSource`, llama a `Layered::new(sources).property_sources()` y confirma +> que el valor renderizado es `******`, no el secreto. + +## Paso 13 — Obtén configuración desde un servidor de configuración (opcional) + +Para una flota de servicios, puedes centralizar la configuración. `ConfigClient` +obtiene un documento remoto (compatible con el formato de cable del servidor +Spring Cloud Config) y lo aplana en un `StaticSource` que insertas en la cadena por +encima de los defaults: + +```rust,ignore +use firefly::config::ConfigClient; + +let remote = ConfigClient::new("http://config:8888", "lumen") + .with_profile("prod") + .with_label("main") + .with_basic_auth("user", "pass") + .fetch_source() // fail-fast; .fetch_source_or_empty() = soft fallback + .await?; +sources.insert(1, Box::new(remote)); // above defaults, below env/flags +``` + +Lo que acaba de ocurrir: `ConfigClient::new(url, app)` construye un cliente (el +perfil por defecto es `default`, la label es `main`); los métodos del builder +establecen el resto; `fetch_source().await` consulta +`{url}/{app}/{profile}/{label}` y devuelve un `StaticSource`. Una respuesta no 2xx +registra un warning y produce un mapa vacío (un fallo suave); los fallos de +transporte o de decodificación lanzan `ConfigError::Remote`. El servidor +independiente vive en [`firefly-config-server`](./91-appendix-modules.md). + +## Eventos de aplicación en proceso + +Vale la pena nombrar una pieza más del crate de config, porque te la encontrarás +en los límites del ciclo de vida. `ApplicationEventBus` es un pub/sub +**en proceso, despachado por `TypeId`, ordenado y síncrono** para eventos de ciclo +de vida y de notificación local, distinto del broker asíncrono `firefly-eda` que +Lumen usa para los eventos de dominio (sin transporte, sin topics; los listeners +se ejecutan en el hilo que publica): + +```rust,ignore +use firefly::config::{ApplicationEventBus, ApplicationReadyEvent}; + +let bus = ApplicationEventBus::new(); +bus.subscribe::(|_e| { /* on ready */ }); +bus.publish(&ApplicationReadyEvent); +``` + +Eventos de ciclo de vida que vienen incluidos: `ContextRefreshedEvent`, +`ApplicationReadyEvent`, `ContextClosedEvent` y `RefreshScopeRefreshedEvent` +(disparado tras una recarga exitosa). Cualquier tipo `'static` puede publicarse +como un evento de dominio local. + +> **Note** No confundas esto con +> [Arquitectura dirigida por eventos](./10-eda-messaging.md): el +> `ApplicationEventBus` es un canal *local* de ciclo de vida/notificación; los +> eventos de dominio del monedero de Lumen viajan sobre el `Broker` de +> `firefly-eda` por un topic, con un adaptador real de Kafka/RabbitMQ esperando +> tras el default en memoria. + +## Resumen — qué cambió en Lumen + +| Antes | Después de este capítulo | +|-------|--------------------------| +| identidad codificada a mano en dos cadenas `pub const` | los mismos valores entendidos como mandos de `CoreConfig` que alimentan el banner y `/actuator/info` | +| direcciones de enlace leídas por `FireflyApplication` desde `FIREFLY_SERVER_ADDR` / `FIREFLY_MANAGEMENT_ADDR` | el hogar tipado para esas direcciones, situado en lo alto de una cadena de precedencia documentada | +| sin ruta hacia ajustes por entorno | perfiles, marcadores de posición y `#[derive(ConfigProperties)]` listos para la inyección en el siguiente capítulo | +| la datasource y la seguridad se construirían a mano | ambas se levantan desde `application.yaml` con una única llamada de autoconfiguración esperada para cada una | +| secretos sin considerar | enmascarado + redacción de `/actuator/env` en su sitio antes de que Lumen sostenga siquiera una clave de firma | + +Ahora también sabes: + +- Que la configuración es un **mapa plano de cadenas con clave por puntos** + enlazado sobre un struct `serde` tipado, con el *tipo destino* dirigiendo cada + parseo. +- La **cadena de precedencia** —defaults → YAML base → YAML de perfil → entorno → + flags de CLI— y que gana la última source. +- Que `load_from_profile` es el valor por defecto cómodo (con una capa de entorno + `FIREFLY_*`), mientras que un `Vec>` explícito + `load` da + control total. +- Cómo se resuelven los marcadores de posición `${...}` (el entorno vence a la + config, con reservas `:default` y una protección contra ciclos), cómo + `#[derive(ConfigProperties)]` inyecta un subárbol enlazado, y cómo + `auto_configure` / `bearer_layer_from_config` levantan subsistemas enteros desde + YAML. + +## Ejercicios + +1. **Promueve los puertos a YAML.** Escribe un `application.yaml` con `web.addr` / + `web.admin-addr`, cárgalo con `load_from_profile(".", "application", "dev")` y + confirma que una variable de entorno `FIREFLY_WEB_ADDR` sigue ganando (la fila + 4 de precedencia vence a la fila 2). Luego reconstruye la cadena a mano con + `from_env("LUMEN")` y muestra que `LUMEN_WEB_ADDR` gana en su lugar. +2. **Añade un perfil.** Crea `application-prod.yaml` que sobrescriba `web.addr` a + `0.0.0.0:80`, ejecuta con `FIREFLY_PROFILE=prod` y verifica que el valor de + prod surte efecto mientras un `dev` simple conserva el enlace a localhost. +3. **Resuelve un marcador de posición.** Establece `datasource.url: + ${DATABASE_URL:postgres://localhost/lumen}` en el YAML, carga una vez con + `DATABASE_URL` sin establecer (afirma el default) y otra con él establecido + (afirma el override). Luego elimina el `:default` y confirma que el caso sin + establecer ahora lanza `ConfigError::Placeholder`. +4. **Enlaza un bean `ConfigProperties`.** Define el struct `WebProperties` del + Paso 9, establece `lumen.web.addr` mediante un + `ConditionContext::new().with_property(...)` y resuelve `WebProperties` desde un + `Container`: reconocerás este patrón en los tests de DI del siguiente capítulo. +5. **Enmascara un secreto.** Añade una clave `datasource.password` a un + `StaticSource`, llama a `Layered::new(sources).property_sources()` y confirma + que el valor se renderiza como `******` en lugar de en texto plano. + +## Adónde ir después + +- Mira cómo la raíz de composición de Lumen resuelve sus colaboradores —y cómo el + contenedor de primera clase escanea y cablea los beans (incluidos los de + `#[derive(ConfigProperties)]` que acabas de conocer)— en + **[Cableado de dependencias](./04-dependency-wiring.md)**. +- Convierte la datasource configurada en repositorios tipados en + **[Persistencia](./07-persistence.md)**. +- Promueve los defaults en proceso a Postgres y Kafka reales en + **[Producción y despliegue](./20-production.md)**. diff --git a/docs/book/src-es/04-dependency-wiring.md b/docs/book/src-es/04-dependency-wiring.md new file mode 100644 index 00000000..e9d59ae4 --- /dev/null +++ b/docs/book/src-es/04-dependency-wiring.md @@ -0,0 +1,770 @@ +# Cableado de dependencias + +En el [Inicio rápido](./02-quickstart.md) escribiste un `main` de una sola línea y +viste cómo `FireflyApplication::new("lumen").run()` arrancaba un servicio entero. +Una línea de esa secuencia de arranque hizo algo que un servicio normalmente +construye a mano: ensambló el **grafo de objetos** — construyó la caché, el bus +CQRS, el almacén de eventos, el ledger y el controlador que depende de todos +ellos, en el orden correcto, y los conectó. Este capítulo trata sobre *cómo*. + +La respuesta breve es que tú nunca escribes ese ensamblaje. En un servicio +Firefly **declaras** cada colaborador como un bean — una struct con un derive de +estereotipo, o un método factory —, marcas sus dependencias con `#[autowired]` y +el **escaneo de componentes** del framework descubre cada declaración en el +arranque y cablea el grafo por ti. No hay raíz de composición escrita a mano, ni +`build_app`, ni una lista de llamadas `new(...)` enhebradas a lo largo de una +función. Tú dices *qué* necesita cada pieza; el contenedor lo provee. + +Aprenderemos esto del mismo modo que enseña el resto del libro: contra los beans +reales de Lumen, los que están en `samples/lumen`. Al terminar serás capaz de leer +el cableado de Lumen, ampliarlo y explicar exactamente qué hizo el framework en el +arranque. El capítulo inmediatamente siguiente, +[Inyección de dependencias y autoconfiguración](./04a-dependency-injection.md), +recorre después en profundidad toda la superficie del contenedor; este capítulo te +da el modelo mental funcional sobre el que se apoya. + +Al terminar este capítulo, serás capaz de: + +- Explicar qué son un **bean**, un **estereotipo** y un **escaneo de componentes**, y + cómo reemplazan a una raíz de composición escrita a mano. +- Declarar beans de dos formas — un derive de estereotipo sobre una struct que + posees y una factory `#[bean]` para las que no —, y saber cuándo recurrir a cada + una. +- Usar `#[autowired]` para inyectar una sola dependencia, una colección completa, + una opcional o un `Provider` diferido. +- Vincular un trait con su implementación mediante `provides` y desambiguar varios + candidatos con `primary` y `order`. +- Leer el inventario de beans de Lumen en el informe de arranque y rastrear cómo + `FireflyApplication` resuelve el grafo a partir de las declaraciones. + +## Conceptos que conocerás + +Antes de la primera declaración, aquí tienes las cuatro ideas en las que se apoya +este capítulo. Cada una se reintroduce en su contexto cuando se usa por primera +vez; esta es la versión breve. + +> **Note** **Término clave — bean.** Un *bean* es un objeto que el framework +> construye y gestiona por ti, y luego entrega a quien lo declare como dependencia. +> Tú declaras los beans; el contenedor los descubre en el arranque y los conecta. +> Esto es exactamente la noción de Spring de un bean gestionado por el contexto de +> aplicación. + +> **Note** **Término clave — inyección de dependencias (DI).** La *inyección de +> dependencias* significa que un componente no construye sus propios colaboradores +> — declara *qué* necesita y el framework se lo provee. La pieza que hace ese +> aprovisionamiento es el **contenedor de DI**. El contenedor de Firefly es el +> análogo en Rust del `ApplicationContext` de Spring. + +> **Note** **Término clave — estereotipo.** Un *estereotipo* es un derive que +> pones sobre una struct para convertirla en un bean gestionado y registrar su rol +> arquitectónico — lógica de negocio, acceso a datos, capa HTTP, etcétera. Los +> cinco estereotipos de Firefly (`Service`, `Component`, `Repository`, +> `Configuration`, `Controller`) reflejan los `@Service`, `@Component`, +> `@Repository`, `@Configuration` y `@Controller` de Spring. + +> **Note** **Término clave — escaneo de componentes.** Un *escaneo de componentes* +> es la pasada de arranque que encuentra cada bean declarado y lo registra. Spring +> escanea el classpath con reflexión; Rust no tiene reflexión en tiempo de +> ejecución, así que el escaneo de Firefly es *en tiempo de enlazado* — cada derive +> de estereotipo emite un registro que el escaneo recopila del binario compilado. + +## Paso 1 — Ver el cableado que ya no escribes + +Abre `samples/lumen/src/web.rs` y lee el comentario de documentación de su módulo. +Llama al archivo "la **raíz de composición**", e inmediatamente después te dice que +no hay ninguna escrita a mano. + +> **Note** **Término clave — raíz de composición.** La *raíz de composición* es el +> único lugar de un programa donde se ensambla el grafo de objetos — donde cada +> componente se construye y se conecta. En muchos frameworks escribes esta función +> a mano. En Firefly el framework *es* la raíz de composición: escanea tus beans y +> los cablea, de modo que nunca deletreas el grafo. + +Recuerda el `main` de una sola línea del Inicio rápido: + +```rust,ignore +// src/main.rs +#[tokio::main] +async fn main() -> Result<(), firefly::BoxError> { + firefly::FireflyApplication::new("lumen").run().await +} +``` + +Esa única llamada ensambla el grafo de objetos completo de Lumen. Dentro de +`run()`, el cableado ocurre en tres movimientos: + +1. Construye la pila web y **autorregistra** los beans de infraestructura propios + del framework — el `Bus` CQRS, el `Broker` de eventos, la caché, el registro de + métricas, el planificador — en el contenedor, para que *tus* beans puedan + inyectarlos por autowiring. +2. **Escanea los componentes** del grafo de crates: cada tipo derivado de un + estereotipo y cada factory `#[bean]` de Lumen se descubre, se comprueba contra + sus condiciones y se registra. +3. **Resuelve** los controladores en orden para montarlos, lo que construye + recursivamente los colaboradores con autowiring de cada controlador en orden de + dependencias — exactamente el grafo que construiría una raíz escrita a mano, pero + derivado de las declaraciones en lugar de deletreado. + +Lo que acaba de ocurrir: nada en tu código nombra el orden en el que se construyen +la caché, el bus, el almacén, el ledger y el controlador. Declaraste cada uno junto +a sí mismo; el contenedor calculó el orden a partir de los tipos de dependencia. +Ese es todo el truco, y el resto del capítulo es la mecánica que hay detrás. + +> **Tip** **Punto de control.** Abre `samples/lumen/src/web.rs` y localiza el +> comentario que dice que no hay "**ninguna raíz de composición escrita a mano ni +> builder**". Todos los ejemplos de abajo provienen de este archivo (y de sus +> hermanos `ledger.rs` y `commands.rs`). Estás leyendo el cableado real, no un +> juguete. + +## Paso 2 — Declarar un bean propio con un estereotipo + +El bean más simple es una struct que puedes anotar directamente. La haces visible +al contenedor derivando un **estereotipo**. El modelo de lectura de Lumen es +exactamente este caso — un mapa en memoria que escribe la proyección y lee la +consulta `GetWallet`: + +```rust,ignore +// src/ledger.rs — the CQRS query side, a scanned data-access bean. +use std::collections::HashMap; +use std::sync::Mutex; +use firefly::prelude::*; + +/// The in-memory read model — a `#[derive(Repository)]` bean (Spring's +/// `@Repository`): the projection upserts it, `GetWallet` reads it. +#[derive(Debug, Default, Repository)] +pub struct ReadModel { + rows: Mutex>, +} +``` + +Lo que acaba de ocurrir, bloque a bloque: + +- `#[derive(Repository)]` es el estereotipo. Declara `ReadModel` como un bean + gestionado *y* registra su rol como capa de acceso a datos. Ese único derive es + todo el registro que hay — sin llamada a `register(...)`, sin entrada en una + lista. +- `Default` permite al contenedor construir el bean sin argumentos. Una struct + derivada de un estereotipo sin campos `#[autowired]` se construye mediante su + `Default` y luego se registra como un **singleton** (una única instancia + compartida para el proceso). +- Los campos propios de la struct (`rows`) son estado ordinario. Solo los campos + que marcas con `#[autowired]` — y `ReadModel` no tiene ninguno — se rellenan + desde el contenedor. + +Los cinco estereotipos difieren únicamente en el rol arquitectónico que comunican; +los cinco registran el tipo como un bean gestionado: + +| Derive | Rol | +|----------------------------|----------------------------------------------------------| +| `#[derive(Service)]` | Capa de lógica de negocio: orquestación de casos de uso. | +| `#[derive(Component)]` | Bean gestionado genérico sin un rol específico. | +| `#[derive(Repository)]` | Capa de acceso a datos: bases de datos, almacenamiento externo, puertos. | +| `#[derive(Configuration)]` | Un contenedor de factories que puede albergar métodos `#[bean]`. | +| `#[derive(Controller)]` | Capa HTTP (`#[rest_controller]` se construye sobre esto).| + +> **Design note.** El rol que registra cada estereotipo no es cosmético. Se +> almacena en el bean, de modo que la vista `/beans` del panel de administración (y +> el informe de arranque) puede agrupar los beans por capa — `[repository] +> ReadModel`, `[service] WalletHandlers`, etcétera — la misma introspección de DI +> que expone Spring Boot Actuator. + +> **Tip** **Punto de control.** `ReadModel` se convierte en un bean a partir de un +> derive y un `Default`. Quédate con esa imagen: *un derive de estereotipo es el +> registro.* + +## Paso 3 — Inyectar dependencias con `#[autowired]` + +Un modelo de lectura no tiene colaboradores, pero la mayoría de los beans sí. Para +declarar lo que un bean necesita, marca un campo con `#[autowired]` y el contenedor +lo rellena por tipo. Esta es la forma de escribir en Rust la inyección por +constructor: tú declaras *qué*, el contenedor lo provee. El controlador de wallet +de Lumen es el caso de manual (el `WalletApi` real de `src/web.rs`): + +```rust,ignore +use std::sync::Arc; +use firefly::cqrs::QueryCache; +use firefly::prelude::*; + +/// The wallet HTTP surface — a `#[derive(Controller)]` DI bean. Its +/// collaborators are autowired from the container; `#[rest_controller]` +/// auto-mounts it (Chapter 6). +#[derive(Clone, Controller)] +pub struct WalletApi { + #[autowired] + pub bus: Arc, // the CQRS bus it dispatches through + #[autowired] + pub ledger: Arc, // the application service the saga + stream use + #[autowired] + pub query_cache: Arc, // invalidated after a mutation +} +``` + +> **Note** **Término clave — `Arc`.** `Arc` es el puntero compartido con conteo +> de referencias atómico de Rust. El contenedor reparte singletons compartidos, de +> modo que una dependencia inyectada llega como `Arc` — muchos beans pueden +> mantener el mismo `Arc` y todos ven la única instancia. Allí donde veas +> `#[autowired] field: Arc`, léelo como "dame el `T` compartido". + +Lo que acaba de ocurrir: cuando el contenedor construye `WalletApi`, primero +resuelve el `Bus`, luego el `Ledger` (construyendo recursivamente *sus* propias +dependencias — las verás en el Paso 5), después el `QueryCache`, y solo entonces +inyecta los tres y devuelve el controlador. No escribiste ningún constructor; los +tipos de los campos *son* la firma del constructor. + +Una dependencia que no existe aflora como un **error de resolución claro en el +arranque** — un "no such bean" con nombre que apunta al tipo que falta — y no como +un panic tres marcos más adentro en tiempo de ejecución. El cableado con fallo +rápido es la idea central. + +`#[autowired]` inyecta más que un único `Arc`. La *forma* del campo selecciona +el modo de inyección: + +- `#[autowired] widgets: Vec>` inyecta **cada** `Widget` registrado, + ordenado por el `order` de cada bean — inyección de colección, la forma de reunir + todas las implementaciones de un puerto. +- `#[autowired] maybe: Option>` inyecta `Some` cuando hay un `Thing` + registrado y `None` cuando no lo hay — una dependencia opcional que no aborta el + arranque si está ausente. +- `#[autowired] tickets: Provider` inyecta un manejador **diferido**: + `tickets.get()` resuelve un valor nuevo en cada llamada, la forma de obtener un + transient dentro de un singleton. + +> **Note** **Término clave — `Provider`.** Un `Provider` es un manejador +> perezoso a un bean en lugar del bean en sí. Llamar a `tickets.get()` lo resuelve +> bajo demanda. Es el análogo en Rust del `ObjectProvider` / `Provider` de +> Spring, y la forma en que un singleton de larga vida obtiene un bean de vida +> corta cada vez que necesita uno. + +> **Tip** **Punto de control.** `WalletApi` nombra tres colaboradores y no +> construye ninguno. Si eliminaras la línea `#[autowired] ledger`, el controlador ya +> no pediría un ledger — el campo es la petición completa. + +## Paso 4 — Declarar beans que no posees con factories `#[bean]` + +No todo colaborador es una struct sobre la que puedas poner un derive. El almacén +de eventos, la caché de consultas, el servicio JWT y el ledger se *construyen* — +toman argumentos de constructor, o vienen de un crate de terceros, o una factory es +sencillamente la forma más clara de expresarlos. Para estos, declaras un contenedor +`#[derive(Configuration)]` y le das métodos factory `#[bean]`. + +> **Note** **Término clave — factory `#[bean]`.** Un método `#[bean]` es una +> factory: el contenedor lo llama y registra lo que devuelva como un bean, indexado +> por el **tipo de retorno** del método. El contenedor lleva +> `#[derive(Configuration)]`. Esto es la clase `@Configuration` de Spring con +> métodos `@Bean`, uno a uno. + +Aquí está el contenedor `LumenBeans` completo de Lumen, en `src/web.rs`: + +```rust,ignore +// src/web.rs +use std::sync::Arc; +use firefly::cqrs::QueryCache; +use firefly::eda::Broker; +use firefly::eventsourcing::{EventStore, MemoryEventStore}; +use firefly::prelude::*; +use firefly::security::{BearerLayer, FilterChain, JwtService}; + +/// Lumen's `@Configuration` holder. Its `#[bean]` factory methods **declare** +/// the app's domain beans. `container.scan()` discovers and registers them — +/// the framework does the registration, so there is no `register_arc` to call. +#[derive(Configuration)] +struct LumenBeans; + +#[bean] +impl LumenBeans { + /// The in-memory event store (`@Bean`). + #[bean] + fn event_store(&self) -> MemoryEventStore { + MemoryEventStore::new() + } + + /// The read-side query cache honouring `GetWallet`'s 30s TTL (`@Bean`). + #[bean] + fn query_cache(&self) -> QueryCache { + QueryCache::new() + } + + /// The HS256 JWT service (`@Bean`). + #[bean] + fn jwt_service(&self) -> JwtService { + JwtService::new(crate::security::DEMO_SIGNING_KEY) + } + + /// The security filter chain + bearer layer — auto-discovered and applied + /// by `FireflyApplication`, no `.security(...)` call (Chapter 14). + #[bean] + fn security_filter_chain(&self) -> FilterChain { + crate::security::security_layers().1 + } + #[bean] + fn bearer_layer(&self) -> BearerLayer { + crate::security::security_layers().0 + } + + /// The ledger application service — a **pure factory** whose parameters are + /// **autowired**: the container resolves the event store and the + /// framework-provided `Broker` port by type, then hands them to the factory. + #[bean] + fn ledger(&self, store: Arc, broker: Arc) -> Ledger { + let store: Arc = store; + Ledger::new(store, broker) + } +} +``` + +Lo que acaba de ocurrir, bloque a bloque: + +- `#[derive(Configuration)] struct LumenBeans;` es el contenedor. El escaneo lo + descubre del mismo modo que descubre `ReadModel` — un derive. +- `#[bean] impl LumenBeans { ... }` marca todo el bloque impl como portador de + factories de beans, y cada método `#[bean]` interno se registra como su propio + bean. El contenedor indexa cada uno por su tipo de retorno: `event_store` registra + un `MemoryEventStore`, `query_cache` registra un `QueryCache`, y así + sucesivamente. +- `event_store`, `query_cache` y `jwt_service` solo toman `&self` — son factories + sin dependencias. El contenedor las llama y registra el resultado. +- `ledger(&self, store: Arc, broker: Arc)` es la + importante: sus **parámetros se resuelven a su vez desde el contenedor** por tipo + antes de que el método se ejecute. Un bean puede depender de un bean. El + contenedor construye el `MemoryEventStore` (a partir de la factory `event_store` + de arriba) y provee el `Broker` del framework, y luego llama a `ledger`. + +> **Note** **Término clave — puerto y adaptador.** Un *puerto* es una interfaz (en +> Rust, un trait object como `Arc` o `Arc`); un +> *adaptador* es una implementación concreta de él. "Depende del puerto, inyecta el +> adaptador" significa que un bean pide el trait y el contenedor provee la +> implementación que esté registrada. Este es el vocabulario de la arquitectura +> hexagonal que usa el resto del libro. + +Fíjate en que la factory `ledger` ensancha `Arc` a `Arc` antes de entregárselo a `Ledger::new`. El `Ledger` almacena el +*puerto*, no el almacén concreto — de modo que cambiar `MemoryEventStore` por un +almacén respaldado por Postgres es un cambio de una línea en esta factory, y el +ledger, los handlers y el controlador ni se enteran. + +Tres ideas sostienen todo el diseño. Léelas despacio; todo lo posterior es +consecuencia: + +- **El framework hace el registro.** Nunca llamas a `register_arc` ni a + `Container::bind`. `container.scan()` descubre el contenedor `LumenBeans` *y* cada + método `#[bean]`, y registra los valores producidos indexados por tipo de retorno. +- **Los puertos se resuelven por tipo.** La factory `ledger` toma `broker: Arc` y el contenedor provee el broker del framework — "depende de la interfaz, + inyecta la implementación". +- **Un bean puede depender de un bean.** `ledger(&self, store, broker)` arrastra + otros dos beans por tipo; el contenedor los construye primero y luego llama a la + factory — la misma construcción ordenada por dependencias que haría una raíz + escrita a mano, derivada de los tipos de los parámetros. + +> **Design note.** Un contenedor `#[derive(Configuration)]` con métodos `#[bean]` +> es el análogo de `@Configuration` + `@Bean` de Spring: una factory cuyos métodos +> producen beans indexados por tipo de retorno, resolviendo sus propios argumentos +> desde el contenedor. Lumen declara así todo su grafo de dominio, y el escaneo de +> componentes convierte las declaraciones en el grafo de objetos cableado. + +> **Tip** **Punto de control.** Ahora tienes ambas formas de declarar un bean: un +> derive de estereotipo sobre una struct que posees (`ReadModel`) y una factory +> `#[bean]` para las cosas que construyes (`event_store`, `ledger`). Lumen usa un +> derive cuando puede anotar el tipo y una factory cuando no puede. + +## Paso 5 — Rastrear una resolución de principio a fin + +Junta los Pasos 2 a 4 siguiendo una sola resolución: ¿cómo construye +`FireflyApplication` el `WalletApi`? + +1. El escaneo ya ha registrado cada bean: `LumenBeans` y sus cinco factories, el + `ReadModel`, los beans de servicio `WalletHandlers` y `WalletProjection`, y el + propio `WalletApi` — además del `Bus`, `Broker`, caché, planificador y registros + propios del framework. +2. Para montar el controlador, el contenedor llama a `resolve::()`. Los + tipos de los campos dicen que necesita `Arc`, `Arc` y + `Arc`. +3. `Arc` y `Arc` ya existen (el framework preregistró el bus; la + factory `query_cache` produjo la caché). Se entregan directamente. +4. `Arc` aún no existe, así que el contenedor llama a la factory `ledger`. + Esa factory necesita `Arc` y `Arc`. El contenedor + construye el almacén a partir de la factory `event_store`, provee el broker del + framework y llama a `ledger` — produciendo el `Ledger`. +5. Con los tres colaboradores en mano, el contenedor construye `WalletApi` y lo + guarda en caché como un singleton. + +Lo que acaba de ocurrir: el contenedor construyó el grafo **empezando por las +hojas** — almacén y broker antes que el ledger, ledger antes que el controlador — +puramente a partir de los tipos de dependencia. Ese ordenamiento es el trabajo que +una raíz de composición solía hacer a mano. Aquí está *derivado*, y se recalcula +correctamente en el momento en que añades o quitas una dependencia. + +La misma recursión cablea el resto de Lumen. El bean de handler CQRS inyecta el +ledger y el modelo de lectura del mismo modo (el `WalletHandlers` real de +`src/commands.rs`): + +```rust,ignore +/// The CQRS handler bean — Spring's `@Component` command/query handler. Its +/// collaborators are `#[autowired]` from the DI container. +#[derive(Service)] +struct WalletHandlers { + #[autowired] + ledger: Arc, + #[autowired] + read_model: Arc, +} + +#[handlers] +impl WalletHandlers { + #[command_handler] + async fn deposit(&self, cmd: Deposit) -> Result { + self.ledger + .deposit(&cmd.wallet_id, Money::cents(cmd.amount)) + .await + .map_err(to_cqrs) + } + // ... open_wallet, withdraw, get_wallet ... +} +``` + +Aquí se inyectan los mismos singletons `Arc` y `Arc` que también +mantienen el controlador y la proyección — una única instancia de cada uno, +compartida por todo bean que la pida. (La maquinaria de `#[handlers]` / +`#[command_handler]` que pone estos métodos en el bus es el tema de +[CQRS y mensajería](./09-cqrs.md); por ahora, fíjate únicamente en que un handler es +un bean y obtiene sus colaboradores por autowiring.) + +> **Tip** **Punto de control.** Rastréalo en tu cabeza una vez más: `WalletApi` → +> `Ledger` → `MemoryEventStore` + `Broker`. Si sabes nombrar esa cadena, entiendes +> el resolver. + +## Paso 6 — Usar los beans de infraestructura del framework por tipo + +Quizá hayas notado que `WalletApi` inyecta `Arc` y la factory `ledger` inyecta +`Arc`, y sin embargo Lumen nunca *declara* un bus ni un broker. Vienen +del framework. Antes de que se ejecute el escaneo, `FireflyApplication` preregistra +sus propios beans de infraestructura en el contenedor, de modo que cualquiera de tus +beans puede inyectarlos por tipo: + +| Bean (resolver por tipo) | Tipo | +|---------------------------|--------------------------------------------------| +| `Bus` | `Arc` (validación preinstalada) | +| adaptador de caché | `Arc` (Memory por defecto) | +| broker | `Arc` (InMemory por defecto) | +| planificador | `Arc` | +| registro de métricas | `Arc` | +| compuesto de salud | `Arc` | + +Lo que acaba de ocurrir: el contenedor *es* la raíz de composición de Lumen, y el +framework lo siembra primero con estos colaboradores. Por eso una factory `#[bean]` +puede tomar `broker: Arc` y simplemente recibir uno — el broker ya +estaba registrado. Llegas a cualquiera de estos inyectándolo por autowiring en un +bean; ajustas los mandos de *configuración* que tienen debajo (CORS, idempotencia, +cabeceras de seguridad, direcciones de enlace) a través de +`FireflyApplication::configure`: + +```rust,ignore +firefly::FireflyApplication::new("lumen") + .configure(|cfg: &mut CoreConfig| { + // adjust CoreConfig / WebStack knobs here + }) + .run() + .await +``` + +> **Note** **Término clave — autoconfiguración.** La *autoconfiguración* consiste +> en que el framework preregistra beans de infraestructura sensatos (un broker en +> memoria, una caché en memoria, el registro de métricas) para que tu aplicación +> funcione con cero cableado, permitiéndote a la vez sobrescribir cualquiera de +> ellos. Es el mecanismo tras los valores por defecto de Spring Boot que "simplemente +> funcionan", tratado al completo en +> [Inyección de dependencias y autoconfiguración](./04a-dependency-injection.md). + +> **Tip** **Punto de control.** Ningún bean de `samples/lumen` construye un `Bus`, +> un `Broker` ni una caché — los inyectan por autowiring. Haz un grep de +> `samples/lumen/src` buscando `Arc` y `Arc` y confirma que cada +> uso es un consumidor, nunca un productor. + +## Paso 7 — Vincular un trait con su implementación + +Hasta ahora cada dependencia inyectada ha sido un tipo concreto o un puerto del +framework. Cuando *tú* posees tanto un trait (un puerto) como su implementación (un +adaptador), los vinculas en el derive con `provides` y luego resuelves el trait — +"depende del puerto, obtén el adaptador": + +```rust,ignore +trait Clock: Send + Sync { fn now(&self) -> u64; } + +#[derive(Component, Default)] +#[firefly(provides = "dyn Clock", primary)] +struct SystemClock; +impl Clock for SystemClock { fn now(&self) -> u64 { 42 } } + +// elsewhere: c.resolve::() yields the SystemClock instance. +``` + +Lo que acaba de ocurrir, bloque a bloque: + +- `#[derive(Component, Default)]` registra `SystemClock` como un bean gestionado, + como de costumbre. +- `#[firefly(provides = "dyn Clock")]` *adicionalmente* vincula el trait object + `dyn Clock` con esta implementación. Ahora un bean puede inyectar `Arc` + y el contenedor le entrega el `SystemClock`. +- `primary` lo marca como el predeterminado cuando varios beans satisfacen el mismo + trait. + +> **Note** **Término clave — `primary` y `order`.** Cuando varios beans satisfacen +> un trait, `#[firefly(... primary)]` elige el que devuelve un `resolve:: Trait>()` simple (el `@Primary` de Spring), y `#[firefly(order = N)]` fija la +> posición que toma un bean cuando se recopilan *todos* — mediante `resolve_all:: Trait>()` o mediante un campo `Vec>` inyectado por autowiring (el `@Order` +> de Spring). + +`provides` en el derive es la forma **amigable con el escaneo** de vincular un +trait. Cuando en cambio ensamblas un contenedor a mano (en un test acotado, +pongamos), el movimiento equivalente es una llamada explícita a `Container::bind::()`; ambos registran el mismo mapeo de trait a adaptador. Para el +caso poco frecuente en que necesitas una instancia con nombre *específica* en lugar +de cualquiera que satisfaga, el contenedor también admite resolución por +cualificador-por-nombre. Los tres — `bind`, beans con nombre y toda la superficie de +desambiguación — se tratan en +[Inyección de dependencias y autoconfiguración](./04a-dependency-injection.md). + +Lumen mismo usa `provides` para su endpoint de streaming protegido por feature, que +registra como un puerto `RouteContributor` que el framework descubre y fusiona: + +```rust,ignore +#[cfg(feature = "streaming")] +#[derive(Service)] +#[firefly(provides = "dyn firefly::web::RouteContributor")] +struct StreamingRoutes { + #[autowired] + api: Arc, +} +``` + +> **Tip** **Punto de control.** Ahora puedes vincular un puerto con un adaptador +> sin una raíz de composición: `provides` en el derive y luego `resolve:: Trait>()`. Añade una segunda implementación sin `primary` y resolver el trait pasa +> a ser un error de *bean no único* — el contenedor negándose a adivinar. + +## Paso 8 — Condicionar beans por condición y por perfil + +Una misma base de código tiene que ejecutarse con adaptadores en memoria baratos en +desarrollo e infraestructura real en producción. El mecanismo es el **registro +condicional**: un bean puede declarar las circunstancias bajo las cuales debería +existir siquiera, y el escaneo respeta eso a medida que recopila cada registro. + +```rust,ignore +// Registered only when the property is present and not false. +#[derive(Service, Default)] +#[firefly(condition_on_property = "feature.audit=on")] +struct AuditService; + +// Registered only under the named profile. +#[derive(Service, Default)] +#[firefly(profile = "prod")] +struct PostgresHealthCheck; +``` + +Lo que acaba de ocurrir: `condition_on_property = "feature.audit=on"` registra +`AuditService` solo cuando esa propiedad de configuración está establecida; +`profile = "prod"` registra `PostgresHealthCheck` solo cuando el perfil `prod` está +activo. El escaneo las evalúa a medida que descubre cada bean, de modo que el +contenedor acaba conteniendo *exactamente* los beans que el entorno reclama — sin +ningún `if` en el código de tu servicio. + +> **Note** **Término clave — perfil.** Un *perfil* es una porción de entorno con +> nombre — `dev`, `test`, `prod` — que conmuta qué beans y qué configuración están +> activos. Firefly lee los perfiles activos de la configuración (la variable de +> entorno `FIREFLY_PROFILE` por defecto); los perfiles se introducen en +> [Configuración](./03-configuration.md) y aquí se usan para condicionar beans, +> exactamente como hace el `@Profile` de Spring. + +El mismo condicionamiento funciona en las factories `#[bean]` — `#[bean(profile = +"prod")]` registra una factory solo bajo el perfil `prod` — y es el motor tras cada +nota de "cambia el adaptador para producción" de este libro. Lumen puede quedarse en +memoria con fines didácticos mientras un despliegue de producción cambia a +infraestructura real solo mediante configuración. + +> **Tip** **Punto de control.** Añade `#[firefly(condition_on_property = +> "wallet.enabled=true")]` a un bean `#[derive(Service)]` desechable, ejecuta Lumen y +> observa cómo *no* aparece en el informe de arranque hasta que estableces la +> propiedad. La condición decidió la existencia del bean antes de la construcción. + +## Paso 9 — Engancharse al ciclo de vida de un bean + +Los beans de infraestructura reales necesitan *actuar* una vez cableados — abrir un +pool, suscribirse a un topic — y deshacerlo al apagar. Nombra los métodos en el +derive: + +```rust,ignore +#[derive(Service, Default)] +#[firefly(post_construct = "started", pre_destroy = "stopped")] +struct ProjectionSubscriber { /* ... */ } + +impl ProjectionSubscriber { + fn started(&mut self) { /* subscribe the read-model projection */ } + fn stopped(&self) { /* drain and unsubscribe */ } +} +``` + +Lo que acaba de ocurrir: `post_construct = "started"` nombra un método que se +ejecuta *después* de que el bean se construya y sus campos `#[autowired]` se +inyecten; `pre_destroy = "stopped"` nombra un método que se ejecuta en +`container.destroy()`. La destrucción ocurre en **orden inverso al de +construcción**, de modo que un suscriptor iniciado después del almacén se +desmantela antes que él — un desmontaje limpio sin una secuencia de apagado escrita +a mano. + +> **Note** **Término clave — `post_construct` / `pre_destroy`.** Estos son los +> análogos en Rust de `@PostConstruct` y `@PreDestroy` de Spring (y de las llamadas +> de ciclo de vida de JSR-250): un método que se ejecuta una vez tras completarse el +> cableado y un método que se ejecuta al apagar, con la garantía de orden inverso. + +> **Tip** **Punto de control.** Los hooks de ciclo de vida son la forma en que un +> bean hace *él mismo* su cableado de una sola vez, en lugar de que una raíz de +> composición lo haga tras la construcción. El contenedor posee el ordenamiento. + +## Paso 10 — Leer el inventario de beans en el arranque + +Has declarado beans, los has inyectado por autowiring, has vinculado un puerto, has +condicionado algunos y le has dado hooks de ciclo de vida a uno. El framework +imprime exactamente lo que cableó. Ejecuta Lumen y lee el informe de arranque: + +```bash +cargo run +``` + +El bloque `:: beans (…) ::` lista cada bean registrado, agrupado por estereotipo: +`LumenBeans` y sus factories, `WalletApi`, los beans `ledger` / `event_store`, el +`[repository] ReadModel`, el `[service] WalletHandlers` y `WalletProjection`. Estos +son los mismos datos que renderiza la vista `/beans` del panel de administración, en +el puerto de gestión en `http://localhost:8081/admin/`. + +> **Note** **Término clave — escaneo de componentes (en tiempo de enlazado).** Como +> Rust no tiene reflexión en tiempo de ejecución, cada derive de estereotipo emite un +> registro de `inventory` en tiempo de compilación, y `firefly::scan(&container)` +> (equivalentemente `container.scan()`) recopila cada uno de los que se enlazaron en +> el binario y los registra — respetando condiciones y perfiles a medida que avanza. +> `FireflyApplication` ejecuta este escaneo por ti en el arranque. + +Lo que acaba de ocurrir: el informe *es* el inventario que produjo el escaneo. Nada +es reflexivo ni está oculto — "qué está cableado" se imprime línea a línea. Una +dependencia que faltase habría abortado el arranque con un error de resolución con +nombre antes de que este informe llegara a imprimirse. + +> **Warning** El descubrimiento en tiempo de enlazado tiene una peculiaridad +> específica de Rust. Un bean solo es descubrible cuando los registros de su crate +> están **enlazados en el binario**. Para una aplicación de un solo crate como Lumen +> eso es automático. Pero en un servicio multicrate, un crate de *capa* del que el +> binario solo depende transitivamente — un crate `-models` o `-core` cuyos beans +> nunca se nombran directamente — puede ser **eliminado como código muerto** por el +> enlazador, beans incluidos. Fuerza el enlazado de esos con +> [`firefly::link!`](./22-layered-microservices.md) en la raíz del crate del binario +> (`firefly::link!(my_core, my_models);`) y protege el resultado con +> `firefly::assert_discovered(...)`. El Lumen de un solo crate nunca necesita esto; +> la nota está aquí para que el vacío del informe ante un crate eliminado nunca sea +> un misterio. + +> **Tip** **Punto de control.** El bloque `:: beans ::` nombra cada bean que +> declaraste en este capítulo, sin ninguna llamada de registro en parte alguna de tu +> código. Esa es la recompensa: tú escribiste declaraciones, el framework escribió el +> grafo. + +## La única vía de escape — `register_all!` + +El escaneo de componentes es el camino que toman Lumen y el framework, y es el +predeterminado para todo en `samples/lumen`. Hay exactamente un mecanismo de +respaldo explícito, para los dos casos que el escaneo no puede alcanzar: + +```rust,ignore +let c = Container::new(); +firefly::register_all!(&c, [ReadModel, Ledger, WalletApi]); +let api = c.resolve::().expect("controller resolves"); +``` + +Recurre a `register_all!` para los **beans genéricos** — la monomorfización de un +tipo genérico se elige en el lugar de uso, así que no se puede inventariar — o +simplemente para mantener el cableado local a un único test acotado. Ambos registran +los mismos beans contra el mismo contenedor; el escaneo se limita a construirte la +lista a partir del inventario de tiempo de enlazado. El punto de entrada de más bajo +nivel por debajo del escaneo es el `ApplicationContext`, que envuelve el contenedor +con la secuencia de arranque completa y resulta práctico en un test: + +```rust,ignore +use firefly::prelude::*; + +let ctx = ApplicationContext::builder() + .profiles(["test"]) + .property("feature.audit", "on") + .build(); +let c = ctx.container(); + +// Every stereotype-derived bean in the crate graph is discovered and wired. +let api = c.resolve::().expect("scan registered the controller"); +``` + +La taxonomía de errores es precisa: un bean que falta, un bean no único sin +`primary` y una dependencia circular detectada afloran cada uno como un error +distinto y con nombre en tiempo de resolución — los datos que también reporta la +vista `/beans` de administración. +[Inyección de dependencias y autoconfiguración](./04a-dependency-injection.md) +trata en profundidad toda la superficie del contenedor — scopes, beans con nombre, +`bind`, `register_all!` y el modelo de errores. + +## Resumen — qué cambió en Lumen + +| Antes | Después de este capítulo | +|--------|--------------------| +| el cableado imaginado como una raíz de composición escrita a mano | entendido como **beans declarados** que el escaneo de componentes descubre y cablea — sin raíz que mantener | +| un derive de estereotipo parecía decorativo | visto como **el registro en sí**: un derive crea un bean gestionado y registra su capa | +| `#[autowired]` parecía una anotación de un solo valor | conocido como cuatro modos de inyección — `Arc`, `Vec>`, `Option>`, `Provider` | +| los puertos parecían abstractos | vistos en concreto como `Arc` / `Arc` — una factory `#[bean]` para cambiar un adaptador | +| no estaba claro cómo `FireflyApplication` resuelve el grafo | nombrado: preregistrar beans de infra → escanear → resolver controladores, construyendo colaboradores empezando por las hojas en orden de dependencias | + +También sabes ahora: + +- Por qué un servicio Firefly no tiene `build_app` — las declaraciones más un + escaneo de componentes reemplazan al grafo escrito a mano, y el framework *es* la + raíz de composición. +- Que las condiciones y los perfiles condicionan la existencia de un bean, de modo + que una misma base de código se ejecuta en memoria en dev y sobre infraestructura + real en prod sin un `if`. +- Que `post_construct` / `pre_destroy` le dan a un bean su propio cableado y + desmontaje de una sola vez, ordenados por el contenedor. +- Que `register_all!` y `ApplicationContext::builder()` son los respaldos explícitos + para genéricos y tests acotados — todo lo demás se escanea. + +## Ejercicios + +1. **Lee el inventario de beans.** Ejecuta Lumen y lee el bloque `:: beans (…) ::` + del informe de arranque. Encuentra `LumenBeans`, `WalletApi`, las factories + `ledger` / `event_store` y el bean de acceso a datos `[repository] ReadModel`, + agrupados por estereotipo — los mismos datos que renderiza la vista `/beans` del + panel de administración en `http://localhost:8081/admin/`. +2. **Añade un bean y obsérvalo aparecer.** Añade un pequeño `#[derive(Service)]` a + `web.rs`, ejecuta Lumen y confirma que aparece en el informe — no escribiste + ninguna llamada de registro. Luego añade `#[firefly(condition_on_property = + "wallet.enabled=true")]` y obsérvalo desaparecer hasta que establezcas la + propiedad. +3. **Vincula un puerto automáticamente.** Define un trait `Clock`, dale a + `SystemClock` `#[firefly(provides = "dyn Clock", primary)]` y resuelve `dyn + Clock`. Añade una segunda implementación *sin* `primary`, observa el error de bean + no único y luego mueve `primary` al que quieras como predeterminado. +4. **Cambia un almacén desde una sola factory.** Cambia el `#[bean]` `event_store` de + `LumenBeans` para que devuelva un almacén distinto, y explica en una frase por qué + la factory `ledger`, los handlers y el controlador no necesitan ningún cambio — + dependen del *puerto* `EventStore`, no del almacén concreto. +5. **Rastrea una resolución.** Elige `WalletHandlers` y anota, en orden, cada bean + que el contenedor debe construir antes de poder construir ese handler. Comprueba + tu respuesta contra los campos `#[autowired]` de `src/commands.rs` y la factory + `ledger` de `src/web.rs`. + +## Adónde ir después + +- Profundiza en el contenedor en **[Inyección de dependencias y + autoconfiguración](./04a-dependency-injection.md)** — scopes, beans con nombre y + cualificadores, `Container::bind`, toda la superficie condicional y el modelo de + autoconfiguración que este capítulo solo esbozó. +- Mira exactamente qué hace `run()`, etapa por etapa, en **[Arranque con + FireflyApplication](./04b-bootstrap.md)** — la secuencia de arranque que impulsa el + escaneo que acabas de aprender. +- Luego conoce las primitivas reactivas sobre las que se construye cada capítulo + posterior en **[El modelo reactivo — Mono y Flux](./05-reactive-model.md)**, y dale + a Lumen sus primeros endpoints en **[Tu primera API HTTP](./06-first-http-api.md)**. diff --git a/docs/book/src-es/04a-dependency-injection.md b/docs/book/src-es/04a-dependency-injection.md new file mode 100644 index 00000000..7faa8f5e --- /dev/null +++ b/docs/book/src-es/04a-dependency-injection.md @@ -0,0 +1,1065 @@ +# Inyección de dependencias y autoconfiguración + +El [capítulo anterior](./04-dependency-wiring.md) recorrió el cableado de Lumen a +vista de pájaro: **declara beans** y el escaneo de componentes de +`FireflyApplication` los descubre y los conecta en el arranque. Este capítulo es +el recorrido guiado, desde los primeros principios, de ese contenedor — el motor +que convierte un crate lleno de declaraciones `#[derive(...)]` y `#[bean]` en un +grafo de objetos cableado. Construiremos toda la superficie de DI una idea a la +vez, siempre contra los propios colaboradores de Lumen (`ReadModel`, `Ledger`, +`WalletApi`), de modo que al final nada del contenedor sea una caja negra. + +No necesitas haber terminado el capítulo anterior para seguir este — cada +concepto se reintroduce aquí en contexto. Pero deberías tener un crate de Lumen +que compile y se ejecute (desde [Quickstart](./02-quickstart.md)), porque los +ejemplos reflejan código que ya se distribuye en +[`samples/lumen`](https://github.com/fireflyframework/fireflyframework-rust/tree/main/samples/lumen). + +Al terminar este capítulo, serás capaz de: + +- Explicar la **inversión de control** a la manera de Rust, y por qué el + descubrimiento de Firefly ocurre en *tiempo de enlazado* en lugar de mediante + reflexión. +- Declarar un bean con un **derive de estereotipo** y cablear sus dependencias + con inyección por constructor mediante `#[autowired]`. +- Desambiguar adaptadores en competencia con `#[firefly(primary)]`, nombres y + cualificadores, y leer las cuatro reglas de resolución en orden de prioridad. +- Producir beans que no posees con factorías `#[derive(Configuration)]` + + `#[bean]`, incluidas factorías **async** que realizan E/S en el arranque. +- Limitar beans mediante **perfiles** y la familia `condition_on_*`, y dejar que + una **autoconfiguración** se retire en cuanto declaras tu propio bean. +- Enmarcar un bean con hooks de ciclo de vida, elegir su **ámbito** e + introspeccionar todo el grafo a través de la vista `/beans`. + +## Conceptos que conocerás + +Antes de la primera línea de código, aquí están los términos que sostienen todo. +Cada uno se reintroduce en contexto la primera vez que se usa; esta es la versión +corta para que el mapa esté en tu cabeza desde el principio. + +> **Note** **Término clave — bean.** Un *bean* es cualquier valor que el +> framework construye, cablea, posee y entrega a quien lo necesita. Tú declaras +> los beans; el contenedor los descubre en el arranque y los conecta. Esto es +> exactamente la noción de Spring de un bean gestionado por el contexto de +> aplicación. + +> **Note** **Término clave — contenedor (contenedor de DI).** El *contenedor* es +> el registro que contiene cada bean, resuelve las dependencias de un bean y +> construye el grafo de objetos en orden de dependencias. El contenedor de +> Firefly es el tipo `firefly::container::Container`, normalmente operado a través +> de un `ApplicationContext`. El análogo en Spring es el `ApplicationContext` / +> `BeanFactory`. + +> **Note** **Término clave — inversión de control (IoC).** La *inversión de +> control* significa que el framework llama a tus constructores en el orden +> correcto, en lugar de que tu código los llame a mano. Tú declaras *qué* +> necesita un bean; el contenedor decide *cuándo* y *en qué orden* construirlo. La +> inyección de dependencias es el mecanismo concreto que materializa la IoC. + +> **Note** **Término clave — puerto y adaptador.** Un *puerto* es una capacidad +> abstracta de la que tu código depende (un trait, p. ej. `EventStore`); un +> *adaptador* es una implementación concreta de él (p. ej. `MemoryEventStore`). +> Depender del puerto y seleccionar el adaptador en tiempo de cableado es lo que +> permite que una misma base de código Lumen se ejecute sobre infraestructura en +> memoria en los tests e infraestructura real en producción. Este es el +> vocabulario de la arquitectura hexagonal. + +> **Design note.** El contenedor de Firefly ofrece un modelo de DI declarativo — +> derives de estereotipo, `#[autowired]`, factorías `#[bean]`, `primary`, +> perfiles, la familia `condition_on_*` y hooks de ciclo de vida. Como Rust no +> tiene reflexión en tiempo de ejecución, el descubrimiento ocurre en **tiempo de +> enlazado** (a través del crate `inventory`) y el autowiring lo genera una macro +> derive en lugar de inferirse en tiempo de ejecución; un bean es descubrible +> exactamente cuando su crate está enlazado. El modelo es: declara la intención, +> deja que el contenedor proporcione las instancias. La superficie resulta +> familiar si has usado un framework con todo incluido, pero el mecanismo en +> tiempo de enlazado es propio de Firefly. + +## Paso 1 — Ve el problema que resuelve la inversión de control + +Empieza escribiendo el cableado *a mano*, como lo harías sin un contenedor, para +que el valor de invertirlo sea concreto. El lado de lectura de Lumen mantiene +vistas de carteras en un `ReadModel`; su lado de escritura es un `Ledger` sobre un +event store y un broker; su superficie HTTP es un controlador `WalletApi` que +necesita tanto el bus CQRS como el ledger. Cableado a mano, eso es un ensamblaje +pequeño pero real: + +```rust,ignore +let store = Arc::new(MemoryEventStore::new()); +let broker = Arc::new(InMemoryBroker::new()); +let read_model = Arc::new(ReadModel::default()); +let ledger = Arc::new(Ledger::new(Arc::clone(&store), Arc::clone(&broker))); +// ... and then hand the collaborators to the controller's state, in order. +``` + +El problema no son las cuatro líneas — es que **tú** eres responsable del +*orden*. `Ledger` debe existir antes que `WalletApi`; `store` y `broker` deben +existir antes que `Ledger`. Añade un quinto colaborador y vuelves a re-tejer la +función. El contenedor invierte esto: cada bean *declara* sus dependencias, y el +contenedor llama a los constructores en orden de dependencias por ti. + +> **Note** **Término clave — raíz de composición.** La *raíz de composición* es el +> único lugar de un programa donde se ensambla todo el grafo de objetos. El bloque +> escrito a mano de arriba *es* una raíz de composición. En Firefly el framework +> es la raíz de composición: escanea tus beans y los cablea, de modo que nunca +> deletreas el grafo en una función. + +Qué acaba de pasar: viste el cableado exacto que el contenedor asumirá. La +recompensa no son solo menos líneas — es un registro central que el panel de +administración introspecciona (la página `/beans`), un informe de arranque que +registra el grafo línea a línea, y un *error de resolución en el arranque* en +lugar de un pánico en lo más profundo de la primera petición cuando falta algo. + +> **Tip** **Punto de control.** Puedes nombrar los tres colaboradores nucleares de +> Lumen — `ReadModel`, `Ledger`, `WalletApi` — y decir cuál depende de cuál. Ten +> ese grafo en mente; cada paso de abajo cablea una arista de él. + +
+ + +Container · scan() wires beans in dependency order +WalletApi#[derive(Controller)] +Ledger#[derive(Service)] +ReadModel#[derive(Component)] +EventStore#[derive(Repository)] +Brokerport — #[autowired] +autowired +autowired + + + +
El contenedor escanea los beans de estereotipo y los cablea en orden de dependencias. WalletApi autocablea el Ledger y el ReadModel; el Ledger autocablea los puertos EventStore y Broker — sin raíz de composición a mano.
+
+ +## Paso 2 — Declara un bean con un derive de estereotipo + +Un **bean** es cualquier valor que el contenedor construye, cablea y posee. +Conviertes un tipo en bean con un **derive de estereotipo** — una anotación ligera +que genera un método `firefly_register(&Container)` *y* envía un thunk de escaneo +en tiempo de enlazado para que el escaneo de componentes pueda encontrarlo. + +> **Note** **Término clave — estereotipo.** Un *estereotipo* es un derive que a la +> vez convierte un tipo en bean y documenta su rol arquitectónico. Se distribuyen +> cinco, todos funcionalmente equivalentes — difieren solo en la intención que +> registran y la etiqueta que llevan a la vista `/beans`. Son los estereotipos +> `@Component` / `@Service` / `@Repository` / `@Configuration` / `@Controller` de +> Spring. + +| Derive | Documenta | +|--------|-----------| +| `#[derive(Component)]` | un bean gestionado genérico | +| `#[derive(Service)]` | lógica de negocio / caso de uso | +| `#[derive(Repository)]` | acceso a datos / un puerto | +| `#[derive(Configuration)]` | un contenedor de factorías `#[bean]` | +| `#[derive(Controller)]` | un bean de controlador web | + +El read model de Lumen es un bean de acceso a datos, así que lleva +`#[derive(Repository)]`. Este es código real de `samples/lumen/src/ledger.rs`: + +```rust,ignore +use firefly::prelude::*; +use std::collections::HashMap; +use std::sync::Mutex; + +#[derive(Debug, Default, Repository)] +pub struct ReadModel { + rows: Mutex>, +} +// generated: ReadModel::firefly_register(&container) + a component-scan thunk +``` + +Qué acaba de pasar: derivar `Repository` registró `ReadModel` como bean singleton +y envió un thunk de escaneo para que `container.scan()` lo encuentre en el +arranque. Elegir `Repository` en lugar de `Component` no cuesta nada técnicamente, +pero le dice a todo lector — y a la página `/beans` — exactamente para qué sirve +`ReadModel`. + +> **Note** **Término clave — singleton.** Un bean *singleton* tiene una sola +> instancia, cacheada tras la primera vez que se resuelve, y compartida por todo +> lo que depende de ella. Es el ámbito por defecto; conocerás los demás en el Paso +> 9. El ámbito de bean por defecto de Spring es el mismo. + +Por qué importa: la proyección que llena el read model y el query handler que lo +lee, ambos autocablean `Arc` — y como es un singleton, comparten *el +mismo* mapa. Una lectura tras escritura ve la escritura. + +> **Tip** **Punto de control.** Un struct con un derive de estereotipo es un bean. +> Si ejecutaste Lumen y abriste `http://localhost:8081/admin/`, `ReadModel` +> aparece en la página `/beans` etiquetado como `repository`. + +## Paso 3 — Inyecta dependencias con `#[autowired]` + +Las dependencias de un bean son sus campos `#[autowired]`. El contenedor resuelve +cada uno por tipo y lo asigna antes de que el bean se construya. El **tipo del +campo** controla la *forma* de la inyección: + +> **Note** **Término clave — autowiring (inyección por constructor).** El +> *autowiring* significa que el contenedor llena un campo resolviendo su tipo +> desde el registro, en lugar de que tú pases el valor. Firefly lo hace en tiempo +> de construcción, de modo que una dependencia requerida que falta es un error +> ruidoso en el arranque, no un `None` tres marcos dentro de una petición. Esta es +> la inyección por constructor de `@Autowired` de Spring. + +| Tipo del campo | Resuelve mediante | +|------------|--------------| +| `Arc` | `resolve::()` (requerido) | +| `Option>` | `resolve::().ok()` (opcional) | +| `Vec>` | `resolve_all::()` (todas las implementaciones) | +| `Provider` | un handle diferido (`container.provider::()`) | + +La **proyección** del read model de Lumen es un bean `#[derive(Service)]` que +autocablea el `Ledger` (cuyo event store reproduce) y el `ReadModel` que alimenta. +Este es código real de Lumen: + +```rust,ignore +use firefly::prelude::*; +use std::sync::Arc; + +#[derive(Service)] +struct WalletProjection { + #[autowired] + ledger: Arc, + #[autowired] + read_model: Arc, +} +``` + +Cuando el contenedor construye `WalletProjection`, resuelve primero `Arc` y +`Arc` — construyendo cada uno recursivamente si aún no lo ha hecho — y +luego construye la proyección con ambos campos asignados. El orden se *deriva de +los tipos de los campos*, nunca se escribe. + +> **Note** **Término clave — `Arc`.** `Arc` es el puntero compartido de Rust +> con conteo de referencias atómico. Los beans se comparten (un singleton tiene +> muchos poseedores), así que el contenedor siempre entrega un `Arc`. Clonar un +> `Arc` es barato — incrementa un contador, no los datos — por lo que los structs +> de bean suelen ser `#[derive(Clone)]` con campos `Arc`. + +Qué acaba de pasar: declaraste *qué* necesita la proyección, y el contenedor lo +proporcionará en orden de dependencias. Un campo sin atributo se llena con +`Default::default()`; un campo `#[firefly(value = "${...}")]` se enlaza desde la +configuración (Paso 8). + +> **Note** Una dependencia **requerida** que falta (`Arc` sin proveedor) es un +> ruidoso `ContainerError::NoSuchBean` en tiempo de resolución, con sugerencias +> aproximadas de tipo «¿querías decir…?», en lugar de un fallo silencioso. Hazla +> opcional con `Option>` y el campo pasa a ser `None` en lugar de un error. + +> **Note** **La regla `Default`.** La factoría generada construye el struct como un +> literal, llenando los campos `#[autowired]` / `#[firefly(value = ...)]` desde el +> contenedor y **todos los demás campos** con `Default::default()`. Por tanto, un +> struct de estereotipo necesita `#[derive(Default)]` si — y solo si — tiene al +> menos un campo que no sea ni `#[autowired]` ni `#[firefly(value = ...)]` (como el +> campo `rows` de `ReadModel`). Un struct todo-autowired, o un contenedor sin +> campos como un `#[derive(Configuration)]`, compila sin él. + +> **Note** Autocablear un campo `Provider` requiere que el contenedor tenga un +> handle a *sí mismo*, cosa que solo un contenedor compartido tiene. Construye uno +> con `Container::shared()` (o llama a `install_shared_handle()` sobre un +> `Arc`); un `ApplicationContext` ya lo hace por ti. Resolver un campo +> `Provider` contra un `Container::new()` pelado provoca un pánico con un +> mensaje que te indica usar `shared()`. + +> **Tip** **Punto de control.** Puedes predecir el orden de construcción de +> `WalletProjection`: el contenedor resuelve primero `Ledger` y `ReadModel`, luego +> construye la proyección. En ningún sitio escribiste ese orden — los tipos de los +> campos lo codifican. + +## Paso 4 — Depende de un puerto, obtén el adaptador + +Lumen depende de *puertos* — `EventStore`, `Broker` — y elige un adaptador en +tiempo de cableado. El contenedor expresa «depende del puerto, obtén el +adaptador» con una **vinculación** (binding) a un objeto-trait: registra el tipo +concreto, vincula el trait a él, y luego resuelve el trait. + +```rust,ignore +use firefly::prelude::*; + +let c = Container::new(); +firefly::register_all!(&c, [MemoryEventStore]); +c.bind::(|a| a); +let store: Arc = c.resolve().unwrap(); +``` + +Qué acaba de pasar: `register_all!` registró el `MemoryEventStore` concreto; `bind` +registró «el trait `EventStore` lo satisface `MemoryEventStore`»; y `resolve` +devuelve entonces el adaptador a través del tipo del puerto. Los consumidores +autocablean `Arc` y nunca nombran el adaptador concreto. + +> **Note** `bind::(|a| a)` provoca un pánico si `T` no está registrado +> primero — bind es una vista sobre un registro *existente*, así que registra el +> tipo concreto antes de vincularle un trait. + +Cuando **varios** adaptadores respaldan un puerto — la clásica división en memoria +frente a Postgres — exactamente uno debe marcarse como **primary**, o la +resolución falla ruidosamente: + +> **Note** **Término clave — bean primary.** Cuando más de un bean satisface un +> tipo, el marcado con `#[firefly(primary)]` es la elección por defecto. Sin un +> primary y con más de un candidato, la resolución falla en lugar de adivinar. +> Esto es `@Primary` de Spring. + +```rust,ignore +#[derive(Repository)] +#[firefly(primary)] // the default adapter +pub struct MemoryEventStore { /* … */ } + +#[derive(Repository)] +pub struct PostgresEventStore { /* … */ } // activated by profile/condition +``` + +Las reglas de resolución, en estricto orden de prioridad: + +1. **Registro directo** — un tipo `T` registrado directamente se resuelve a él. +2. **Vinculación única** — una implementación vinculada a un trait se resuelve a + ella. +3. **`#[firefly(primary)]`** — entre varias vinculaciones, gana la primary. +4. **Error** — `NoSuchBean` cuando nada coincide; `NoUniqueBean` (nombrando cada + candidato en competencia) cuando varios coinciden sin primary. + +Qué acaba de pasar: aprendiste los únicos cuatro resultados que `resolve` puede +producir. Mover `primary` de un adaptador al otro es el *único* cambio necesario +para intercambiar el almacén de respaldo de Lumen — nada de `Ledger` cambia, +porque `Ledger` depende del puerto. + +> **Tip** **Punto de control.** Dados dos adaptadores vinculados a un puerto sin +> primary, puedes predecir el error: `NoUniqueBean`, nombrando ambos candidatos. +> Añade `#[firefly(primary)]` a uno y la resolución tiene éxito — sin cambio en el +> consumidor. + +### `#[firefly(order)]` y beans con nombre + +`#[firefly(order = N)]` controla la secuencia de inicialización y el orden en que +`resolve_all::()` devuelve las implementaciones (el menor se ejecuta primero); +las constantes `HIGHEST_PRECEDENCE` / `LOWEST_PRECEDENCE` marcan los extremos. +Cuando dos beans comparten un tipo — digamos un almacén primary y una réplica de +lectura — dale a uno un **nombre** y selecciónalo con un **cualificador**: + +> **Note** **Término clave — cualificador.** Un *cualificador* nombra cuál de +> varios beans del mismo tipo quieres en un punto de inyección. El bean productor +> lleva `#[firefly(name = "…")]`; el campo consumidor lleva +> `#[firefly(qualifier = "…")]`. Esto es `@Qualifier` de Spring. + +```rust,ignore +#[derive(Repository)] +#[firefly(name = "replica")] +pub struct ReplicaStore { /* … */ } + +#[derive(Service)] +pub struct ReportService { + #[firefly(qualifier = "replica")] store: Arc, +} +``` + +Qué acaba de pasar: el cualificador desambigua por nombre donde el tipo por sí +solo es ambiguo. Un cualificador mal escrito que apunta al tipo equivocado es un +claro `NoSuchBean`, no una inyección silenciosamente errónea. + +## Paso 5 — Produce beans que no posees con factorías `#[bean]` + +No toda dependencia es un tipo que puedas anotar — un cliente de terceros necesita +argumentos de constructor, una interfaz necesita un adaptador construido a mano. +Para estos, un contenedor `#[derive(Configuration)]` expone **métodos factoría** +`#[bean]`. Cada método se indexa por su **tipo de retorno**; sus argumentos +`Arc` se resuelven desde el contenedor, de modo que una factoría puede +depender de otros beans. + +> **Note** **Término clave — factoría de beans.** Una *factoría de beans* es un +> método cuyo valor de retorno se convierte en un bean, indexado por el tipo de +> retorno. La usas cuando un bean no puede simplemente derivar un estereotipo — +> necesita lógica de construcción o envuelve un tipo externo. Esto es el método +> `@Bean` sobre una clase `@Configuration` de Spring. + +Así es exactamente como Lumen produce sus beans de infraestructura nucleares — +código real de `samples/lumen/src/web.rs`: + +```rust,ignore +use firefly::prelude::*; +use std::sync::Arc; +use firefly::cqrs::QueryCache; +use firefly::eda::Broker; +use firefly::eventsourcing::{EventStore, MemoryEventStore}; + +#[derive(Configuration)] +struct LumenBeans; + +#[bean] +impl LumenBeans { + /// The in-memory event store. + #[bean] + fn event_store(&self) -> MemoryEventStore { + MemoryEventStore::new() + } + + /// The read-side query cache honouring `GetWallet`'s 30s TTL. + #[bean] + fn query_cache(&self) -> QueryCache { + QueryCache::new() + } + + /// The ledger application service — autowires the event store and the + /// framework-provided `Broker` port. + #[bean] + fn ledger(&self, store: Arc, broker: Arc) -> Ledger { + let store: Arc = store; + Ledger::new(store, broker) + } +} +``` + +Qué acaba de pasar: `LumenBeans` es un contenedor `@Configuration` sin campos. Sus +tres métodos producen tres beans, cada uno indexado por su tipo de retorno +(`MemoryEventStore`, `QueryCache`, `Ledger`). Los argumentos del método `ledger` +(`Arc`, `Arc`) se resuelven desde el contenedor — de +modo que una factoría puede cablearse a sí misma desde otros beans. **No** llamas a +nada para cablear estos: `Container::scan()` descubre el contenedor *y* sus +métodos `#[bean]` (cada uno envía su propio thunk de escaneo) y los registra +automáticamente. + +> **Note** Por esto el `Ledger` de Lumen es un struct simple, no un bean +> `#[derive(Service)]`: lo *produce* esta factoría en lugar de derivarse. Un +> `#[autowired] ledger: Arc` aguas abajo (en `WalletApi`, en la +> proyección) lo encuentra porque la factoría registró el valor bajo el tipo +> `Ledger`. Intercambiar `MemoryEventStore` por un adaptador de Postgres es una +> línea en este contenedor; el resto de Lumen queda intacto. + +Un método `#[bean]` devuelve un **tipo concreto (con tamaño)** — esa es la clave +del bean. Para exponerlo tras un puerto, añade +`#[firefly(provides = "dyn Broker")]` al contenedor o llama a `Container::bind` +tras el registro. Las opciones por método reflejan las de nivel de struct: +`#[bean(name = "...", scope = "...", primary, order = N, profile = "...", +condition_on_property = "k=v", condition_on_class = "…", condition_on_bean = "T", +condition_on_missing_bean = "T", condition_on_single_candidate = "T")]`. + +> **Tip** **Punto de control.** Puedes explicar por qué `LumenBeans` no llama a +> ningún `register_*` ni a `bind`: `container.scan()` descubre cada `#[bean]` y +> registra su valor de retorno bajo el tipo de retorno. El framework hace el +> registro. + +### Beans async (`async fn #[bean]`) + +Un bean que realiza E/S para construirse a sí mismo — abrir un pool de base de +datos, conectar con un broker, precalentar una caché — declara su factoría +`async`. El servicio [`lumen-ledger`](./22-layered-microservices.md) hace +exactamente esto; código real de su `WalletPersistenceConfig`: + +```rust,ignore +use firefly::data_sqlx::Db; +use firefly::prelude::*; + +#[derive(Configuration, Default)] +pub struct WalletPersistenceConfig; + +#[firefly::bean] +impl WalletPersistenceConfig { + /// The `Db` datasource bean — an async factory that opens the connection + /// pool and applies the schema with `await`. + #[bean] + async fn data_source(&self) -> Db { + connect_and_migrate().await + } +} +``` + +Qué acaba de pasar: el framework aparca una factoría `async` durante el +`container.scan()` síncrono y la `await`ea durante `Container::init_async_beans()` +— ejecutado por el bootstrap inmediatamente después del escaneo, antes de que se +resuelvan controladores, handlers y singletons eager — y luego instala el +resultado como un singleton listo. Los beans async se secuencian mediante +`#[bean(order = N)]`, de modo que uno puede autocablear otro inicializado antes. + +> **Note** Esto es el «un `@Bean` hace E/S bloqueante en tiempo de refresco del +> contexto» de Spring Boot, salvo que la E/S se `await`ea en lugar de bloquear un +> hilo. Un fallo de la factoría se reporta como un error `BeanCreation` nombrando +> el bean — el «Error creating bean named '…'» de Spring. + +`FireflyApplication` drena los beans async en su propia ruta de bootstrap. Si +construyes un `ApplicationContext` directamente, llama a +**`build_async().await`** en lugar de `build()` — el `build()` síncrono no puede +`await`ear un bean async pendiente y provoca un **pánico** en lugar de dejarlo +silenciosamente sin inicializar: + +```rust,ignore +let ctx = ApplicationContext::builder().build_async().await?; // awaits async beans +``` + +En `lumen-ledger`, el `Db` de esta factoría async es a partir de lo que se +construye el repositorio `#[derive(SqlxRepository)]` — el repositorio abre el pool +y ejecuta la migración con `await` antes de que llegue cualquier petición. + +## Paso 6 — Limita beans por perfil y condición + +Hasta ahora cada bean siempre existe. Las condiciones responden a una pregunta +distinta: «¿debería este bean existir *en absoluto*, dado el entorno?» Este es el +mecanismo que permite que una misma base de código Lumen se ejecute sobre +infraestructura en memoria en los tests e infraestructura real en producción sin +un `if` en el código del servicio. + +> **Note** **Término clave — perfil.** Un *perfil* es una bandera de entorno con +> nombre (`dev`, `test`, `prod`) que limita qué beans están activos. Un bean con +> `#[firefly(profile = "prod")]` existe solo cuando `prod` está activo. Los +> perfiles soportan una gramática booleana — `prod & cloud`, `dev | test`, +> `!staging`, paréntesis. Esto es `@Profile` de Spring. + +> **Note** **Término clave — bean condicional.** Un *bean condicional* existe solo +> cuando se cumple una condición declarada — una propiedad de configuración está +> establecida, una etiqueta de característica está presente, otro bean existe o +> falta. El contenedor las evalúa en tiempo de escaneo. Esto es la familia +> `@ConditionalOn*` de Spring Boot. + +`Container::scan` evalúa las condiciones en **dos pasadas**. + +**Pasada 1** asienta los hechos de configuración/perfil — conocibles antes de que +se construya ningún bean: + +```rust,ignore +#[derive(Repository)] +#[firefly(profile = "prod", condition_on_property = "lumen.store.postgres=true")] +pub struct PostgresEventStore { /* … */ } +``` + +**Pasada 2** evalúa las condiciones dependientes del registro — conocibles solo +después de que la pasada 1 se asiente. Esto habilita el patrón +**por-defecto-con-anulación**: distribuye un respaldo que cede ante cualquier +implementación provista por el usuario: + +```rust,ignore +#[derive(Repository)] +#[firefly(condition_on_property = "lumen.store.postgres=true")] +pub struct PostgresEventStore { /* … */ } // real store when configured + +#[derive(Repository)] +#[firefly(condition_on_missing_bean = "EventStore")] +pub struct MemoryEventStore { /* … */ } // fallback whenever none is wired +``` + +Qué acaba de pasar: con la propiedad sin establecer, `PostgresEventStore` se omite +en la pasada 1, así que en la pasada 2 no hay bean `EventStore` — y el +`condition_on_missing_bean` de `MemoryEventStore` se dispara, registrando el +respaldo. Establece la propiedad y `PostgresEventStore` se registra en la pasada +1, de modo que el respaldo se retira. Ni un `if` a la vista. + +La familia condicional completa: `condition_on_property = "key=value"`, +`condition_on_class = "label"`, `condition_on_bean = "Type"`, +`condition_on_missing_bean = "Type"`, `condition_on_single_candidate = "Type"`, +más `profile = "expr"`. + +> **Design note.** El escaneo evalúa las condiciones en dos pasadas precisamente +> para que las dependientes del registro (`condition_on_bean`, +> `condition_on_missing_bean`, `condition_on_single_candidate`) puedan ver el +> *resultado* de la pasada de configuración/perfil. Este escaneo en dos pasadas es +> cómo *toda* la autoconfiguración propia de Firefly se retira cuando proporcionas +> tu propio bean — el tema del siguiente paso. + +> **Tip** **Punto de control.** Puedes predecir qué almacén se resuelve: con +> `lumen.store.postgres` sin establecer, el respaldo en memoria; con él +> establecido a `true`, el almacén Postgres. El código de servicio que depende de +> `Arc` es idéntico en ambos casos. + +## Paso 7 — Deja que una autoconfiguración se aparte de tu camino + +Una **autoconfiguración** es cómo un starter aporta valores por defecto sensatos +que desaparecen en el momento en que declaras los tuyos. Es el patrón +por-defecto-con-anulación del Paso 6, empaquetado. + +> **Note** **Término clave — autoconfiguración.** Una *autoconfiguración* es un +> contenedor `@Configuration` cuyos `#[bean]` están protegidos por +> `condition_on_missing_bean` y se registran **los últimos** en el escaneo — de +> modo que tu propio bean siempre gana y el valor por defecto aporta algo solo +> cuando no escribiste nada. Esto es `@AutoConfiguration` de Spring Boot. + +Deriva `#[derive(AutoConfiguration)]` y protege cada `#[bean]`: + +```rust,ignore +use firefly::prelude::*; + +#[derive(AutoConfiguration, Default)] +pub struct CacheAutoConfiguration; + +#[firefly::bean] +impl CacheAutoConfiguration { + #[bean(condition_on_missing_bean = "CacheClient", condition_on_property = "cache.type=memory")] + fn cache_client(&self) -> CacheClient { + CacheClient::in_memory() // the default — only if you didn't wire one + } +} +``` + +Qué acaba de pasar: `#[derive(AutoConfiguration)]` es un `#[derive(Configuration)]` +cuyos beans se registran **los últimos** durante el escaneo. Como sus `#[bean]` +llevan `condition_on_missing_bean`, el escaneo en dos pasadas registra primero tu +bean incondicional, y luego *omite* el valor por defecto de la autoconfiguración — +de modo que tu bean siempre gana, y nunca escribes un `if`. Quita el starter de +tus dependencias y el código de la autoconfiguración no se enlaza, así que no +aporta nada: el descubrimiento ocurre en tiempo de enlazado, no por reflexión. + +> **Design note.** «Presente exactamente cuando está enlazado» es todo el truco. +> Una autoconfiguración aporta sus valores por defecto precisamente cuando su +> crate está compilado dentro, y no aporta nada una vez quitas el starter — sin +> escaneo del classpath, sin reflexión, sin registro `spring.factories` que +> mantener. + +## Paso 8 — Trae la configuración directamente a un bean + +Un servicio no debería pasar la configuración a través de su constructor a mano. +Dos atributos de estereotipo la traen directamente. Se cubren por completo en +[Configuración](./03-configuration.md) §«Enlazar configuración directamente a un +bean»; este es el resumen relevante para la DI. + +**Valor único — `#[firefly(value = "${key:default}")]`** enlaza un único escalar +resuelto y con marcadores expandidos sobre un campo (parseado vía `FromStr`); la +cola `:default` aporta un respaldo cuando la clave está ausente: + +> **Note** **Término clave — marcador (placeholder).** Un *marcador* como +> `${lumen.web.addr}` se reemplaza en tiempo de enlazado con el valor de +> configuración resuelto, con una cola `:default` opcional. Solo se soportan +> marcadores `${...}` — las expresiones SpEL `#{...}` quedan fuera de alcance +> (véase el Paso 12). Esto es `@Value` de Spring en su forma de marcador. + +```rust,ignore +#[derive(Service)] +pub struct WalletApiConfig { + #[firefly(value = "${lumen.web.addr:127.0.0.1:8080}")] addr: String, +} +``` + +**Subárbol completo — `#[derive(ConfigProperties)]`** enlaza un struct `serde` +bajo un prefijo y lo registra como un singleton inyectable; cualquier bean puede +entonces autocablearlo: + +> **Note** **Término clave — propiedades de configuración.** Un struct de +> *propiedades de configuración* enlaza un subárbol completo de configuración +> (todo bajo un prefijo) en un struct `serde` tipado y lo registra como un bean. +> Esto es `@ConfigurationProperties` más `@EnableConfigurationProperties` de +> Spring. + +```rust,ignore +use serde::Deserialize; +use firefly::prelude::*; +use std::sync::Arc; + +#[derive(Deserialize, ConfigProperties, Default)] +#[firefly(prefix = "lumen.web")] +pub struct WebProperties { + pub addr: String, + #[serde(default)] pub admin_addr: String, +} + +#[derive(Service)] +pub struct ReportService { + #[autowired] props: Arc, // the bound subtree, injected +} +``` + +Qué acaba de pasar: `ConfigProperties` es el sexto derive consciente del +contenedor junto a los cinco estereotipos. Genera un `firefly_register` que enlaza +y registra el struct, y lleva una etiqueta de estereotipo `config_properties` a +`/beans`. Añadir `#[firefly(validate)]` (con `#[derive(Validate)]` en el struct) +ejecuta las restricciones declarativas del struct enlazado tras el enlazado, y una +violación **hace fallar la creación del bean** en el arranque en lugar de arrancar +una configuración malformada — el `@Validated` de Spring. + +> **Note** `#[firefly(value = "${...}")]` es `@Value` (forma de marcador), y +> `#[derive(ConfigProperties)]` + `#[firefly(prefix = "...")]` es +> `@ConfigurationProperties`. Ambos enlazan contra el mismo mapa de configuración +> fusionado, resuelto por perfil y con marcadores expandidos descrito en +> [Configuración](./03-configuration.md). + +## Paso 9 — Elige el ámbito de un bean + +Cada bean tiene un **ámbito** que controla cuánto vive su instancia. Pásalo como +`#[firefly(scope = "...")]`. Casi todos los beans de Lumen son singletons; recurres +a los demás raramente pero deliberadamente. + +| Ámbito | Comportamiento | Úsalo para | +|-------|-----------|---------| +| `singleton` (por defecto) | una instancia, cacheada tras la primera resolución | servicios sin estado, pools, cachés | +| `transient` | una instancia nueva en cada resolución | estado por operación (el contexto de borrador de una saga) | +| `request` | uno por petición HTTP (necesita un `ScopeHandler` de petición) | el usuario autenticado, un id de traza de petición | +| `session` | uno por sesión (necesita un `ScopeHandler` de sesión) | estado por sesión | + +```rust,ignore +#[derive(Component)] +#[firefly(scope = "transient")] +pub struct TransferContext { // a fresh scratch pad per transfer + pub steps: Vec, +} +``` + +Qué acaba de pasar: el nombre del ámbito es la variante en minúsculas de +`Scope::{Singleton, Transient, Request, Session}` (definido en +`crates/container/src/scope.rs`). Un bean `transient` se reconstruye en cada +resolución; los ámbitos `request` y `session` necesitan un handler, que se cubre a +continuación. + +### Ámbitos de petición y sesión: el SPI `ScopeHandler` + +Rust no tiene un thread-local ambiental de petición como sí tiene un contenedor +reflexivo de la JVM, así que los ámbitos `request` y `session` se **operan +explícitamente** mediante una implementación del SPI `ScopeHandler` — el análogo +directo del `org.springframework...config.Scope` de Spring. Un handler cachea una +instancia por clave (por petición, por sesión) y la desaloja cuando esa clave +termina: + +> **Note** **Término clave — SPI (interfaz de proveedor de servicio).** Un *SPI* +> es un trait que el framework define y un *host* implementa para enchufar +> comportamiento. `ScopeHandler` es el SPI para el ciclo de vida de +> petición/sesión: instalas una implementación y el contenedor opera el ámbito a +> través de ella. + +```rust,ignore +use firefly::prelude::*; +use std::sync::Arc; + +// A host installs the handlers once at startup. Until one is installed, +// resolving a request/session-scoped bean is a NoSuchBean ("no active +// request context"), matching the Spring/pyfly behaviour. +container.register_request_scope(Arc::new(my_request_handler)); +container.register_session_scope(Arc::new(my_session_handler)); +``` + +Qué acaba de pasar: `register_request_scope` / `register_session_scope` respaldan +los dos ámbitos integrados; los ámbitos personalizados arbitrarios usan +`register_scope("name", handler)` (que rechaza nombres vacíos y los cuatro +integrados). Los tres viven en `Container` (`crates/container/src/lib.rs`); un +`ScopeHandler` es solo un `get(name, factory)` más `remove(name)` +(`crates/container/src/scope.rs`). + +> **Design note.** El ciclo de vida de petición/sesión está *instalado*, no es +> implícito. Un `Container` pelado sin handler instalado reporta `NoSuchBean` para +> esos ámbitos en lugar de filtrar silenciosamente un singleton — el aplazamiento +> es explícito, el mismo compromiso descrito en el Paso 12. + +### `RefreshScope` — paridad de recarga de configuración + +Un cuarto handler, ya hecho, se distribuye para la recarga de configuración: +`RefreshScope` (`crates/container/src/scope.rs`). Un bean con ámbito refresh se +cachea como un singleton, pero una llamada a `refresh()` desaloja **todas** las +instancias con ámbito refresh para que la siguiente resolución las reconstruya +contra la nueva configuración — el hook que un futuro `/actuator/refresh` llamaría +ante un cambio de configuración. Regístralo bajo el nombre convencional +`REFRESH_SCOPE_NAME` (`"refresh"`): + +```rust,ignore +use firefly::prelude::*; +use std::sync::Arc; + +let refresh = Arc::new(firefly::container::RefreshScope::new()); +container.register_scope(firefly::container::REFRESH_SCOPE_NAME, refresh.clone())?; + +// On a config-change event: +let evicted: Vec = refresh.refresh(); // rebuild on next resolve +``` + +Para un único singleton hay un hook más ligero: `container.reset_instance::()` +descarta solo la instancia cacheada de `T` para que se reconstruya en la siguiente +resolución (devuelve si realmente se desalojó una instancia). Es la forma por-bean +de la misma idea de refresco-al-cambiar-configuración. + +> **Note** `RefreshScope` / `REFRESH_SCOPE_NAME` reflejan el `@RefreshScope` de +> Spring Cloud, y `reset_instance::()` es el hook de refresco por-bean. Ambos +> existen para que una recarga de configuración pueda reconstruir los beans +> afectados sin reiniciar el proceso. + +### `#[firefly(lazy)]` — renunciar a la pasada de precalentamiento eager + +Por defecto los singletons se construyen de forma **eager** cuando un +`ApplicationContext` arranca (Paso 11). `#[firefly(lazy)]` excluye a un singleton +**de esa pasada de precalentamiento eager** — entonces se construye en la primera +resolución en su lugar: + +```rust,ignore +#[derive(Service)] +#[firefly(lazy)] // skipped at startup; built on first use +pub struct ExpensiveReportEngine { /* … */ } +``` + +> **Note** `#[firefly(lazy)]` es `@Lazy`: quita el bean de la pasada de +> precalentamiento del arranque para que un singleton caro o de uso poco frecuente +> se construya solo cuando algo lo resuelve. A diferencia de Spring, **no** crea un +> proxy y por tanto no rompe un verdadero ciclo de dependencias en tiempo de +> construcción; recurre a `Provider` para aplazar una dependencia (Paso 12). + +## Paso 10 — Enmarca un bean con hooks de ciclo de vida + +Un bean a menudo necesita una configuración única tras inyectar sus dependencias, +y un desmontaje limpio al apagarse. Nombra un método por hook en el atributo del +struct: + +> **Note** **Término clave — hooks de ciclo de vida.** Un hook *post-construct* se +> ejecuta una vez después de que el bean se construye y sus dependencias se +> inyectan; un hook *pre-destroy* se ejecuta al apagarse. Son claves de atributo +> en el struct, no macros independientes. Estos son `@PostConstruct` / +> `@PreDestroy` de Spring (el sustituto de `InitializingBean` / `DisposableBean`). + +```rust,ignore +#[derive(Service)] +#[firefly(post_construct = "on_start", pre_destroy = "on_stop")] +pub struct ProjectionListener { /* … */ } + +impl ProjectionListener { + fn on_start(&self) { /* subscribe to wallets.events */ } + fn on_stop(&self) { /* unsubscribe, flush */ } +} +``` + +Qué acaba de pasar: `on_start` se ejecuta tras la construcción — con todos los +campos `#[autowired]` asignados — de modo que puede consultar a los colaboradores +con seguridad. `Container::destroy()` (el `close()` del `ApplicationContext`) +ejecuta cada hook `pre_destroy` en orden de construcción **inverso**, de modo que +un listener que arrancó después del read model se detiene antes que él. + +> **Note** En el Lumen real la proyección del read model se suscribe al broker vía +> `#[event_listener]` (el mecanismo de EDA), no un hook `post_construct` — el hook +> aquí es el patrón general que cualquier bean usa para una configuración única. +> Verás la ruta de EDA en +> [Mensajería y arquitectura orientada a eventos](./10-eda-messaging.md). + +## Paso 11 — Comprende el arranque eager y el fallo rápido + +Un `Container` pelado construye los singletons de forma **perezosa** — el primer +`resolve::()` construye `T` y lo cachea. El `ApplicationContext`, sin embargo, +es **eager** por defecto, igualando el arranque con fallo rápido de Spring. +`ApplicationContext::build()` escanea el grafo de crates, registra los +supervivientes, y luego **precalienta** inmediatamente cada singleton no-lazy +resolviéndolo una vez (`crates/firefly/src/context.rs`). + +> **Note** **Término clave — inicialización eager / pasada de precalentamiento.** +> La *pasada de precalentamiento* es `build()` resolviendo cada singleton no-lazy +> una vez en el arranque, de modo que estén todos construidos antes de la primera +> petición. Esto es lo que te da la garantía de Spring de «valida el cableado en el +> arranque». + +Dos cosas se derivan de esa pasada de precalentamiento: + +- **Fallo rápido.** Un error de construcción — una dependencia requerida que + falta, un pánico en un `post_construct` — aflora en el *arranque*, no en lo más + profundo de la primera petición. +- **`post_construct` se ejecuta en el arranque.** Como precalentar un bean lo + construye, el hook `post_construct` de cada singleton no-lazy se dispara durante + `build()`, antes de que el contexto te entregue el contenedor. + +Eso pone todo el ciclo de vida del bean en una línea: + +> **escaneo → registro → precalentar singletons no-lazy → `post_construct` → +> (servir) → `close()` → `pre_destroy` en orden inverso** + +Puedes excluir la pasada de precalentamiento por completo con `.eager(false)`, o +excluir un único bean con `#[firefly(lazy)]`: + +```rust,ignore +use firefly::prelude::*; + +let ctx = ApplicationContext::builder() + .profiles(["prod"]) + .eager(false) // skip the warm pass; everything builds lazily + .build(); +``` + +> **Design note.** El precalentamiento eager en `build()` es una política a nivel +> de *contexto*: un `Container` pelado permanece perezoso. `.eager(false)` apaga +> toda la pasada de precalentamiento; `#[firefly(lazy)]` excluye un bean de ella. +> `close()` es el apagado simétrico — ejecuta cada `pre_destroy` en orden de +> construcción inverso y desaloja los singletons cacheados. + +### La superficie del builder de `ApplicationContext` + +`ApplicationContext::builder()` acepta más que `.profiles()` y `.property()`; la +superficie completa (`crates/firefly/src/context.rs`): + +| Método | Propósito | +|--------|---------| +| `.profiles([...])` | perfiles activos (por defecto `FIREFLY_PROFILE`, luego `"default"`) | +| `.property(k, v)` / `.properties(map)` | añade propiedades de configuración para marcadores y condiciones | +| `.config_sources(vec![...])` | fusiona capas `firefly_config::Source` (env, YAML) en el mapa de propiedades | +| `.class(label)` | marca una «etiqueta» de característica presente para comprobaciones `condition_on_class` | +| `.eager(bool)` | precalienta los singletons no-lazy en `build()` — por defecto `true` | +| `.build()` | construye el contenedor compartido, escanea y luego (por defecto) precalienta los singletons | +| `.build_async().await` | lo mismo, pero `await`ea los beans async pendientes (Paso 5) | + +> **Note** `.class(label)` es el sustituto de Firefly para `@ConditionalOnClass`: +> Rust no tiene un classpath que sondear, así que un host declara qué +> características opcionales están «presentes» por etiqueta, y los beans +> `condition_on_class = "label"` se limitan según ello. + +> **Tip** **Punto de control.** Puedes enunciar cuándo aflora una dependencia que +> falta: bajo un `ApplicationContext` eager, en `build()`; bajo un `Container` +> perezoso pelado, en el primer `resolve`. Lumen arranca a través de +> `FireflyApplication`, que es eager — así que los errores de cableado hacen fallar +> el arranque, no la primera petición. + +## Paso 12 — Escaneo de componentes frente a `register_all!` + +Ya has usado ambas rutas de descubrimiento; aquí está el contraste en un solo +lugar. `Container::scan()` recopila el thunk de escaneo de cada derive de +estereotipo no genérico a lo largo de todo el grafo de crates, aplica condiciones y +perfiles, y registra los supervivientes. Opéralo a través del `ApplicationContext`, +que construye el contexto de condiciones a partir de los perfiles activos y la +configuración: + +```rust,ignore +use firefly::prelude::*; +use std::sync::Arc; + +let ctx = ApplicationContext::builder() + .profiles(["prod"]) + .property("lumen.store.postgres", "true") + .build(); // scans, then (by default) warms singletons +let store: Arc = + ctx.resolve().unwrap(); // -> PostgresEventStore (prod) +println!("{} beans registered", ctx.bean_count()); +``` + +Para escanear solo parte del grafo de crates, pasa las rutas de módulo base: + +```rust,ignore +let c = Container::new(); +c.scan_packages(&["lumen::domain", "lumen::ledger"]); // these modules only +``` + +Un registro coincide cuando la ruta de módulo que lo define es igual a un paquete +base o es descendiente de él; las condiciones y los perfiles se aplican +exactamente como en `scan()`. + +Los **beans genéricos** no pueden inventariarse (la monomorfización se elige en el +punto de uso), así que registra esos con el respaldo de lista explícita — el mismo +`register_all!` que usaste en el Paso 4: + +```rust,ignore +let c = Container::new(); +firefly::register_all!(&c, [ReadModel, Ledger, WalletApi]); // calls each firefly_register +let api = c.resolve::().unwrap(); +``` + +> **Design note.** El descubrimiento ocurre en tiempo de enlazado vía `inventory`, +> no en tiempo de ejecución vía reflexión — así que un bean solo es descubrible si +> su crate está realmente enlazado. `register_all!` es el respaldo explícito para +> los genéricos, que no pueden inventariarse. + +### Introspección — la vista `/beans` + +El contenedor es observable. `container.beans()` devuelve un `BeanDescriptor` por +registro (nombre, tipo, ámbito, estereotipo, primary, inicializado, conteo de +resoluciones), y `bean_stats()` agrega los conteos por estereotipo — exactamente lo +que renderiza la página `/beans` del panel de administración, y lo que +`firefly beans --url …` (el [capítulo de la CLI](./19-cli.md)) imprime. Los errores +también llevan diagnósticos: `fuzzy_suggestions(name)` alimenta las pistas «¿querías +decir…?», y `CircularDependency` se captura con una pila de resolución por hilo. + +> **Tip** **Punto de control.** Con Lumen en ejecución, abre +> `http://localhost:8081/admin/` y encuentra la página `/beans`. `ReadModel` +> (`repository`), `LumenBeans` (`configuration`), `WalletHandlers` / +> `WalletProjection` (`service`) y `WalletApi` (`controller`) están todos ahí — el +> mismo grafo que el informe de arranque registró línea a línea. + +## Qué cambia el modelo de Rust + +El contenedor de Firefly deliberadamente *no* es un clon línea por línea del de +Spring. El único hecho estructural detrás de cada diferencia es que **Rust no tiene +reflexión en tiempo de ejecución**: un bean se construye mediante una clausura +factoría generada que resuelve sus propias dependencias y construye el struct de +una sola vez, en lugar de las fases «instanciar → poblar → llamar a init» de +Spring. No hay una costura tras la instanciación donde tejer comportamiento, ni +forma de entregarle a un bean un handle reflexivo al contenedor. Las siguientes +características de Spring se reemplazan, por tanto, por modismos de Rust en lugar de +portarse — cada una una elección deliberada, no una característica ausente: + +- **Sin `BeanPostProcessor` / `BeanFactoryPostProcessor`.** No hay fase de + intercepción tras la instanciación ni pasada de reescritura de definiciones. El + comportamiento transversal se compone explícitamente — envuelve un colaborador, o + usa una factoría `#[bean]` para construir la instancia ya cableada que quieres. +- **Sin interfaces `*Aware`** (`ApplicationContextAware`, `EnvironmentAware`, …). A + un bean no se le entrega el contexto mediante un callback. Autocablea lo que + realmente necesitas — `Arc` para configuración, `Provider` para + una dependencia diferida — en lugar de alcanzar de vuelta al contenedor. +- **Sin `FactoryBean`.** Un método factoría `#[bean]` produce cualquier tipo y se + indexa por su tipo de retorno; eso cubre «un bean cuyo trabajo es construir otro + bean» sin una abstracción `FactoryBean` distinta. +- **Sin proxies con ámbito `@Scope(proxyMode)`.** Un bean con ámbito + `request`/`session` se resuelve a través de su `ScopeHandler`, no se inyecta en + un singleton mediante un proxy transparente. Para traer una dependencia de ámbito + más corto (o diferida) a un bean de vida más larga, mantén un `Provider` y + llama a `.get()` en el punto de uso — el sustituto idiomático tanto de los + proxies `@Lazy` como de los proxies con ámbito. +- **Sin fases `SmartLifecycle` por bean.** El contenedor tiene `post_construct` / + `pre_destroy` (el sustituto de `InitializingBean` / `DisposableBean`), y el orden + de arranque/parada a nivel de aplicación vive en el crate `firefly-lifecycle` + separado — no hay negociación de `start`/`stop`/`isRunning`/fase por bean. +- **Sin SpEL `#{...}`.** `#[firefly(value = "${...}")]` solo hace inyección de + marcadores. Las expresiones, las referencias a métodos/beans y la aritmética en + la configuración quedan intencionadamente fuera de alcance para el modismo de + Rust tipado. + +> **Design note.** Lee esta lista como *sustituciones*, no como carencias: +> `Provider` hace las veces de `@Lazy` / proxies con ámbito / `ObjectFactory` +> cuando necesitas aplazar o alcanzar una dependencia de ámbito más corto; +> `post_construct` / `pre_destroy` hacen las veces de `InitializingBean` / +> `DisposableBean`; una factoría `#[bean]` hace las veces de `FactoryBean`; y la +> composición explícita hace las veces del tejido de `BeanPostProcessor`. La +> maquinaria reflexiva ha desaparecido, pero cada trabajo que hacía tiene una +> contraparte tipada y comprobada en compilación. + +## Resumen + +Este capítulo fue un recorrido guiado, no un paso-a-paso de código — el cableado +que documenta ya es cómo funciona `samples/lumen`. Ahora sabes cómo: + +- Declarar un bean con un **derive de estereotipo** (`Component` / `Service` / + `Repository` / `Configuration` / `Controller`) y leer su rol en la vista + `/beans`. +- Cablear dependencias con inyección por constructor **`#[autowired]`**, y razonar + sobre el orden de construcción a partir solo de los tipos de los campos. +- Depender de un **puerto** y seleccionar el **adaptador** con + `#[firefly(primary)]`, nombres y cualificadores — conociendo al dedillo las + cuatro reglas de resolución. +- Producir beans externos o construidos con factorías **`#[derive(Configuration)]` + + `#[bean]`**, incluidas factorías **async** que `await`ean E/S en el arranque + (drenadas por `init_async_beans()` / `build_async()`). +- Limitar beans por **perfil** y la familia **`condition_on_*`**, y dejar que una + **autoconfiguración** se retire en el momento en que declaras tu propio bean. +- Elegir un **ámbito**, instalar el SPI **`ScopeHandler`** para los ámbitos + request/session/refresh, enmarcar un bean con **hooks de ciclo de vida**, y + confiar en el arranque **eager con fallo rápido**. +- Descubrir beans por **escaneo de componentes** o el respaldo **`register_all!`** + para genéricos, e introspeccionar todo el grafo a través de **`/beans`**. + +Los beans de Lumen, entre todos, ejercitan el conjunto completo de estereotipos: +`@Configuration` + `@Bean` (`LumenBeans`), `@Service` (`WalletHandlers`, +`WalletProjection`, el `RouteContributor` de streaming), `@Repository` +(`ReadModel`) y `@Controller` + `@Autowired` (`WalletApi`). Cada atributo mostrado +— `autowired`, `primary`, `order`, `qualifier`, `scope`, `lazy`, `profile`, la +familia `condition_on_*`, `post_construct`, `pre_destroy`, `provides`, `value` y +`prefix` — es una opción real sobre los derives de estereotipo. + +## Ejercicios + +1. **Resuelve el grafo a mano.** Toma `ReadModel`, `Ledger` y `WalletApi`, + cablealos con `register_all!(&c, [ReadModel, Ledger, WalletApi])` y + `c.resolve::()`, y confirma que el contenedor construye el grafo en + orden de dependencias — el mismo grafo que el escaneo de `FireflyApplication` + construye en el arranque. +2. **Por-defecto-con-anulación.** Da a `MemoryEventStore` + `#[firefly(condition_on_missing_bean = "EventStore")]` y a `PostgresEventStore` + `#[firefly(condition_on_property = "lumen.store.postgres=true")]`. Escanea con y + sin la propiedad establecida; confirma qué almacén se resuelve cada vez. +3. **Intercambio de primary.** Vincula dos adaptadores a un puerto sin primary y + observa el error `NoUniqueBean` nombrando ambos candidatos. Añade + `#[firefly(primary)]` a uno y mira cómo la resolución tiene éxito — sin cambio + en el bean consumidor. +4. **Orden de ciclo de vida.** Añade hooks `post_construct` / `pre_destroy` a dos + beans donde uno depende del otro; llama a `ApplicationContext::close()` y + confirma que el dependiente se desmonta primero. +5. **Lee el grafo en vivo.** Ejecuta Lumen, abre `http://localhost:8081/admin/` y + encuentra la página `/beans`. Mapea cada entrada de vuelta al código que la + declaró — las factorías `#[bean]` en `LumenBeans`, el `#[derive(Repository)]` + `ReadModel`, el `#[derive(Controller)]` `WalletApi` — y anota la etiqueta de + estereotipo que lleva cada una. + +## Adónde ir después + +Ahora tienes en la mano el contenedor de DI completo de Firefly — el motor que +cablea cada bean en Lumen, desde las factorías `#[bean]` hasta el controlador +autocableado. El modelo reactivo sustenta todo lo que sigue — continúa a **[El +modelo reactivo](./05-reactive-model.md)**. diff --git a/docs/book/src-es/04b-bootstrap.md b/docs/book/src-es/04b-bootstrap.md new file mode 100644 index 00000000..a81ac3a3 --- /dev/null +++ b/docs/book/src-es/04b-bootstrap.md @@ -0,0 +1,585 @@ +# Arranque con FireflyApplication + +El `main` de Lumen es una sola línea, y esa línea es todo el servicio. En el +[Inicio rápido](./02-quickstart.md) lo ejecutaste y viste un banner, un informe +de arranque y dos puertos en vivo, pero diste por bueno `run()` sin más. Este +capítulo levanta la tapa. Aquí no se *añade* nada nuevo a Lumen; en su lugar +aprenderás exactamente qué hace `FireflyApplication::new("lumen").run().await` +entre el momento en que pulsas Intro y el momento en que los dos servidores +aceptan conexiones. Conocer el pipeline rinde frutos en cada capítulo posterior, +porque cada uno declara un bean, un controlador, un handler, un listener o una +tarea programada que *una de estas etapas* descubre y conecta por ti. + +Al terminar este capítulo, serás capaz de: + +- Explicar la diferencia entre `new`, `run` y `bootstrap`, y saber cuál debería + llamar tu código de pruebas. +- Recorrer el pipeline de arranque de doce etapas que ejecuta `bootstrap()`, y + nombrar lo que descubre cada etapa: el stack web, el escaneo de DI, la + autoconfiguración de CQRS, el descubrimiento de seguridad, el automontaje de + controladores, el vaciado de handlers, OpenAPI y el router de gestión. +- Usar las palancas del builder de `FireflyApplication` (`version`, `configure`, + `security`, `on_ready`, `extra_routes`, los overrides de dirección) y saber + cuándo se prefiere la vía del *bean declarativo* sobre la palanca imperativa. +- Sobrescribir las direcciones de enlace pública y de gestión desde el entorno + con `FIREFLY_SERVER_ADDR` / `FIREFLY_MANAGEMENT_ADDR`. +- Leer el informe de arranque línea a línea y usarlo como comprobación de + cordura sobre lo que conectó el framework. +- Entender el apagado ordenado y el 404 RFC 9457 por defecto que obtienes gratis. + +## Conceptos que conocerás + +Antes de recorrer el pipeline, aquí están las ideas en las que se apoya este +capítulo. Cada una se reintroduce en contexto allí donde se usa por primera vez; +esta es la versión corta. + +> **Note** **Término clave — bootstrap.** El *arranque* (bootstrapping) es el +> acto único de ensamblar una aplicación en ejecución a partir de sus +> declaraciones: construir la infraestructura, descubrir componentes, +> conectarlos entre sí y producir algo servible. En Firefly, todo el arranque es +> el cuerpo de `FireflyApplication::bootstrap()`. El análogo en Spring es todo lo +> que `SpringApplication.run(...)` hace antes de que el servidor embebido empiece +> a aceptar peticiones. + +> **Note** **Término clave — raíz de composición.** La *raíz de composición* +> (composition root) es el único lugar de un programa donde se ensambla el grafo +> de objetos, donde se construye y conecta cada componente. Muchos frameworks te +> obligan a escribirla a mano. En Firefly el framework *es* la raíz de +> composición: escanea tus beans y los conecta, así que nunca deletreas el grafo +> en una función. Por eso Lumen no tiene `build_app`, ni router escrito a mano, +> ni ningún punto de llamada `register_*`. + +> **Note** **Término clave — inventario.** El *inventario* (inventory) es un +> conjunto de registros de tiempo de enlace que las macros de Firefly rellenan en +> tiempo de compilación. Cuando escribes `#[command_handler]`, `#[event_listener]`, +> `#[scheduled]` o `#[rest_controller]`, la macro registra el elemento en una +> tabla global que el framework *vacía* en el arranque. No hay reflexión ni +> escaneo del sistema de archivos en tiempo de ejecución: las declaraciones +> mismas *son* el registro. Así es como `main` nunca cambia a medida que Lumen +> crece. + +> **Note** **Término clave — superficie de gestión.** La *superficie de gestión* +> (management surface) es el conjunto de endpoints HTTP operativos —salud, +> información, métricas, introspección de configuración— más el panel de +> administración autoalojado y la documentación de la API. Firefly los sirve en +> un puerto separado (`8081` por defecto) de tu API de negocio (`8080`), de modo +> que los endpoints operativos nunca se filtran a la red pública. Esto refleja +> Spring Boot Actuator. + +## Paso 1 — Mira la única línea que estás a punto de descifrar + +El `main` de Lumen es la misma línea que escribiste en el inicio rápido, que vive +en `src/main.rs` junto a las declaraciones `mod` del crate: + +```rust,ignore +// src/main.rs +#[tokio::main] +async fn main() -> Result<(), firefly::BoxError> { + firefly::FireflyApplication::new("lumen").run().await +} +``` + +Lo que acaba de ocurrir: no hay `build_app`, ni router escrito a mano, ni ningún +punto de llamada `register_*` / `subscribe_*` / `schedule_*` en ninguna parte de +Lumen. `main` solo nombra la aplicación y entrega el control al framework. Todo +lo demás —los beans de [Inyección de dependencias](./04a-dependency-injection.md), +los controladores de [Tu primera API HTTP](./06-first-http-api.md), los handlers +de [CQRS](./09-cqrs.md), los listeners de +[Arquitectura dirigida por eventos](./10-eda-messaging.md) y las tareas +programadas de [Planificación y notificaciones](./16-scheduling-notifications.md)— +lo descubre el framework a partir de declaraciones que se sitúan junto al código. + +> **Note** **Término clave — `BoxError`.** `firefly::BoxError` es el tipo de error +> en caja del framework, `Box`. Devolverlo +> desde `main` te permite usar `?` sobre el arranque y hace que cualquier fallo de +> arranque aflore como una salida del proceso distinta de cero. Se reexporta desde +> la fachada `firefly`, así que nunca nombras el crate subyacente. + +> **Design note.** `FireflyApplication::new(name).run()` es el análogo en Rust de +> `SpringApplication.run(App.class, args)` de Spring Boot y de +> `FireflyApplication("lumen").run()` de pyfly. Esa única llamada *es* la raíz de +> composición. Nada es reflexivo ni está oculto: el informe de arranque (Paso 10) +> registra exactamente qué se conectó, de modo que "qué está corriendo" se imprime +> línea a línea en el arranque. + +> **Tip** **Punto de control.** Abre `samples/lumen/src/main.rs` (o el `main.rs` +> de tu propio crate). Confirma que `main` es una sola sentencia: +> `new("lumen").run().await`. Si ves un `build_app`, un router o cualquier llamada +> `register_*`, estás leyendo una forma más antigua: el framework actual conecta +> todo eso por ti. + +## Paso 2 — Distingue entre `new`, `run` y `bootstrap` + +Tres métodos gobiernan el ciclo de vida, y elegir el correcto marca la diferencia +entre un servidor de producción y una prueba rápida en proceso. + +> **Note** **Término clave — `bootstrap` frente a `run`.** `bootstrap()` ensambla +> la aplicación entera —cada etapa del Paso 4— y devuelve un valor `Bootstrapped` +> **sin enlazar un socket ni servir**. `run()` llama a `bootstrap()` y luego a +> `serve()`. Así que ambas vías ensamblan la *misma* aplicación; solo difiere el +> último movimiento (enlace + servir). + +- **`FireflyApplication::new(name)`** construye el builder. Lee las direcciones de + enlace por defecto del entorno y siembra el nombre de la aplicación. Todavía no + pasa nada: ni escaneo, ni servidor. +- **`.run().await`** arranca y sirve hasta que el proceso recibe + `SIGINT`/`SIGTERM`. Esto es lo que llama `main`. +- **`.bootstrap().await`** hace todo lo que hace `run` *salvo* servir, y devuelve + un `Bootstrapped` cuyo `api_router` puedes manejar en proceso. Esta es la + costura para pruebas. + +Las pruebas HTTP de Lumen usan exactamente la vía `bootstrap`. Aquí está el helper +real de `src/web.rs` que llaman los módulos de prueba: + +```rust,ignore +// src/web.rs — the testable in-process router, no socket bound +#[cfg(test)] +pub(crate) async fn build_router() -> axum::Router { + firefly::FireflyApplication::new(APP_NAME) + .version(VERSION) + .bootstrap() + .await + .expect("lumen bootstrap") + .api_router +} +``` + +Lo que acaba de ocurrir: `bootstrap()` devuelve un `Bootstrapped`, y `.api_router` +es su router público completamente ensamblado, con controladores, middleware y +seguridad aplicados. Una prueba maneja después ese router con +`tower::ServiceExt::oneshot`, enviando una petición directamente al router sin +socket TCP de por medio. Como la vía de producción (`run`) y la vía de prueba +(`bootstrap` → `oneshot`) ensamblan la **misma** aplicación, las pruebas de +[Pruebas](./18-testing.md) ejercitan exactamente el cableado que sirve `main`. + +> **Note** Observa que este helper llama a `.version(VERSION)` mientras que `main` +> no lo hace. La versión es puramente cosmética —aparece en el banner y en +> `/actuator/info`—, así que `main` puede omitirla y dejar que tome su valor por +> defecto. La prueba la fija explícitamente solo para que las aserciones sobre +> `/actuator/info` sean estables. + +> **Tip** **Punto de control.** Ya puedes responder: *¿qué método llama una +> prueba, y por qué?* Una prueba llama a `bootstrap()` porque quiere el router +> conectado sin enlazar un puerto; `main` llama a `run()` porque quiere servir. + +## Paso 3 — Conoce el valor `Bootstrapped` + +`bootstrap()` devuelve una struct `Bootstrapped`. Rara vez construirás una tú +mismo, pero conocer sus campos desmitifica qué significa "ensamblada". La forma +real del framework: + +```rust,ignore +pub struct Bootstrapped { + /// The web stack (kept so `serve` can run the lifecycle). + pub web: WebStack, + /// The scanned DI container. + pub container: Arc, + /// The fully-assembled public API router (controllers + middleware + security). + pub api_router: Router, + /// The management router (`/actuator/*` + the self-hosted `/admin` dashboard). + pub management_router: Router, + /// The task scheduler (started by `serve`). + pub scheduler: Arc, + /// The public bind address. + pub api_addr: String, + /// The management bind address. + pub management_addr: String, +} +``` + +Lo que acaba de ocurrir: un `Bootstrapped` lleva ambos routers (público + de +gestión), el contenedor de DI escaneado, el scheduler que aún no ha arrancado y +las dos direcciones a las que enlazar. El único trabajo que le queda a `run()` es +llamar a `serve()` sobre este valor, que arranca el scheduler y enlaza ambos +routers. Una prueba ignora todo salvo `api_router`. + +> **Note** **Término clave — contenedor de DI.** El *contenedor* (container) es el +> registro que guarda cada bean que construyó el framework, indexado por tipo, de +> modo que cualquier componente puede pedir un colaborador por tipo y obtener la +> instancia gestionada. Es la mitad de tiempo de ejecución de la inyección de +> dependencias que conociste en +> [Inyección de dependencias](./04a-dependency-injection.md). +> `Bootstrapped.container` es ese registro, completamente escaneado. + +## Paso 4 — Recorre el pipeline de arranque, etapa por etapa + +Este es el corazón del capítulo. `bootstrap()` ejecuta un pipeline fijo, y cada +etapa se vincula a algo que el framework *descubre y conecta* por ti. Léelo una +vez de arriba abajo; volverás a etapas individuales a medida que los capítulos +posteriores añadan los beans que cada etapa encuentra. La numeración de abajo +sigue el código fuente del framework (`crates/firefly/src/application.rs`). + +**1. Construir el stack web.** `WebStack::new(config)` levanta axum, el `Bus` de +CQRS, el `Broker` de EDA, el `Scheduler`, el registro de métricas, el compuesto de +salud y el middleware por defecto (id de correlación, métricas de petición, +idempotencia, CORS, cabeceras de seguridad). La seguridad *no* se aplica aquí +—viene de un bean tras el escaneo—, así que el stack se construye en bruto y +mutable. + +> **Note** **Término clave — bus / broker / scheduler.** El *bus* enruta los +> comandos y consultas de CQRS (Command/Query Responsibility Segregation) hacia +> sus handlers; el *broker* entrega los eventos a los listeners; el *scheduler* +> ejecuta las tareas `#[scheduled]` con un temporizador. Los tres son beans de +> infraestructura del framework, construidos aquí y registrados en el contenedor en +> la etapa 3 para que tu código pueda autoconectarlos (autowire). + +**2. Inicializar el logging.** Se instala el subscriber de logging estructurado. +Cuando la feature `admin` está activa, los logs también se derivan al búfer de +captura en memoria del panel de administración, de modo que `/admin` puede mostrar +una cola de logs en vivo. + +**3. Escaneo de componentes del contenedor.** El framework primero +**autorregistra sus propios beans de infraestructura** +(`web.register_beans(&container)`: el `Bus`, el `Broker`, el `Scheduler`, los +registros) y luego `container.scan()` descubre, registra y autoconecta **tus** +beans: cada +`#[derive(Component/Service/Repository/Configuration/Controller)]` y cada factoría +`#[bean]` enlazada al binario. Inmediatamente después del escaneo síncrono, +`container.init_async_beans().await` espera a cada factoría `#[bean]` `async fn` +(un pool de BD, una conexión al broker) para que los beans asíncronos estén vivos +antes de que cualquier cosa los resuelva, y un error de construcción aborta el +arranque (fail-fast). En el caso de Lumen, ese escaneo encuentra el +`#[derive(Configuration)]` de `LumenBeans` y sus factorías `#[bean]` (el event +store, la caché de consultas, el servicio JWT, la `FilterChain`, el `BearerLayer`, +el ledger) más el bean del controlador `WalletApi`. Esta es la DI de tiempo de +enlace de [Inyección de dependencias](./04a-dependency-injection.md). + +**4. Autoconfigurar el bus de CQRS.** La propagación de la correlación siempre se +añade como capa (`bus.use_middleware(CorrelationMiddleware::new())`). Si hay un +bean `QueryCache` presente en el contenedor, su middleware de caché de lectura +también se añade como capa, de modo que la caché de 30 segundos de `GetWallet` en +Lumen ([Caché](./17-caching.md)) se conecta sin código de aplicación, solo por +*declarar el bean `QueryCache`*. El middleware de validación ya está instalado por +el core. + +**5. Ejecutar el hook de preparación opcional.** La mayoría de las aplicaciones +—Lumen incluida— no necesitan ninguno; los beans y el pipeline cubren el cableado. +`on_ready` existe para el caso raro que quiere los colaboradores en vivo +(contenedor, bus, broker, scheduler) después del escaneo pero antes de servir. Lo +cubrimos en el Paso 5, más abajo. + +**6. Autodescubrir la seguridad.** Sin una llamada explícita a `.security(...)`, +el framework resuelve el bean `FilterChain` (RBAC basado en rutas) y el bean +`BearerLayer` (extracción de token) desde el contenedor y los aplica: el bean +`SecurityFilterChain` de Spring, descubierto. Lumen declara ambos como `#[bean]` +en `LumenBeans`, de modo que las rutas protegidas de [Seguridad](./14-security.md) +se activan automáticamente. (Si hay un bean `ExceptionHandlerRegistry` presente, se +instala como la capa de advice más externa: el análogo de `@ControllerAdvice`). + +**7. Automontar las rutas.** `mount_controllers(&container)` resuelve cada +`#[rest_controller]` y construye su router a partir del bean de estado +autoconectado del controlador; `mount_route_contributors(&container)` fusiona cada +bean `RouteContributor`. Así es como se añade el endpoint de streaming +condicionado por feature de Lumen: declarando un bean, no editando una raíz de +composición. Resolver los controladores aquí también construye sus colaboradores, +incluido el `#[bean]` `ledger`. + +> **Note** **Término clave — `RouteContributor`.** Un `RouteContributor` es un +> bean que aporta rutas axum en bruto que el framework fusiona en el router +> público. Es la vía de escape para endpoints que no encajan en la forma de +> `#[rest_controller]`, como el flujo reactivo de eventos de Lumen. Lo declaras +> como un bean (`#[firefly(provides = "dyn firefly::web::RouteContributor")]`) y el +> framework lo encuentra; sigue sin haber una raíz de composición que editar. + +**8. Vaciar el inventario.** El framework vacía los registros de tiempo de enlace +que las macros rellenaron en tiempo de compilación. +`register_discovered_handlers(&bus)` más +`register_discovered_handler_beans(&bus, &container)` instalan cada +`#[command_handler]` / `#[query_handler]`; +`subscribe_discovered_listeners(broker)` más la variante de bean suscriben cada +`#[event_listener]`; y `register_discovered_scheduled(&scheduler)` más la variante +de bean programan cada tarea `#[scheduled]`. No hay puntos de llamada +`register(&bus)` / `subscribe(&broker)`: las declaraciones *son* el registro. + +**9. Aplicar la cadena de middleware.** El middleware descubierto se aplica sobre +las rutas montadas, se añade la capa de autenticación bearer, se fija el fallback +404 por defecto y `web.apply_middleware(...)` envuelve todo el router en el borde +de observabilidad heredado: idempotencia, el log de acceso, métricas de petición, +correlación, trazas W3C, cabeceras de seguridad, renderizado de problemas, CORS y +el advice global de excepciones. Con la feature `admin`, una capa de trazas más +externa **origina y reenvía** `traceparent` para que cada petición sea +correlacionable entre servicios. + +**10. Servir la documentación OpenAPI.** La especificación se construye a partir +del **inventario en vivo** —cada ruta `#[rest_controller]` más cada DTO +`#[derive(Schema)]`— y se sirve en `/v3/api-docs` (más `/openapi.json`), con +Swagger UI en `/swagger-ui` y ReDoc en `/redoc`. Estas se montan en el router de +**gestión** (junto a actuator y admin), *no* en la API pública, ya que exponen +toda la superficie de la API. Esto se conecta automáticamente sin código de +aplicación; [OpenAPI, Swagger UI y ReDoc](./06a-openapi.md) lo cubre por completo. + +> **Note** La especificación OpenAPI anuncia la URL base de la API *pública* como +> su `server` aunque la documentación se sirva en el puerto de gestión, de modo +> que el "Try it out" de Swagger UI envía las peticiones al `8080`, no al origen +> `8081` desde el que se cargó. `FIREFLY_OPENAPI_SERVER_URL` sobrescribe esa URL +> base (por ejemplo, una URL pública detrás de un proxy inverso). + +**11. Instalar el 404 por defecto.** Una ruta no coincidente obtiene un 404 RFC +9457 `application/problem+json` en condiciones, en lugar del cuerpo vacío y desnudo +de axum (véase [Paso 8](#step-8--understand-the-default-404)). + +**12. Construir el router de gestión.** Los endpoints de actuator +(`/actuator/health|info|metrics|loggers|mappings|beans|conditions|env`) se +ensamblan y —con la feature `admin`— el **panel de administración autoalojado** se +monta en `/admin/`, conectado a los componentes en vivo (salud, métricas, el bus, +el scheduler, el contenedor, la instantánea del entorno, el búfer de trazas, el +búfer de logs). El router de documentación OpenAPI de la etapa 10 se fusiona aquí, +y se fija un único fallback 404 RFC 9457 para toda la superficie de gestión. +[Observabilidad](./15-observability.md) cubre la superficie de administración en +profundidad. + +`bootstrap()` devuelve el `Bootstrapped` ensamblado; `run()` llama después a +`serve()`. + +> **Tip** **Punto de control.** Sin releer, nombra qué etapa descubre un handler +> de comando de CQRS (etapa 8: vaciar el inventario), un controlador (etapa 7: +> automontaje), una cadena de filtros de seguridad (etapa 6: descubrimiento de +> seguridad) y la caché de lectura de `GetWallet` (etapa 4: autoconfiguración de +> CQRS, porque hay un bean `QueryCache` presente). Si sabes ubicar cada una, +> entiendes por qué `main` nunca cambia a medida que Lumen crece. + +## Paso 5 — Recurre a una palanca del builder (solo cuando un bean no sirva) + +`FireflyApplication` es un builder, y cada palanca es opcional. Lumen usa solo +`new` (en `main`) y `version` (en `build_router`). Aquí está el conjunto completo, +extraído del código fuente del framework para que las firmas sean exactas: + +| Método | Qué hace | +|--------|--------------| +| `new(name)` | Nombra la aplicación (banner + `/actuator/info`). Toma los enlaces por defecto de `FIREFLY_SERVER_ADDR` / `FIREFLY_MANAGEMENT_ADDR`. | +| `version(v)` | Fija la versión (banner + `/actuator/info`). | +| `configure(\|cfg\| { … })` | Ajusta el `CoreConfig` in situ: CORS, cabeceras de seguridad, idempotencia, las palancas de [Configuración](./03-configuration.md). | +| `security(chain, bearer)` | Instala una `FilterChain` + `BearerLayer` **explícitamente**, en lugar de descubrirlos desde beans. | +| `on_ready(\|ctx\| async { … })` | Un hook de preparación sobre los `container` / `bus` / `broker` / `scheduler` en vivo, ejecutado tras el escaneo y antes de servir. | +| `extra_routes(\|container\| router)` | Fusiona rutas extra que no son `#[rest_controller]`, construidas a partir del contenedor escaneado. | +| `info_contributor(c)` | Añade un contribuidor a `/actuator/info`. | +| `api_addr(addr)` | Sobrescribe la dirección de enlace de la API pública. | +| `management_addr(addr)` | Sobrescribe la dirección de enlace de gestión (actuator + admin). | +| `bootstrap()` | Ensambla la aplicación **sin servir** (pruebas). | +| `run()` | Arranca y sirve. | + +Las palancas son encadenables. Un hipotético servicio `orders` que quiera un toque +de cableado imperativo podría escribir: + +```rust,ignore +// the knobs are chainable; Lumen needs almost none of them +firefly::FireflyApplication::new("orders") + .version("1.0.0") + .configure(|cfg| { /* tune the CoreConfig: CORS, security headers, … */ }) + .management_addr("127.0.0.1:9091") + .run() + .await +``` + +Lo que acaba de ocurrir: cada palanca devuelve `Self`, así que encadenas tantas +como necesites y terminas con `run()` (o `bootstrap()`). La mayoría de los +servicios terminan con una cadena mucho más corta que esta; la de Lumen es la +cadena vacía, `new("lumen").run()`. + +> **Design note.** Lumen declara la seguridad como `#[bean]` en lugar de llamar a +> `.security(...)`, declara su endpoint de streaming como un bean +> `RouteContributor` en lugar de llamar a `.extra_routes(...)`, y siembra su +> proyección dentro de los beans de `ledger`/proyección en lugar de en +> `.on_ready(...)`. Las palancas explícitas del builder existen para aplicaciones +> que prefieren un toque de cableado imperativo; la vía del *bean* es la senda +> preferida del framework, plenamente declarativa: declaración junto al código, +> descubierta en el arranque. Prefiere un bean; recurre a una palanca solo cuando +> ninguna forma de bean encaje. + +> **Tip** **Punto de control.** Ya puedes justificar por qué el `main` de Lumen no +> tiene ninguna palanca del builder: todo lo que una palanca podría hacer, Lumen lo +> hace con un bean que el escaneo encuentra. + +## Paso 6 — Sobrescribe las direcciones de enlace desde el entorno + +Por defecto, la API pública enlaza `0.0.0.0:8080` y la superficie de gestión +enlaza `0.0.0.0:8081`. Puedes mover cualquiera de las dos sin tocar código, porque +`new` lee dos variables de entorno en el momento de la construcción: + +```bash +FIREFLY_SERVER_ADDR=127.0.0.1:9090 \ +FIREFLY_MANAGEMENT_ADDR=127.0.0.1:9091 \ +cargo run --bin lumen +``` + +Lo que acaba de ocurrir: `new` leyó `FIREFLY_SERVER_ADDR` para el enlace público y +`FIREFLY_MANAGEMENT_ADDR` para el enlace de gestión, recurriendo cada uno a su +valor por defecto `0.0.0.0:808x` cuando no está definido. Las dos superficies se +mueven de forma *independiente*, prueba de que son listeners genuinamente +separados, no un único servidor con un prefijo de ruta. Si prefieres fijar las +direcciones en código, `api_addr(...)` / `management_addr(...)` sobrescriben el +entorno. + +> **Tip** **Punto de control.** Arranca Lumen con los dos overrides de arriba y +> luego, en una segunda terminal, ejecuta `curl localhost:9091/actuator/health`. Un +> `{"status":"UP"}` desde `:9091` (y nada en `/actuator/*` de `:9090`) confirma que +> la superficie de gestión se movió por su cuenta. Este es el primer aperitivo de +> la historia de configuración tipada de [Configuración](./03-configuration.md). + +## Paso 7 — Lee el informe de arranque + +Justo antes de servir, `serve()` imprime el banner, las URLs de documentación y +luego `log_startup_report(&container)`: un **informe línea a línea** al estilo de +Spring Boot/pyfly, de modo que un log de arranque se lee como la consola de Spring +Boot. El formato es: + +```text +:: active profiles :: default +:: beans (N) :: + [stereotype ] name scope (TypeName) + …one line per scanned bean, sorted by stereotype then name… +:: routes (N) :: + METHOD path -> Controller::handler + …one line per auto-mounted route, sorted by (path, method)… +:: cqrs handlers: H | event listeners: L | scheduled tasks: S | controllers: C :: +:: openapi :: N operations | K component schemas (served at /v3/api-docs) :: +``` + +Leyéndolo de arriba abajo: + +- **`:: active profiles ::`** — los perfiles de configuración activos (`default` + cuando no se fija ninguno). +- **`:: beans (N) ::`** — cada bean que escaneó el contenedor, uno por línea: el + `[stereotype]` (`service`, `repository`, `controller`, `configuration`, + `component` o `bean`), el nombre del bean, su scope y su nombre de tipo corto. + Esta es la misma tabla que renderiza la vista `/beans` del panel de + administración. +- **`:: routes (N) ::`** — la tabla de rutas automontadas: cada ruta + `#[rest_controller]` como `METHOD path -> Controller::handler`. Se extrae del + mismo registro `firefly_container::routes()` que alimenta `/admin/api/mappings` y + el documento OpenAPI, así que los tres nunca divergen. +- **`:: cqrs handlers … ::`** — los *recuentos* vaciados del inventario: cuántos + `#[command_handler]`/`#[query_handler]`, `#[event_listener]`, `#[scheduled]` y + controladores se descubrieron (cada recuento suma los registros de `fn` libre y + los de bean). +- **`:: openapi ::`** — el recuento de operaciones (una por ruta) y el recuento de + esquemas de componentes (uno por DTO `#[derive(Schema)]`), confirmando que la + especificación está viva. + +Lo que acaba de ocurrir: nada de tu aplicación imprimió esto; lo hizo el framework, +a partir del contenedor y el inventario en vivo. Los números son una comprobación +de cordura rápida: si esperabas cuatro handlers y el informe dice tres, falta un +`#[command_handler]` o su crate no está enlazado. + +> **Tip** **Punto de control.** Ejecuta `cargo run` y lee el informe. Fíjate en lo +> cortas que son hoy las líneas de `beans`, `routes` y recuentos —Lumen aún tiene +> poca lógica de negocio— y luego vuelve a este informe después de +> [CQRS](./09-cqrs.md) y observa cómo crecen los números sin una sola edición en +> `main`. + +## Paso 8 — Entiende el 404 por defecto + +Como el framework instala un fallback en ambos routers (etapas 11 y 12), una ruta +no coincidente devuelve un 404 RFC 9457 `application/problem+json` en condiciones +—el mismo sobre de `type`/`title`/`status` y el mismo tipo de contenido +`application/problem+json` que cualquier otro error del framework— en lugar del +cuerpo vacío y desnudo de axum (que un navegador ofrecería descargar como un +archivo en blanco): + +```text +GET /api/v1/nope + +HTTP/1.1 404 Not Found +content-type: application/problem+json +{ "type": "...", "title": "Not Found", "status": 404, + "detail": "No route matches GET /api/v1/nope" } +``` + +Lo que acaba de ocurrir: el fallback se conecta *dentro* del borde de +observabilidad, así que incluso un 404 de ruta no coincidente se registra, se traza +y se correlaciona; no hay laguna de observabilidad para "la ruta que no existía". +Este es el mismo renderizado de problemas que encuentras para los errores de +handler en [Tu primera API HTTP](./06-first-http-api.md) y para los errores de +seguridad en [Seguridad](./14-security.md): errores uniformes, de extremo a +extremo, sin trabajo por ruta. + +> **Note** **Término clave — RFC 9457.** El RFC 9457 (que deja obsoleto al RFC +> 7807) define el tipo de medio `application/problem+json`: un sobre de error +> pequeño y legible por máquina con los campos `type`, `title`, `status` y +> `detail`. Firefly renderiza *todos* los errores —fallos de handler, validación, +> seguridad y rutas no coincidentes— a través de esta única forma, de modo que un +> cliente parsea los errores exactamente igual sin importar de dónde provengan. + +## Paso 9 — Entiende el apagado ordenado + +`serve()` arranca el scheduler en una tarea en segundo plano y luego sirve la API +pública en `api_addr` y la superficie de gestión en `management_addr` a través del +ciclo de vida del framework, cada una envuelta con `with_graceful_shutdown`. Ante +`SIGINT`/`SIGTERM`, ambos servidores dejan de aceptar nuevas conexiones, permiten +que las peticiones en vuelo terminen y `run()` devuelve `Ok(())`. Una parada +disparada por señal se trata como un *apagado limpio, no como un error*: el caso de +error de cancelación del ciclo de vida se mapea a `Ok(())`. + +Lo que acaba de ocurrir: nunca escribiste un manejador de señales. El framework +atrapa la señal, drena ambos puertos y devuelve éxito, de modo que un `Ctrl-C` en +tu terminal sale sin traza de pila y con código de salida cero. Ese es el +comportamiento en el que confía un orquestador de contenedores (Kubernetes enviando +`SIGTERM`) para un reinicio progresivo. + +> **Tip** **Punto de control.** Ejecuta Lumen y luego pulsa `Ctrl-C`. El proceso +> sale limpiamente, sin panic y sin traza de pila. Si viste un error, estás en una +> build más antigua: el `serve()` actual mapea la cancelación a `Ok(())`. + +## Resumen + +Este capítulo no añadió código a Lumen: descifró la línea que ha estado en +`main.rs` desde el inicio rápido. Ahora sabes: + +- **`new` / `run` / `bootstrap`.** `new` construye el builder; `run` arranca y + sirve; `bootstrap` ensambla la aplicación idéntica *sin* servir y devuelve un + `Bootstrapped` cuyo `api_router` manejan tus pruebas en proceso. +- **El pipeline de doce etapas.** Construir el stack web, inicializar el logging, + escanear los componentes del contenedor (esperando a los beans asíncronos), + autoconfigurar el bus de CQRS, ejecutar el hook de preparación opcional, + autodescubrir la seguridad, automontar controladores y route contributors, vaciar + el inventario (handlers / listeners / tareas programadas), aplicar la cadena de + middleware, servir OpenAPI en el puerto de gestión, instalar el 404 por defecto y + construir el router de gestión con actuator + admin. +- **Palancas del builder frente a beans.** `version`, `configure`, `security`, + `on_ready`, `extra_routes`, `info_contributor` y los overrides de dirección + existen para el cableado imperativo, pero Lumen prefiere el bean declarativo para + cada uno de ellos, así que su `main` es la cadena vacía. +- **Los valores operativos por defecto.** Dos puertos independientes + sobrescribibles por `FIREFLY_SERVER_ADDR` / `FIREFLY_MANAGEMENT_ADDR`, un informe + de arranque línea a línea, un 404 RFC 9457 para las rutas no coincidentes y un + apagado ordenado ante SIGINT/SIGTERM, todo gratis. + +`FireflyApplication` es la columna vertebral de la que cuelga el resto del libro. +Cada capítulo que declara un bean, un controlador, un handler, un listener o una +tarea programada está contribuyendo al pipeline de arriba, y nunca reescribiendo +`main`, solo dándole al framework una cosa más que descubrir. + +## Ejercicios + +1. **Rastrea una etapa hasta un capítulo.** Para cada una de las etapas 4, 6, 7 y + 8, nombra el capítulo posterior que añade el bean o la declaración que esa etapa + descubre, y la única línea de código de Lumen que la hace activarse. (Pista: la + etapa 4 es el bean `QueryCache` de [Caché](./17-caching.md)). +2. **Maneja la costura de pruebas.** En `samples/lumen`, lee `src/http_test.rs` y + encuentra dónde llama a `build_router()`. Confirma que la prueba nunca enlaza un + socket: maneja directamente el router ensamblado por `bootstrap()`. Luego explica + por qué una prueba HTTP que pasa demuestra algo sobre la vía de *producción* + `run()`. +3. **Mueve los puertos de forma independiente.** Arranca Lumen con + `FIREFLY_SERVER_ADDR=127.0.0.1:9090 FIREFLY_MANAGEMENT_ADDR=127.0.0.1:9091 + cargo run`, luego `curl localhost:9091/actuator/health` y + `curl localhost:9090/api/v1/wallets/none`. Confirma que la salud responde en + `:9091` y que el 404 público (problem+json RFC 9457) responde en `:9090`. +4. **Lee el informe de arranque como una lista de comprobación.** Ejecuta Lumen y + copia las líneas `:: cqrs handlers … ::` y `:: routes … ::`. Después de terminar + [CQRS](./09-cqrs.md), ejecútalo de nuevo y compara las dos: cada número nuevo + debería corresponder a un `#[command_handler]`, `#[query_handler]` o + `#[rest_controller]` que añadiste, con `main` intacto. +5. **Provoca un apagado ordenado.** Ejecuta Lumen, lanza una petición lenta y pulsa + `Ctrl-C` a mitad de vuelo. Confirma que la petición en vuelo aún se completa y que + el proceso sale con código `0` y sin traza de pila: la señal fue un apagado, no + un fallo. + +## Adónde ir después + +- Mira los beans que escanea este pipeline, declarados en + **[Inyección de dependencias y autoconfiguración](./04a-dependency-injection.md)**. +- Escribe el `#[rest_controller]` que la etapa 7 automonta en + **[Tu primera API HTTP](./06-first-http-api.md)**. +- Observa cómo cobra vida la especificación OpenAPI que construye la etapa 10 en + **[OpenAPI, Swagger UI y ReDoc](./06a-openapi.md)**. diff --git a/docs/book/src-es/05-reactive-model.md b/docs/book/src-es/05-reactive-model.md new file mode 100644 index 00000000..e5e56478 --- /dev/null +++ b/docs/book/src-es/05-reactive-model.md @@ -0,0 +1,776 @@ +# El modelo reactivo — Mono y Flux + +Este es el capítulo angular. `firefly-reactive` es el **núcleo reactivo** de +Firefly, de calidad de producción: dos publishers perezosos, componibles y +conscientes de la contrapresión — `Mono` (0 o 1 valor) y `Flux` (0..N valores) — +construidos de forma nativa sobre Tokio. Cada superficie reactiva del framework +se construye a partir de estos dos tipos: endpoints HTTP reactivos, repositorios +reactivos, el `WebClient` reactivo y las caras reactivas de EDA y CQRS. Nada de +lo que hay aquí requiere infraestructura — cada ejemplo se ejecuta en el propio +proceso — de modo que puedes teclear cada uno en un test de prueba y verlo pasar. + +En este capítulo no aterriza ningún archivo fuente de Lumen. En su lugar +construyes el *vocabulario* sobre el que Lumen se apoya por partida doble más +adelante: el `Flux` que hay detrás de su endpoint NDJSON / SSE de +*streaming de los eventos de un wallet* (activado en +[Producción y despliegue](./20-production.md)), y el `Mono` perezoso que +devuelven `Bus::send_mono` / `Bus::query_mono` para que un comando de wallet se +componga en un pipeline reactivo. Lee esto antes de los capítulos de construcción +de servicios; todo lo posterior asume que sabes leer de un vistazo un pipeline +`Mono`/`Flux`. + +Al terminar este capítulo, serás capaz de: + +- Explicar qué es un *publisher reactivo*, por qué `Mono` y `Flux` son + **perezosos** y por qué su canal de error está fijado a un único tipo. +- Construir, transformar, combinar y ejecutar pipelines sobre ambos publishers, y + leer el `Result, _>` que devuelve `.block().await`. +- Recuperarte de errores con `on_error_*` / `retry_backoff`, y empujar valores de + forma imperativa a un `Flux` con un `FluxSink`. +- Mover trabajo entre hilos con un `Scheduler` (`subscribe_on` / `publish_on`). +- Convertir un `Mono`/`Flux` en una respuesta HTTP con los responders reactivos de + Firefly (`MonoJson`, `NdJson`, `Sse`), y rastrear cómo los usa el endpoint de + streaming de Lumen. +- Ver cómo esos mismos dos tipos atraviesan el `WebClient` reactivo, los + repositorios, EDA y el bus de CQRS. + +## Conceptos que conocerás + +Antes del primer pipeline, aquí tienes las ideas sobre las que se apoya este +capítulo. Cada una se reintroduce en contexto allí donde se usa por primera vez; +esta es la versión breve. + +> **Note** **Término clave — publisher reactivo.** Un *publisher* es un valor que +> *describe* una computación que produce datos a lo largo del tiempo, sin +> ejecutarla todavía. Encadenas operadores sobre él para construir un pipeline y +> luego te *suscribes* para hacerlo ejecutar. El análogo en Spring es un +> `Publisher` de Project Reactor (`Mono` / `Flux`), el motor que hay detrás de +> Spring WebFlux. El `Mono` y el `Flux` de Firefly son la escritura en Rust de +> exactamente esos. + +> **Note** **Término clave — perezoso (lazy).** Un publisher es *perezoso* cuando +> construir el pipeline no realiza ningún trabajo; el trabajo se ejecuta solo +> cuando te suscribes, bloqueas o haces await. Esto es lo contrario de un `Future` +> ejecutado con avidez que arranca en el momento en que se crea en algunos +> runtimes — un `Mono` al que nunca te suscribes nunca se ejecuta. + +> **Note** **Término clave — contrapresión (backpressure).** La *contrapresión* +> es el mecanismo por el que un consumidor lento estrangula a un productor rápido +> para que los datos no se acumulen en memoria. Un `Flux` respeta la contrapresión +> de extremo a extremo: un cliente HTTP lento que consume un cuerpo `NdJson` en +> streaming realmente ralentiza al productor que lo alimenta, en lugar de +> bufferizar todo el stream por adelantado. + +## Paso 1 — Conoce los dos publishers + +El núcleo reactivo de Firefly son dos tipos, distinguidos por su **cardinalidad** +— cuántos valores pueden emitir: + +- **`Mono`** — un productor de *como mucho un* valor (0 o 1, más un error + terminal). El análogo reactivo de «una función async que devuelve un `T`». +- **`Flux`** — un productor de *0..N* valores más una finalización-o-error + terminal. El análogo reactivo de «un stream async de `T`». + +Ambos son **perezosos**: construir un pipeline no hace nada; el trabajo se ejecuta +solo cuando te suscribes, bloqueas o haces await. Ambos son `Send + 'static`, de +modo que un `Mono` o un `Flux` encaja directamente en un handler de axum sin +ningún envoltorio. + +> **Note** **Término clave — señal terminal.** Un pipeline termina con exactamente +> una *señal terminal*: un `Flux` completa tras su último valor (o sin valores), y +> cualquiera de los dos publishers puede terminar antes de tiempo con un +> **error**. En `firefly-reactive` el tipo de error está fijado a +> `firefly_kernel::FireflyError`. Fijar el error mantiene ergonómica la superficie +> de operadores — no hay un parámetro de tipo de error que haya que enhebrar por +> cada `map` — y se conecta directamente con las respuestas de problema RFC 9457 +> del framework, de modo que un pipeline fallido se convierte gratis en un cuerpo +> `application/problem+json`. + +Teclea lo siguiente en un test (`#[tokio::test] async fn`) para ver ambas formas +ejecutarse hasta completarse: + +```rust +use firefly_reactive::{Flux, Mono}; + +# async fn ex() { +// Mono: one value, lazily transformed, then awaited. +let n = Mono::just(20) + .map(|x| x + 1) + .filter(|x| *x > 10) + .default_if_empty(0) + .block() + .await + .unwrap(); +assert_eq!(n, Some(21)); + +// Flux: a stream of values, filtered + mapped, collected to a Vec. +let xs = Flux::range(1, 5) + .filter(|x| x % 2 == 1) + .map(|x| x * 10) + .collect_list() + .block() + .await + .unwrap() // Result -> Option + .unwrap(); // Option -> Vec (collect_list always yields a list) +assert_eq!(xs, vec![10, 30, 50]); +# } +``` + +Qué acaba de pasar, bloque a bloque: + +- El pipeline del `Mono` empieza con `Mono::just(20)`, luego hace `map`, `filter` + y aporta un `default_if_empty(0)` por si el filtro rechazara el valor. Nada de + eso se ejecutó hasta `.block().await`. El resultado es `Some(21)`: sobrevivió un + valor. +- El pipeline del `Flux` recorre `1..=5`, conserva los números impares, multiplica + cada uno por diez, y `collect_list` pliega todo el stream en un único `Vec`. + Como `collect_list` devuelve un `Mono>`, ejecutarlo produce + `Ok(Some(vec))`. + +> **Warning** `Mono::block()` es `async`: pese al nombre, nunca aparca un worker de +> Tokio. Resuelve el publisher en el sitio y devuelve +> `Result, FireflyError>`, de modo que `.block().await` es la forma +> idiomática de ejecutar un pipeline hasta completarlo. Las dos capas que devuelve +> son deliberadas — el `Result` exterior es éxito-o-error, el `Option` interior es +> valor-o-vacío. + +> **Tip** **Punto de control.** Mete ambos fragmentos en un `#[tokio::test]` y +> ejecuta `cargo test`. Los dos `assert_eq!` pasan: `n == Some(21)` y +> `xs == vec![10, 30, 50]`. Has ejecutado tus primeros pipelines perezosos — y has +> visto la forma `Result, _>` que `.block().await` devuelve siempre. + +### Leer el tipo de retorno + +Todo lo que ejecuta un `Mono` hasta completarlo devuelve +`Result, FireflyError>`. Las tres capas cargan cada una con un hecho, y +leerlas es una habilidad que usarás en todos los capítulos posteriores: + +| Resultado | Qué significa | +|------------------------|----------------------------------------------------------| +| `Ok(Some(v))` | el pipeline produjo el valor `v` | +| `Ok(None)` | el pipeline completó **vacío** (`Mono::empty`, un `filter` que rechazó todo) | +| `Err(FireflyError)` | el pipeline alcanzó un **error terminal** y cortocircuitó | + +Un operador terminal de `Flux` (`collect_list`, `reduce`, `count`, …) devuelve un +`Mono`, así que sigue la misma regla — razón por la que +`collect_list().block().await` desenvuelve dos veces en el ejemplo de arriba. + +## Paso 2 — Crear publishers + +Un pipeline empieza en un *constructor*. Echarás mano de un puñado constantemente; +el resto están ahí para cuando un caso límite los necesite. + +Constructores de `Mono`: + +
+ +Mono<T> +0 or 1 item, then complete + + + + +just(v) +complete + +Flux<T> +0..N items, then complete + + + + + + + + +map · filter · flat_map + +
Los dos tipos de retorno reactivos. Un Mono<T> emite como mucho un elemento (Ok(Some)), o ninguno (Ok(None)), y luego completa; un Flux<T> emite un stream de cero o más elementos. Ambos cortocircuitan ante un Err(FireflyError) terminal.
+
+ +| Constructor | Produce | +|-----------------------------------|---------------------------------------------------------| +| `Mono::just(v)` | exactamente `v` | +| `Mono::just_or_empty(opt)` | `v` si es `Some`, vacío si es `None` | +| `Mono::empty()` | completa sin valor (`Ok(None)`) | +| `Mono::error(e)` | un error terminal | +| `Mono::from_future(fut)` | hace await de un `Future` | +| `Mono::from_result_future(fut)` | hace await de un `Future>` | +| `Mono::from_callable(f)` | ejecuta un `FnOnce() -> Result, FireflyError>` al suscribir | +| `Mono::defer(factory)` | construye el `Mono` de nuevo por cada suscripción | + +Constructores de `Flux`: + +| Constructor | Produce | +|-----------------------------------|------------------------------------------------| +| `Flux::just(vec)` | cada elemento del `Vec` | +| `Flux::from_iter(iter)` | cada elemento de un iterador | +| `Flux::range(start, count)` | `start, start+1, …` (count elementos) | +| `Flux::empty()` / `Flux::never()` | completa de inmediato / nunca emite | +| `Flux::error(e)` | un error terminal | +| `Flux::from_stream(s)` | un `Stream>` | +| `Flux::from_value_stream(s)` | un `Stream` | +| `Flux::create(producer)` | push imperativo mediante un `FluxSink` (Paso 5) | +| `Flux::interval(period)` | `0, 1, 2, …` sobre un temporizador | +| `Flux::generate(seed, step)` | generación con estado | + +Qué acaba de pasar: `Mono::just` / `Flux::just` son los constructores literales +que más usarás. `from_future` / `from_result_future` son el puente desde el Rust +`async` hacia el mundo reactivo — el mismo puente que el bus de CQRS usa +internamente para envolver un dispatch en un `Mono`. `defer` y `from_callable` +importan para el **retry**, porque construyen el trabajo *de nuevo en cada +suscripción* (Paso 4). + +> **Note** **Término clave — publisher frío (cold).** Todos estos son *fríos*: el +> trabajo se rehace para cada suscriptor, comenzando en el momento de la +> suscripción, como llamar a una función de nuevo. (El opuesto, un publisher +> *caliente* o *hot*, comparte una única fuente en ejecución entre los +> suscriptores — `Mono::cache` convierte un `Mono` frío en uno que recuerda su +> resultado.) El ser frío por defecto es lo que hace posible el `retry`: un retry +> es simplemente otra suscripción. + +## Paso 3 — Transformar, combinar y terminar + +`Mono` y `Flux` comparten la mayoría de los nombres de operadores; las diferencias +reflejan la cardinalidad. Este es el conjunto de trabajo — tenlo a mano, no lo +memorizarás de una sola lectura: + +| Categoría | Mono | Flux | +|-------------|----------------------------------------------------------------------|--------------------------------------------------------------------------------------------| +| transformar | `map` `map_async` `flat_map` `flat_map_many` `filter` | `map` `map_async` `flat_map(n)` `concat_map` `filter` `scan` `index` `flat_map_iterable` | +| reduce/term | `then` `then_return` `zip_with` | `reduce` `collect_list` `collect_map` `count` `all` `any` `then` `last` `next` `single` `element_at` | +| limit/slice | — | `take` `take_while` `take_last` `skip` `skip_while` `distinct` `distinct_until_changed` | +| combinar | `when` `zip` | `merge_with` `concat_with` `zip_with` `combine_latest` `start_with` `switch_if_empty` `default_if_empty` | +| error | `on_error_return` `on_error_resume` `on_error_map` `retry` `retry_backoff` | `on_error_resume` `on_error_continue` `retry` `retry_backoff` | +| tiempo | `timeout` `delay_element` | `timeout` `delay_elements` `sample` `debounce` `interval` | +| backpressure| — | `on_backpressure_buffer` `on_backpressure_drop` `on_backpressure_latest` `limit_rate` | +| window | — | `buffer` `window` `group_by` | +| side-effect | `do_on_next` `do_on_success` `do_on_error` `do_on_finally` | `do_on_next` `do_on_complete` `do_on_error` `do_on_finally` | +| schedule | `subscribe_on` `publish_on` | `subscribe_on` `publish_on` | +| cache/view | `cache` `as_flux` | — | + +La única distinción que vale la pena interiorizar ahora es `map` frente a +`flat_map`. `map` transforma cada valor con una función corriente (`T -> U`). +`flat_map` transforma cada valor en *otro publisher* y aplana el resultado — así +es como encadenas un paso reactivo dependiente sobre uno anterior. + +```rust +use firefly_reactive::{Flux, Mono}; + +# async fn ex() { +// flat_map: chain a Mono onto the result of another (a sequential dependency). +let total = Mono::just(3) + .flat_map(|seed| Mono::just(seed * 10)) + .map(|x| x + 1) + .block() + .await + .unwrap(); +assert_eq!(total, Some(31)); + +// flat_map on a Flux runs up to N inner publishers concurrently; the first +// argument is that concurrency bound. +let doubled = Flux::range(1, 3) + .flat_map(2, |n| Mono::just(n * 2).as_flux()) + .collect_list() + .block() + .await + .unwrap() + .unwrap(); +assert_eq!(doubled.len(), 3); +# } +``` + +Qué acaba de pasar: + +- En el `Mono`, `flat_map(|seed| Mono::just(seed * 10))` toma el `3`, produce un + `Mono` nuevo (`30`) y lo aplana de modo que el siguiente `map` ve `30`. Esta es + la escritura reactiva de «haz A, luego usa el resultado de A para hacer B». +- En el `Flux`, `flat_map(2, ..)` es la misma idea desplegada en abanico: cada uno + de los tres valores de origen se convierte en un publisher interno, y hasta + **2** de ellos se ejecutan a la vez. `.as_flux()` eleva el `Mono` interno a un + `Flux` para que las firmas cuadren. + +Para ejecutar dos pipelines independientes y combinar sus resultados, usa `zip` +(la función libre) — ambos se ejecutan, y luego sus salidas se emparejan en una +tupla: + +```rust +use firefly_reactive::{zip, Mono}; + +# async fn ex() { +// zip two Monos into a tuple — both run, then combine. +let pair = zip(Mono::just("alice"), Mono::just(42)) + .block() + .await + .unwrap(); +assert_eq!(pair, Some(("alice", 42))); +# } +``` + +> **Tip** **Punto de control.** Ejecuta los tres fragmentos en un test. Deberías +> ver `total == Some(31)`, `doubled.len() == 3` y `pair == Some(("alice", 42))`. +> Si echas mano de `flat_map` en un `Flux` y el compilador se queja de los +> argumentos, recuerda que la forma de Flux toma primero el límite de +> concurrencia. + +## Paso 4 — Manejar errores y reintentar + +Un elemento `Err` es **terminal** en un `Flux`: cada operador cortocircuita en el +primer error y lo propaga aguas abajo — no hay canal de error por elemento. Una +vez que se dispara un error, no fluye ningún valor posterior. Para recuperarte, +eliges un operador de recuperación: + +- `Mono::on_error_return(fallback)` — sustituye un valor. +- `Mono::on_error_resume(f)` / `Flux::on_error_resume(f)` — cambia a un publisher + de respaldo, conservando los elementos emitidos antes del error. +- `Flux::on_error_continue(handler)` — descarta el elemento que falla y conserva + el resto (para operadores que reseñalan por elemento). +- `Mono::on_error_map(f)` — traduce el error a un `FireflyError` distinto. + +> **Note** **Término clave — factory de retry.** `retry` y `retry_backoff` no +> pueden reejecutar un publisher existente, porque un stream o un future de Rust es +> *de un solo uso* — una vez consumido, desaparece. Por eso toman un **closure +> factory** que construye el publisher *de nuevo* para cada intento. Cada retry es +> una suscripción totalmente nueva a un publisher totalmente nuevo. El análogo en +> Spring es el `Retry.backoff(..)` de Reactor. + +`Backoff::new(max_retries, base_delay)` describe el calendario. Aquí una fuente +inestable falla sus dos primeros intentos y tiene éxito en el tercero: + +```rust +use std::time::Duration; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use firefly_reactive::{Backoff, Mono}; +use firefly_kernel::FireflyError; + +# async fn ex() { +let calls = Arc::new(AtomicUsize::new(0)); +let c = calls.clone(); +let value = Mono::retry_backoff( + move || { + let c = c.clone(); + Mono::from_callable(move || { + let n = c.fetch_add(1, Ordering::SeqCst); + if n < 2 { Err(FireflyError::internal("flaky")) } else { Ok(Some(n)) } + }) + }, + Backoff::new(5, Duration::from_millis(10)), +) +.block() +.await +.unwrap(); +assert_eq!(value, Some(2)); +# } +``` + +Qué acaba de pasar: el `move || { … }` exterior es la factory — `retry_backoff` la +llama una vez por intento. Dentro, `Mono::from_callable` ejecuta el trabajo falible +cuando se suscribe. El `AtomicUsize` compartido cuenta los intentos: las llamadas +`0` y `1` devuelven `Err`, así que `retry_backoff` espera (10 ms, y luego un +backoff creciente) y se vuelve a suscribir; la llamada `2` devuelve `Ok(Some(2))`, +que se convierte en el resultado. El tope `Backoff::new(5, …)` significa que se +rendiría tras cinco reintentos. + +Los plazos también son errores. `Mono::timeout` / `Flux::timeout` mapean un plazo +incumplido a un `FireflyError` 504 (código `REACTIVE_TIMEOUT`), que se renderiza +como una respuesta de problema RFC 9457 — la misma ruta de respuesta que cualquier +otro error terminal. + +> **Tip** **Punto de control.** Ejecuta el fragmento de retry. Afirma +> `value == Some(2)`: la fuente falló dos veces y la tercera suscripción tuvo +> éxito. Cambia el umbral de `n < 2` a `n < 9` y observa cómo el pipeline agota sus +> cinco reintentos y aflora el `Err` en su lugar. + +## Paso 5 — Emitir de forma imperativa con `FluxSink` + +Los constructores del Paso 2 cubren las fuentes declarativas. Cuando los valores +llegan desde un callback, un canal o un bucle imperativo, empújalos a un `Flux` +con `Flux::create` y un `FluxSink`. + +> **Note** **Término clave — `FluxSink`.** Un `FluxSink` es el handle de push que +> se te entrega dentro de `Flux::create`. Llama a `sink.next(v)` para emitir un +> valor, `sink.error(e)` para terminar con un error y `sink.complete()` para +> finalizar el stream. Es el análogo en Rust del `FluxSink` de Reactor de +> `Flux.create(..)`. + +```rust +use firefly_reactive::Flux; + +# async fn ex() { +let flux = Flux::create(|sink| { + for i in 1..=3 { + sink.next(i); + } + sink.complete(); +}); +let out = flux.collect_list().block().await.unwrap(); +assert_eq!(out, Some(vec![1, 2, 3])); +# } +``` + +Qué acaba de pasar: `Flux::create` entrega a tu closure un `sink`. El bucle emite +`1, 2, 3` con `sink.next`, luego `sink.complete()` cierra el stream para que +`collect_list` sepa que ha terminado. Así es como adaptas un productor no reactivo +—por ejemplo, un cursor de base de datos o un SDK basado en callbacks— a un `Flux` +sin reescribirlo. + +> **Tip** **Punto de control.** El test afirma `out == Some(vec![1, 2, 3])`. +> Olvida el `sink.complete()` y el stream nunca termina — `collect_list` esperaría +> para siempre. Con `create`, la finalización es responsabilidad tuya. + +## Paso 6 — Mover trabajo entre hilos con un `Scheduler` + +Por defecto, un pipeline se ejecuta allí donde lo suscribiste. Un `Scheduler` te +permite mover el trabajo a un contexto de ejecución distinto —el pool de workers de +Tokio, un pool de bloqueo o en línea— sin reestructurar el pipeline. + +> **Note** **Término clave — `Scheduler`.** Un `Scheduler` decide *dónde* se +> ejecuta el trabajo. `Scheduler::Immediate` se ejecuta en línea sobre la tarea +> actual (sin salto); `Scheduler::Parallel` se ejecuta sobre el pool de workers de +> Tokio, para trabajo limitado por CPU; `Scheduler::BoundedElastic` ejecuta +> llamadas bloqueantes en un pool aparte para que nunca dejen sin recursos al pool +> de workers. Estos reflejan los `Schedulers.immediate()`, `.parallel()` y +> `.boundedElastic()` de Reactor. + +Dos operadores aplican un scheduler. `subscribe_on` salta la **fuente** a un +scheduler: + +```rust +use firefly_reactive::{Flux, Scheduler}; + +# async fn ex() { +let out = Flux::range(1, 3) + .subscribe_on(Scheduler::Parallel) // run the source on the Tokio worker pool + .map(|x| x * 2) + .collect_list() + .block() + .await + .unwrap(); +assert_eq!(out, Some(vec![2, 4, 6])); +# } +``` + +`publish_on` cambia el hilo para todo lo que está **aguas abajo** de él, de modo +que el punto de descarga puede situarse en cualquier lugar de la cadena — una +fuente barata puede saltar a un hilo de worker justo antes de un `map` costoso: + +```rust +use firefly_reactive::{Flux, Scheduler}; + +# async fn ex() { +let out = Flux::range(1, 3) + .map(|x| x + 1) // runs wherever the subscribe happens + .publish_on(Scheduler::Parallel) // everything below hops to the worker pool + .map(|x| x * 10) // runs on the Tokio worker pool + .collect_list() + .block() + .await + .unwrap(); +assert_eq!(out, Some(vec![20, 30, 40])); +# } +``` + +Qué acaba de pasar: `subscribe_on` eligió dónde se ejecuta la *fuente* (toda la +cadena de arriba la siguió hasta `Parallel`); `publish_on` partió la cadena en dos +— el primer `map` se ejecutó en el sitio de la suscripción, el segundo se ejecutó +en el pool de workers. La regla práctica: echa mano de `subscribe_on` para situar +una *fuente* bloqueante o limitada por CPU, y de `publish_on` para descargar una +etapa *aguas abajo* costosa. + +> **Tip** **Punto de control.** Ambos fragmentos afirman sus resultados +> recolectados (`[2, 4, 6]` y `[20, 30, 40]`). Los valores son idénticos a +> ejecutar sin un scheduler — los schedulers cambian *dónde* se ejecuta el trabajo, +> nunca *qué* computa. + +## Paso 7 — Convertir un publisher en una respuesta HTTP + +Aquí es donde el núcleo reactivo se encuentra con la capa web, y donde Lumen lo +usará. `firefly-web` incluye responders que convierten un `Mono`/`Flux` en una +respuesta de axum: un handler reactivo simplemente devuelve uno de ellos, y el +responder conduce el publisher y escribe la respuesta. Usan el formato de cable +estable de `firefly-sse`, de modo que cualquier cliente que hable NDJSON o SSE los +consume directamente. + +| Responder | Comportamiento | +|--------------------------|-----------------------------------------------------------| +| `MonoJson(Mono)` | `Ok(Some)` → 200 JSON; `Ok(None)` → 404 problem+json; `Err` → la respuesta RFC 9457 de ese error | +| `NdJson(Flux)` | `application/x-ndjson`, un elemento por línea, con contrapresión | +| `Sse(Flux)` | `text/event-stream`, un frame `data:` por elemento | +| `SseEvents(Flux)` | `text/event-stream` con control completo de `id` / `event` / `retry` | + +```rust,no_run +use axum::{routing::get, response::IntoResponse, Router}; +use firefly_reactive::{Flux, Mono}; +use firefly_web::{MonoJson, NdJson, Sse}; + +async fn one_order() -> impl IntoResponse { + // Ok(Some) -> 200 application/json; Ok(None) -> 404 problem+json; + // Err -> that error's problem response. + MonoJson(Mono::just(serde_json::json!({ "id": "o1" }))) +} + +async fn stream_orders() -> impl IntoResponse { + // application/x-ndjson, one line per element, backpressured. + NdJson(Flux::just(vec![1, 2, 3])) +} + +async fn live_orders() -> impl IntoResponse { + // text/event-stream, one `data:` frame per element. + Sse(Flux::just(vec![1, 2, 3])) +} + +let app: Router = Router::new() + .route("/orders/one", get(one_order)) + .route("/orders", get(stream_orders)) + .route("/orders/live", get(live_orders)); +``` + +Qué acaba de pasar, responder a responder: + +- **`MonoJson(Mono)`** resuelve el `Mono`: `Ok(Some)` → `200` + `application/json`; `Ok(None)` → `404` `application/problem+json`; `Err` → la + respuesta de problema de ese error. El `Mono` vacío convirtiéndose en un 404 + limpio es exactamente el `Result, _>` de tres capas del Paso 1 mapeado + sobre HTTP. +- **`NdJson(Flux)`** transmite `application/x-ndjson` — un documento JSON + compacto más `'\n'` por elemento, vaciado de forma incremental con contrapresión + real. El `Stream` del `Flux` se enlaza directamente con un cuerpo de streaming de + axum; el stream completo **nunca** se bufferiza. Un elemento `Err` a mitad del + stream termina el cuerpo de forma limpia. +- **`Sse(Flux)`** transmite `text/event-stream` — cada elemento serializado en + un frame `data: \n\n` pelado, idéntico byte a byte al writer de + `firefly-sse`. +- **`SseEvents(Flux)`** transmite valores `firefly_sse::Event` preconstruidos + — úsalo cuando necesites control sobre los campos `id` / `event` / `retry`. + +> **Warning** Aquí la contrapresión es real, no cosmética. Un cliente lento +> estrangula al productor; nada se bufferiza por adelantado. Esto es lo que permite +> a un endpoint `NdJson` transmitir un millón de filas sin que la respuesta aterrice +> nunca por completo en memoria. + +### Cómo lo usa Lumen + +El endpoint opcional `GET /api/v1/wallets/:id/events` de Lumen tiene exactamente +esta forma. Reproduce el stream de eventos persistidos de un wallet como un +`Flux` y se lo entrega a `NdJson` (o a `Sse` con `?format=sse`). El +handler completo —tomado verbatim de `samples/lumen/src/web.rs`, protegido por la +feature `streaming`— son los responders de arriba aplicados al dominio de wallets: + +```rust,ignore +// samples/lumen/src/web.rs — the reactive streaming handler (feature `streaming`). +#[cfg(feature = "streaming")] +async fn stream_events( + State(api): State, + Path(id): Path, + axum::extract::Query(params): axum::extract::Query, +) -> Response { + use crate::domain::WalletEvent; + use axum::response::IntoResponse; + use firefly::reactive::Flux; + use firefly::web::{NdJson, Sse}; + + // `load_events` returns `Err(NotFound)` for an absent wallet, so the 404 is + // decided before the streaming response head is committed. + let events = match api.ledger.load_events(&id).await { + Ok(events) => events, + Err(e) => return WebError::from(domain_to_web(e)).into_response(), + }; + let items: Vec = events.iter().map(WalletEvent::from_domain).collect(); + let flux = Flux::just(items); + if params.format.as_deref() == Some("sse") { + Sse(flux).into_response() + } else { + NdJson(flux).into_response() + } +} +``` + +Dos detalles que vale la pena llevarse. Primero, la decisión de *no encontrado* +ocurre **antes** de construir el `Flux`, de modo que un 404 sigue renderizándose +como una respuesta de problema limpia en vez de un stream entreabierto. Segundo, +Lumen alcanza los tipos reactivos a través de la fachada de una sola dependencia — +`firefly::reactive::Flux` y `firefly::web::{NdJson, Sse}`, nunca los crates +subyacentes `firefly-reactive` / `firefly-web`. El endpoint completo, incluido el +cableado de rutas, vuelve en [Producción y despliegue](./20-production.md). + +> **Note** A lo largo del resto del libro, Lumen alcanza los tipos reactivos a +> través de la fachada — `firefly::reactive::*` para `Mono`/`Flux` y +> `firefly::web::*` para los responders. Los ejemplos de *este* capítulo importan +> `firefly_reactive` / `firefly_web` directamente para que cada fragmento se +> sostenga por sí solo, pero las dos rutas nombran tipos idénticos: +> `firefly::reactive` reexporta `firefly_reactive`, y `firefly::web` reexporta +> `firefly_web`. + +## Paso 8 — Rastrea esos mismos dos tipos por el resto del framework + +`Mono` y `Flux` no son una comodidad solo para la web; son la columna vertebral +de la que cuelga todo el framework. Encontrarás cada uno de estos en su propio +capítulo, pero ver el hilo conductor ahora hace que esos capítulos encajen. + +**El `WebClient` reactivo.** El cliente HTTP reactivo de Firefly devuelve sus +operadores terminales como `Mono` / `Flux`, de modo que una llamada saliente entra +directamente en un pipeline reactivo y se compone de extremo a extremo con los +responders `NdJson` / `Sse` de arriba. Tratamiento completo en +[Clientes HTTP](./13-http-clients.md); la forma: + +```rust,no_run +use firefly_client::WebClientBuilder; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Order { id: String } +#[derive(Deserialize)] +struct Tick { seq: u64 } + +# async fn ex() { +let client = WebClientBuilder::new("https://api.example.com").build(); + +// body_to_mono — the whole body decoded as one T. +let _order: firefly_reactive::Mono = + client.get().uri("/orders/o1").retrieve().body_to_mono::(); + +// body_to_flux — a streamed NDJSON/SSE body decoded element-by-element, +// lazily and with backpressure. +let _ticks: firefly_reactive::Flux = client + .get() + .uri("/ticks") + .header("Accept", "application/x-ndjson") + .retrieve() + .body_to_flux::(); +# } +``` + +> **Note** El cliente **no** trae retry incorporado. Compón `Mono::retry` / +> `Mono::retry_backoff` (Paso 4) sobre el publisher devuelto, de modo que la +> política de retry viva en el sitio de la llamada, que es donde corresponde, en +> lugar de oculta dentro del cliente. + +**Repositorios.** `ReactiveCrudRepository` devuelve `Mono`/`Flux`; los +adaptadores SQL transmiten las filas fuera de `find_all()` como un `Flux` para que +una tabla enorme nunca aterrice por completo en memoria. Véase +[Persistencia](./07-persistence.md). + +**EDA.** `InMemoryBroker::subscribe_reactive(topic)` produce un `Flux` +(dentro de un `EdaResult`), y `publish_mono(event)` es una publicación reactiva fría +que devuelve `Mono<()>`. El ledger de Lumen publica cada evento de wallet en un +`Broker`; véase [EDA](./10-eda-messaging.md). + +**CQRS.** `Bus::send_mono` / `Bus::query_mono` envuelven el dispatch en un +`Mono` perezoso, ejecutando *la misma* búsqueda de handler y cadena de +middleware que el `Bus::send` síncrono. Los comandos de wallet de Lumen viajan por +este bus; véase [CQRS](./09-cqrs.md). Un aperitivo — la forma que toma una consulta +`GetWallet` compuesta reactivamente (ambos métodos toman `&Arc` para que el +`Mono` perezoso pueda ser dueño del bus): + +```rust,ignore +use std::sync::Arc; +use firefly::cqrs::Bus; + +// `send_mono` / `query_mono` take `&Arc` so the lazy Mono can own the bus. +let bus: Arc = /* the WebStack's bus */; +let balance = bus + .query_mono::<_, WalletView>(GetWallet { id: wallet_id }) + .map(|view| view.balance) + .block() + .await?; // Ok(Some()) +``` + +> **Note** Como `firefly-reactive` fija su canal de error a `FireflyError`, un +> dispatch fallido se mapea desde el `CqrsError` del bus a un `FireflyError` fiel +> al estado (validación → 422, autorización → 403, handler ausente → 500) con el +> error original preservado como `source()` — de modo que un comando reactivo fluye +> directamente hacia la pila de problemas RFC 9457 sin traducción adicional. + +## Paso 9 — Interoperar con `Stream` / `Future` en crudo + +Los tipos reactivos no son un jardín amurallado. Convierte hacia dentro y hacia +fuera en los bordes para que un `Mono`/`Flux` pueda envolver (o ser envuelto por) +Rust async corriente: + +- **Hacia dentro:** `Flux::from_stream` (un `Stream>`), + `Flux::from_value_stream` (un `Stream`), `Mono::from_future`, + `Mono::from_result_future`. +- **Hacia fuera:** `Flux::to_stream` / `Flux::into_stream`, `Mono::into_future` (o + simplemente haz `.await` del `Mono` directamente — un `Mono` es en sí mismo + awaitable). + +Qué acaba de pasar: estas son las costuras que te permiten adoptar el núcleo +reactivo de forma incremental. Un `Stream` existente se convierte en un `Flux` al +que puedes aplicar operadores de contrapresión y recuperación; un `Mono` se +convierte en un `Future` corriente en el momento en que alguna otra API quiere uno. + +## Resumen + +Ya manejas el vocabulario sobre el que se construye el resto del libro: + +- **Dos publishers, por cardinalidad.** `Mono` produce 0 o 1 valor; `Flux` + produce 0..N. Ambos son **perezosos** y **fríos**: nada se ejecuta hasta que te + suscribes, bloqueas o haces await, y cada suscripción rehace el trabajo. +- **Un único canal de error fijo.** Cada error terminal es un + `firefly_kernel::FireflyError`, razón por la que los pipelines se conectan + directamente con las respuestas de problema RFC 9457 sin fontanería de tipos de + error. +- **`.block().await` devuelve `Result, FireflyError>`** — éxito/error en + el exterior, valor/vacío en el interior. Un operador terminal de `Flux` devuelve + un `Mono`, así que se lee igual. +- **La recuperación es explícita.** `on_error_return` / `on_error_resume` / + `on_error_continue` / `on_error_map` recuperan; `retry` / `retry_backoff` toman + una **factory** porque los publishers son de un solo uso; `timeout` mapea un + plazo a un `FireflyError` 504. +- **`Flux::create` + `FluxSink`** empujan valores de forma imperativa; un + `Scheduler` (`subscribe_on` / `publish_on`) mueve el trabajo entre en línea, el + pool de workers y el pool de bloqueo. +- **Los responders web** `MonoJson`, `NdJson`, `Sse` y `SseEvents` convierten un + publisher en una respuesta HTTP, con contrapresión real en los de streaming. +- **Esos mismos dos tipos atraviesan todo** — el `WebClient` reactivo, + `ReactiveCrudRepository`, el broker de EDA y `Bus::send_mono` / `query_mono`. + +Qué significa esto para Lumen: en este capítulo no aterrizó ningún archivo fuente, +pero Lumen ya tiene los dos publishers a partir de los cuales se construye cada +superficie reactiva que toca — el `Flux` que hay detrás de su endpoint +de streaming, y el `Mono` que hay detrás de su bus de comandos/consultas. + +## Ejercicios + +1. **Mapea un saldo.** Construye `Mono::just(1_250_i64)` (un saldo en céntimos), + `map`éalo a un `f64` en unidad mayor (`cents as f64 / 100.0`) y hazle + `block().await`. Confirma que obtienes `Some(12.5)`. + +2. **Transmite eventos de wallet como un `Flux`.** Crea un `Vec` de deltas de + saldo con signo (`[1000, 50, -25]`), envuélvelo con `Flux::just`, haz `scan` de + un saldo acumulado y `collect_list`. Verifica que los saldos acumulados son + `[1000, 1050, 1025]` — una versión hecha a mano de lo que el endpoint de + streaming de Lumen emite por evento. + +3. **Recupérate de una fuente inestable.** Escribe un `Mono::from_callable` que + devuelva `Err(FireflyError::internal("flaky"))` las dos primeras veces y + `Ok(Some(n))` después, luego envuélvelo en `Mono::retry_backoff(factory, + Backoff::new(5, Duration::from_millis(10)))`. Afirma que se resuelve a un valor — + el patrón de retry-factory que el cliente HTTP de Lumen usaría contra un + proveedor externo de FX. + +4. **Elige un responder.** Dado un `Flux`, decide qué responder quiere + un dashboard en tiempo real (`Sse`) frente a una exportación masiva (`NdJson`), + y explica en una frase por qué la contrapresión importa en el caso de la + exportación. + +5. **Empuja y luego completa.** Usa `Flux::create` para emitir `1..=5` con + `sink.next`, pero *omite* `sink.complete()`. Ejecútalo bajo un `Mono::timeout` + de unos pocos cientos de milisegundos y observa el `FireflyError` 504 — luego + añade el `complete()` y míralo pasar limpiamente. Por esto la finalización es + responsabilidad tuya con `create`. + +## Adónde ir después + +- Pon estos publishers a trabajar detrás de rutas reales en + **[Tu primera API HTTP](./06-first-http-api.md)** — el primer capítulo que + devuelve un `Mono`/`Flux` desde un handler de Lumen. +- Ve cómo `Flux` transmite filas fuera de la base de datos en + **[Persistencia](./07-persistence.md)** mediante `ReactiveCrudRepository`. +- Compón `Bus::send_mono` / `Bus::query_mono` en pipelines de wallet en + **[CQRS](./09-cqrs.md)**. +- Suscríbete a un `Flux` y haz `publish_mono` de eventos de wallet en + **[EDA y mensajería](./10-eda-messaging.md)**. diff --git a/docs/book/src-es/06-first-http-api.md b/docs/book/src-es/06-first-http-api.md new file mode 100644 index 00000000..bfd1f556 --- /dev/null +++ b/docs/book/src-es/06-first-http-api.md @@ -0,0 +1,786 @@ +# Tu primera API HTTP + +Hasta ahora Lumen compila, arranca, imprime un banner y sirve un actuator, pero +no tiene endpoints propios. También sabes, por +[Cableado de dependencias](./04-dependency-wiring.md), cómo el framework descubre +y cablea los beans que escanea. Este es el capítulo en el que Lumen deja de ser un +banner y empieza a ser un *servicio*: le das una superficie HTTP real, declarada +con una macro, montada por ti, y demostrada por un test que ejercita el router +completo sin llegar nunca a vincular un socket. + +La capa HTTP por debajo es [axum](https://docs.rs/axum). Firefly no la oculta — +escribes handlers de axum corrientes—, pero *añade* la macro de controlador, el +renderizado de problemas y el middleware de correlación/idempotencia que conociste +en el [Inicio rápido](./02-quickstart.md). Escribes dos handlers; el framework +aporta el cableado y monta el controlador. + +Al terminar este capítulo, serás capaz de: + +- Declarar un controlador REST como un único bean de DI cuyos colaboradores se + autocablean, usando `#[derive(Controller)]` y `#[rest_controller]`. +- Mapear dos verbos —`POST /api/v1/wallets` y `GET /api/v1/wallets/:id`— a métodos + handler, y comprender cómo la macro compone las rutas. +- Devolver una vista `serde` simple (`WalletView`) y convertir errores tipados en + documentos RFC 9457 `application/problem+json` con el estado HTTP correcto. +- Entender *por qué* nunca llamas a `mount`: que añadir el bean del controlador *es* + montarlo. +- Ejercitar el router completamente cableado en proceso con `tower::oneshot`, sin + servidor activo y sin ningún puerto por el que competir. + +## Conceptos que conocerás + +Antes de la primera línea de código, aquí están las ideas en las que se apoya este +capítulo. Cada una se reintroduce en contexto allí donde se usa por primera vez; +esta es la versión breve. + +> **Note** **Término clave — controlador.** Un *controlador* es el objeto que posee +> un grupo de endpoints HTTP. Sus métodos son los *handlers*, uno por cada mapeo de +> verbo y ruta. En Firefly un controlador es simplemente un bean con un bloque +> `impl` anotado; el framework lee las anotaciones y construye la tabla de +> enrutamiento. El análogo en Spring es un `@RestController`. + +> **Note** **Término clave — handler / extractor.** Un *handler* es la función +> asíncrona que se ejecuta para una ruta. Un *extractor* es un tipo de argumento que +> extrae por ti una parte de la petición: el id de la ruta, el cuerpo JSON, un objeto +> de consulta. Estos son los propios extractores de axum (`Path`, `Json`, `State`); +> Firefly los reutiliza y añade algunos propios. + +> **Note** **Término clave — documento de problema RFC 9457.** El RFC 9457 (que deja +> obsoleto al RFC 7807) define `application/problem+json`: un sobre JSON pequeño y +> estándar para errores HTTP con los campos `type`, `title`, `status` y `detail`. +> Firefly renderiza así automáticamente todos los errores de los handlers, de modo +> que todos tus errores hablan una única forma legible por máquina. El análogo en +> Spring es `ProblemDetail`. + +> **Note** **Término clave — bus CQRS.** Lumen enruta los **comandos** que cambian +> estado y las **consultas** de solo lectura a través de un *bus* compartido. La +> labor del controlador es únicamente traducir HTTP a un mensaje y despacharlo; la +> lógica de la wallet vive detrás del bus. Construyes esa maquinaria en +> [CQRS](./09-cqrs.md). Para este capítulo, trata `bus.send(...)` / `bus.query(...)` +> como «entrega este mensaje al handler que sabe qué hacer con él». *CQRS* es la +> sigla de Command/Query Responsibility Segregation. + +## Paso 1 — Declarar el bean del controlador + +Los endpoints de wallet de Lumen viven todos en un tipo, `WalletApi`. Es un bean de +DI `#[derive(Controller)]`: un struct simple cuyos colaboradores se `#[autowired]` +desde el contenedor. Declarar el struct es la primera mitad de un controlador; el +bloque `impl` anotado del [Paso 2](#step-2--map-the-verbs) es la segunda mitad. + +Abre `src/web.rs` y añade los imports y el struct: + +```rust,ignore +// src/web.rs +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::Json; +use firefly::cqrs::QueryCache; +use firefly::prelude::*; +use firefly::web::{WebError, WebResult}; + +use crate::commands::{GetWallet, OpenWallet}; +use crate::domain::{DomainError, WalletView}; + +/// The wallet HTTP surface — a `#[derive(Controller)]` DI bean. Its +/// collaborators are **autowired** from the container, and `#[rest_controller]` +/// auto-mounts it; there is no hand-built state and no manual `routes()` call. +#[derive(Clone, Controller)] +pub struct WalletApi { + /// The command/query bus the controller dispatches through (autowired). + #[autowired] + pub bus: Arc, + /// The application service the transfer saga and event stream use (autowired). + #[autowired] + pub ledger: Arc, + /// The query cache, invalidated after a mutation (autowired). + #[autowired] + pub query_cache: Arc, +} +``` + +Lo que acaba de ocurrir, bloque a bloque: + +- Los imports traen los extractores de axum (`Path`, `State`, `Json`), el + `QueryCache` de CQRS, toda la superficie de alta frecuencia vía + `firefly::prelude::*` (que te da `Bus`, `Controller`, `#[autowired]` y las macros + de verbo), y los tipos de resultado/error web (`WebResult`, `WebError`). El import + de `DomainError` lo usa el mapeador de errores del + [Paso 5](#step-5--map-typed-errors-to-rfc-9457-problems). +- `#[derive(Controller)]` marca el struct como un bean de controlador. Es el mismo + estereotipo que los demás beans de Firefly que ya has visto: el contenedor lo + escanea, lo construye y gestiona su ciclo de vida. +- Cada campo `#[autowired]` es un *colaborador* que el contenedor resuelve e inyecta + cuando construye el bean. `bus` es el bus CQRS a través del que despachan los + handlers; `ledger` es el servicio de aplicación que usan más adelante la saga y los + endpoints de streaming; `query_cache` se invalida tras una escritura para que una + lectura tras escritura nunca sirva un saldo obsoleto. Nunca construyes `WalletApi` + tú mismo: lo hace el framework. +- `Clone` es necesario porque la macro entrega un clon del controlador a axum como + *estado* por ruta; el struct está respaldado por `Arc`, así que clonar es barato. + +> **Note** **Término clave — autocableado.** El *autocableado* (autowiring) es la +> inyección por constructor del framework: un campo `#[autowired]` se resuelve desde +> el contenedor por tipo y se entrega al bean en el momento de su construcción. Es +> exactamente el `@Autowired` de Spring. Tú declaras *qué* necesita un controlador; +> el contenedor decide *cómo* suministrarlo. + +> **Tip** **Punto de control.** El struct compila en cuanto existen en el crate los +> beans `Bus`, `Ledger` y `QueryCache` que autocablea (los declaras como factorías +> `#[bean]`: el `Bus` lo aporta el framework, `Ledger` y `QueryCache` son de Lumen). +> Si `cargo build` se queja de que uno de estos tipos no se resuelve, vas por delante +> de la narrativa: las factorías de beans aterrizan en [CQRS](./09-cqrs.md). Por +> ahora, céntrate en la forma del controlador. + +## Paso 2 — Mapear los verbos + +Un struct con campos autocableados es solo un bean. Se convierte en controlador +cuando su bloque `impl` lleva `#[rest_controller]` y sus métodos llevan atributos de +verbo. La macro lee cada uno y genera una función `WalletApi::routes(state) -> +axum::Router`, de modo que la tabla de enrutamiento *se deriva de tu código*, no se +mantiene en un fichero aparte junto a él. + +Añade el bloque `impl` a `src/web.rs`: + +```rust,ignore +// src/web.rs (continued) +/// `#[rest_controller(path = "...")]` generates `WalletApi::routes(state) -> +/// axum::Router`. Each method carries one verb mapping and returns +/// `WebResult`, so a handler error renders as RFC 9457 +/// `application/problem+json`. +#[rest_controller(path = "/api/v1", tag = "Wallets")] +impl WalletApi { + /// `POST /api/v1/wallets` — open a wallet. Validation failures surface as + /// 422 problems; success answers `201 Created` with the view. + #[post( + "/wallets", + summary = "Open a wallet", + description = "Opens a new wallet for an owner with an optional opening balance.", + status = 201 + )] + async fn open( + State(api): State, + Json(body): Json, + ) -> WebResult<(axum::http::StatusCode, Json)> { + let view: WalletView = api.bus.send(body).await.map_err(cqrs_to_web)?; + Ok((axum::http::StatusCode::CREATED, Json(view))) + } + + /// `GET /api/v1/wallets/:id` — fetch the read-model view. An unknown id + /// renders as a 404 problem. + #[get( + "/wallets/:id", + summary = "Fetch a wallet", + description = "Returns the read-model view of a wallet." + )] + async fn get( + State(api): State, + Path(id): Path, + ) -> WebResult> { + let view: WalletView = api.bus.query(GetWallet { id }).await.map_err(cqrs_to_web)?; + Ok(Json(view)) + } +} +``` + +Aquí hay tres cosas que merece la pena leer con atención. + +**La ruta se compone.** `#[rest_controller(path = "/api/v1")]` es el prefijo; +`#[post("/wallets")]` y `#[get("/wallets/:id")]` son los sufijos. La macro los une +en `/api/v1/wallets` y `/api/v1/wallets/:id`. Los atributos `tag`, `summary`, +`description` y `status` son metadatos opcionales: `tag` agrupa los endpoints en la +documentación de la API, `summary`/`description` los anotan, y `status = 201` le +indica al generador de OpenAPI el estado de éxito. Cambian la *documentación*, no el +enrutamiento. + +**Cada handler es un handler de axum corriente.** `State`, `Path` y `Json` son los +propios extractores de axum; Firefly no los reemplaza. `State(api): State` +te entrega el controlador (con sus colaboradores autocableados ya en su sitio); +`Path(id): Path` vincula el segmento `:id`; `Json(body): Json` +deserializa el cuerpo de la petición. El tipo de retorno `WebResult` es lo que +permite que un error del handler se renderice como un documento de problema, tratado +en el [Paso 5](#step-5--map-typed-errors-to-rfc-9457-problems). + +**El controlador es delgado.** `open` y `get` traducen HTTP a un mensaje y lo +entregan al `Bus` de CQRS, para luego traducir el resultado (o el error) de vuelta a +una respuesta HTTP. La *lógica* de la wallet vive detrás del bus, donde la pone +[CQRS](./09-cqrs.md). Lee `api.bus.send(...)` (un comando) y `api.bus.query(...)` +(una consulta) como «despacha al handler que sabe qué hacer»; el bus, los comandos y +el modelo de lectura son los temas de los capítulos 7 al 11. + +> **Note** **Término clave — resolutor de argumentos / extractor validante.** Más +> allá de `Json`/`Path`/`Query`, `firefly::web` (reexportado en `firefly::prelude`) +> incluye extractores que encajan en la misma firma de handler: `Valid` para un +> cuerpo JSON y `ValidPath` / `ValidQuery` para objetos de ruta/consulta (un +> fallo de vinculación es un **400**, un fallo de restricción un problema **422**), el +> extractor de carga de formularios `Multipart` / `UploadedFile`, y el resolutor de +> argumentos `PageRequest` que vincula el `Pageable` de Spring desde +> `?page=&size=&sort=`. El ejemplo por capas en +> [Microservicios por capas](./22-layered-microservices.md) los usa todos. Aquí los +> extractores simples `Json`/`Path` son suficientes. + +> **Design note.** `#[rest_controller(path = "/api/v1")]` declara un controlador y su +> prefijo de ruta; `#[get]` / `#[post]` declaran los mapeos de verbo. Más allá de +> generar el router, la macro emite un descriptor de ruta por endpoint que alimenta la +> vista `/mappings` del actuator y el generador de OpenAPI, de modo que la tabla de +> enrutamiento se deriva de tu código en lugar de mantenerse a su lado, y las +> superficies de documentación quedan automáticamente sincronizadas con los handlers. +> Si has usado antes un framework con baterías incluidas, este estilo de controlador +> declarativo te resultará familiar. + +> **Tip** **Punto de control.** `WalletApi` ahora lleva un `impl` con +> `#[rest_controller]` y dos métodos anotados. La macro ha generado una función +> `WalletApi::routes(state)` (que nunca llamas a mano) y ha registrado un *mount thunk* +> en el inventario de tiempo de enlazado. Verás ambos dar fruto en el +> [Paso 6](#step-6--controllers-are-auto-mounted). + +## Paso 3 — Definir la forma del cable + +La vista que devuelve un handler es un struct `serde` simple. Es la proyección del +*modelo de lectura* de una wallet: plana, optimizada para consultas y desacoplada del +agregado interno. + +> **Note** **Término clave — modelo de lectura / DTO.** Un *DTO* (objeto de +> transferencia de datos) es la forma que ve un cliente en el cable, deliberadamente +> separada de tus tipos de dominio internos. El `WalletView` de Lumen es el DTO del +> modelo de lectura: una proyección plana que devuelve una consulta. Mantenerlo +> separado del agregado `Wallet` significa que puedes evolucionar el modelo interno +> sin romper el contrato de la API. + +```rust,ignore +// src/domain.rs +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Schema)] +pub struct WalletView { + /// The wallet id. + pub id: String, + /// The owner's display name. + pub owner: String, + /// The current balance, in minor units (cents). + pub balance: i64, + /// The aggregate version (number of events applied) — lets a client + /// detect staleness under eventual consistency. + pub version: i64, +} +``` + +Lo que acaba de ocurrir: `WalletView` deriva `Serialize` / `Deserialize` para +cruzar el cable, y `Schema` para que el generador de OpenAPI pueda describirlo (la +derivación `Schema` es el tema de [OpenAPI](./06a-openapi.md)). El saldo viaja como +un recuento entero de *unidades menores* (céntimos), de modo que `€10.00` es el +número JSON `1000`; el dinero nunca viaja como un float. + +El cuerpo de petición que Lumen acepta en `POST /api/v1/wallets` es igual de +corriente: el comando `OpenWallet`. Un `#[serde(rename)]` en su campo de saldo hace +que la clave JSON sea `openingBalance` mientras el campo Rust se queda en +snake_case, de modo que el cable se ve así: + +```json +{ "owner": "alice", "openingBalance": 1000 } +``` + +> **Tip** **Punto de control.** `WalletView` vive en `src/domain.rs` y el controlador +> lo importa con `use crate::domain::WalletView;`. El JSON que devuelve un `GET` son +> exactamente sus cuatro campos: `id`, `owner`, `balance`, `version`. + +## Paso 4 — Dejar que el cliente elija el formato (opcional) + +Los handlers de Lumen responden `application/json` porque devuelven +`Json`: un contrato deliberado y fijado a un formato. Pero un controlador +también puede entregar al framework un DTO y dejar que el *cliente* elija el formato +del cable. Este paso es lectura opcional; puedes saltar al +[Paso 5](#step-5--map-typed-errors-to-rfc-9457-problems) sin perder nada de la +narrativa en curso. + +> **Note** **Término clave — negociación de contenido.** La *negociación de +> contenido* permite que un único handler sirva varios formatos de cable: el cliente +> envía una cabecera `Accept` y el framework renderiza la respuesta con el conversor +> que coincida. El análogo en Spring es un `HttpMessageConverter` elegido por +> `produces`. + +Envuelve el valor de retorno en `Negotiate(dto)` y la respuesta se renderiza con el +conversor que seleccione la cabecera `Accept` de la petición —`JsonMessageConverter` +para `application/json`, `XmlMessageConverter` para `application/xml` / `text/xml`—, +mientras que el cuerpo de la petición se lee por su `Content-Type` de la misma forma: + +```rust,ignore +// a format-agnostic variant of the wallet GET +use firefly::web::Negotiate; + +#[get("/wallets/:id")] +async fn get( + State(api): State, + Path(id): Path, +) -> WebResult> { + let view: WalletView = api.bus.query(GetWallet { id }).await.map_err(cqrs_to_web)?; + Ok(Negotiate(view)) +} +``` + +El mismo handler sirve ahora ambas formas de cable a partir del único `WalletView`: + +```text +GET /api/v1/wallets/wlt_1 Accept: application/json +→ { "id": "wlt_1", "owner": "alice", "balance": 1000, "version": 1 } + +GET /api/v1/wallets/wlt_1 Accept: application/xml +→ wlt_1alice1000... +``` + +
+ +inbound HTTP request + +ProblemLayererrors → problem+jsonTraceContextLayerW3C traceparent in / outCorrelationLayerensure-or-generate idContentNegotiationLayerAccept → JSON / XML + +#[rest_controller]your handler runs +outermost +innermost + +
La pila de capas por defecto, de la más externa a la más interna (algunas capas opcionales —CORS, cabeceras de seguridad, métricas— se omiten). ProblemLayer envuelve todo, de modo que cualquier error se desenrolla hasta una respuesta RFC 9457 application/problem+json; el contexto de traza y la correlación se abren antes de que se ejecute tu handler; la negociación de contenido se sitúa más cerca de las rutas.
+
+ +Nada de esto lo cableas tú. La `ContentNegotiationLayer` se instala por defecto: se +sitúa lo más cerca posible de tus rutas, de modo que una respuesta `Negotiate` se +vuelve a renderizar según el `Accept` del cliente antes de que se ejecute el borde +exterior del middleware, y una respuesta `Json` simple (o cualquier otra) pasa sin +tocarse. Un `Accept` ausente o vacío toma JSON por defecto, y un tipo sin coincidencia +recurre al primer conversor registrado (JSON), de modo que la negociación nunca hace +fracasar la petición. + +> **Design note.** `Negotiate(dto)` entrega al framework un DTO y deja que la cabecera +> `Accept` de la petición elija el formato del cable, sin código de controlador. El par +> `JsonMessageConverter` / `XmlMessageConverter` viene en el registro, y la +> `ContentNegotiationLayer` se instala por defecto, así que la negociación está +> activada de fábrica. Añade un conversor —digamos CBOR— implementando +> `MessageConverter` y registrándolo; los conversores de usuario tienen prioridad +> sobre los integrados. + +Si quieres un único *estilo de casa* —cada respuesta en `camelCase`, los nulos +descartados, las mismas reglas de inclusión en todas partes— en lugar de atributos +serde por tipo, Firefly te da un único objeto para expresar esa política: +`ObjectMapper`. Es un builder que establece una convención de nombrado de +propiedades, una regla de inclusión y el formato legible: + +```rust,ignore +use firefly::web::{ObjectMapper, PropertyNaming, Inclusion}; + +// camelCase on the wire, drop nulls, compact output. +let mapper = ObjectMapper::new() + .naming(PropertyNaming::CamelCase) + .inclusion(Inclusion::NonNull) + .pretty(false); +``` + +Las opciones de nombrado e inclusión son: + +| Opción | Efecto | +|----------------------------------------------|-----------------------------------------------------| +| `PropertyNaming::AsIs` *(por defecto)* | deja los nombres de campo intactos | +| `PropertyNaming::CamelCase` | `opening_balance` → `openingBalance` | +| `PropertyNaming::SnakeCase` | `openingBalance` → `opening_balance` | +| `PropertyNaming::KebabCase` | `opening_balance` → `opening-balance` | +| `PropertyNaming::PascalCase` | `opening_balance` → `OpeningBalance` | +| `PropertyNaming::ScreamingSnakeCase` | `opening_balance` → `OPENING_BALANCE` | +| `Inclusion::Always` *(por defecto)* | serializa todos los campos | +| `Inclusion::NonNull` | omite los campos `null` | +| `Inclusion::NonEmpty` | omite los `null`, las cadenas vacías y las colecciones vacías | + +La transformación de nombrado es *reversible*: un struct Rust en `snake_case` habla +`camelCase` en el cable y lo vuelve a leer de la misma forma, de modo que el mismo +mapper se sitúa en ambos extremos de una petición/respuesta. Si necesitas la +transformación en bruto —por ejemplo para posprocesar un `serde_json::Value` que +construiste a mano—, `apply_write(value)` renombra hacia el cable y `apply_read(value)` +renombra de vuelta hacia tus structs. + +Para que el *servicio entero* observe una única política sin decorar cada DTO, +envuelve un mapper en `MappingJsonConverter` y regístralo. Implementa +`MessageConverter` para `application/json`, y como se registra como conversor de +*usuario* tiene prioridad sobre el `JsonMessageConverter` integrado: + +```rust,ignore +use firefly::web::{ObjectMapper, PropertyNaming, Inclusion, MappingJsonConverter}; + +// One mapper expresses the service-wide JSON contract. +let mapper = ObjectMapper::new() + .naming(PropertyNaming::CamelCase) + .inclusion(Inclusion::NonNull); + +// Wrap it as the JSON converter and register it so every negotiated +// application/json exchange observes the policy. +registry.add(std::sync::Arc::new(MappingJsonConverter::new(mapper))); +``` + +Registrarlo una vez (como un bean conversor) aplica una política global de nombrado e +inclusión JSON a toda la superficie HTTP, en lugar de repetir +`#[serde(rename_all = ...)]` en cada DTO. Los atributos serde por tipo siguen +componiéndose por encima: recurre a ellos cuando un tipo necesita desviarse del estilo +de casa, y deja que `MappingJsonConverter` lleve el valor por defecto en todo lo demás. + +> **Warning** Un mapper de renombrado reescribe *todas* las claves de objeto del +> documento: opera sobre el árbol JSON, así que no puede distinguir un campo de struct +> de una clave dentro de un `HashMap` de forma libre que lleves como datos. Usa una +> política global de nombrado sobre cargas útiles *con forma de DTO*; para un tipo cuyo +> cuerpo contiene datos arbitrarios con claves de cadena, deja la política global en +> `AsIs` y nombra ese único tipo con `#[serde(rename_all = "camelCase")]`: eso es +> consciente del tipo y nunca toca las claves de datos. + +## Paso 5 — Mapear errores tipados a problemas RFC 9457 + +Un handler que devuelve `WebResult` convierte cualquier error en la respuesta +`application/problem+json` correcta vía `?`. `WebResult` es un alias cuyo brazo de +error es un `WebError`, y el framework sabe cómo renderizarlo. El controlador de Lumen +mapea el canal de error del bus a un estado HTTP preciso con un único helper. + +> **Note** **Término clave — `WebResult` / `WebError`.** `WebResult` es +> `Result`. Un `WebError` lleva un `FireflyError`, y el renderizador de +> problemas del framework lo convierte en un cuerpo `application/problem+json` con el +> código de estado correcto. Devolver `WebResult` y usar `?` es todo lo que hace +> falta: nunca escribes la respuesta tú mismo. + +Añade el mapeador de errores a `src/web.rs`: + +```rust,ignore +// src/web.rs (continued) +/// Maps a bus `CqrsError` onto the precise HTTP problem the domain implies: +/// a validation failure → 422, a not-found detail → 404, an +/// insufficient-funds / non-positive detail → 422, otherwise 500. +fn cqrs_to_web(err: CqrsError) -> WebError { + match err { + CqrsError::Validation(detail) => WebError::from(FireflyError::validation(detail)), + CqrsError::Handler(detail) => { + if detail.ends_with("not found") { + WebError::from(FireflyError::not_found(detail)) + } else if detail == DomainError::InsufficientFunds.to_string() + || detail == DomainError::NonPositiveAmount.to_string() + || detail == DomainError::OwnerRequired.to_string() + { + WebError::from(FireflyError::validation(detail)) + } else { + WebError::from(FireflyError::not_found(detail)) + } + } + other => WebError::from(FireflyError::internal(other.to_string())), + } +} +``` + +Lo que acaba de ocurrir: `cqrs_to_web` inspecciona el `CqrsError` del bus y elige el +constructor de `FireflyError` que coincide con el fallo: un fallo de validación se +convierte en un 422, un detalle de «not found» en un 404, y un error inesperado en un +500. Los handlers lo llaman como `.map_err(cqrs_to_web)?`, de modo que el error fluye +fuera del handler como un `WebError` y el renderizador del framework hace el resto. + +Los constructores de `FireflyError` mapean directamente a un estado HTTP: elige el que +coincida con el fallo y el renderizador hace el resto: + +| Constructor | Estado | Uso | +|------------------------------------------|--------|------------------------------| +| `FireflyError::bad_request(detail)` | 400 | entrada malformada | +| `FireflyError::unauthorized(detail)` | 401 | credenciales ausentes/inválidas | +| `FireflyError::forbidden(detail)` | 403 | autenticado pero no autorizado | +| `FireflyError::not_found(detail)` | 404 | recurso ausente | +| `FireflyError::conflict(detail)` | 409 | conflicto de estado | +| `FireflyError::validation(detail)` | 422 | fallo de validación semántica | +| `FireflyError::internal(detail)` | 500 | fallo del servidor | + +Un problema renderizado para una wallet desconocida se ve así; observa el tipo de +contenido dedicado `application/problem+json`, sobre el que los tests hacen asertos: + +```json +{ + "type": "https://fireflyframework.org/problems/not-found", + "title": "Not Found", + "status": 404, + "detail": "wallet wlt_does_not_exist not found" +} +``` + +> **Design note.** Devolver `WebResult` convierte cualquier `FireflyError` en la +> respuesta `application/problem+json` correcta vía `?`, con el renderizado de +> problemas integrado: nunca escribes un mapeo de error a estado para los propios +> errores del framework. El contrato RFC 9457 es estable y neutral respecto al +> lenguaje, así que un 404 de Firefly se presenta idéntico a todo cliente sin importar +> qué servicio lo produjo. + +> **Tip** **Punto de control.** `src/web.rs` contiene ahora el struct `WalletApi`, su +> `impl` con `#[rest_controller]` y `cqrs_to_web`. Eso es una superficie HTTP completa +> —dos endpoints y su mapeo de errores— sin una sola línea que monte una ruta o +> construya un router a mano. + +## Paso 6 — Los controladores se automontan + +Nunca montas el controlador. Como `WalletApi` es un bean `#[derive(Controller)]`, la +macro `#[rest_controller]` registró un *mount thunk* en el inventario de tiempo de +enlazado junto a la función generada `routes(state)`. En el arranque, +`FireflyApplication` llama a `firefly::web::mount_controllers(&container)`, que +resuelve cada bean de controlador desde el contenedor (construyendo sus colaboradores +autocableados), llama a su `routes(state)` y fusiona el resultado; luego superpone la +seguridad y envuelve todo el conjunto en la cadena de middleware web: + +```rust,ignore +// inside FireflyApplication::bootstrap — you write none of this: +let routes = firefly::web::mount_controllers(&container) // every #[rest_controller] + .merge(firefly::web::mount_route_contributors(&container)); // every RouteContributor bean +// security (the FilterChain + BearerLayer beans) is layered onto these routes, +// then the whole router is wrapped in the observability edge: +let api = web.apply_middleware(routes); // + trace, metrics, 404, problem +``` + +> **Note** **Término clave — inventario de tiempo de enlazado.** El *inventario* es un +> registro en el que las macros escriben en tiempo de compilación: cada +> `#[rest_controller]`, handler de comando, listener de evento y tarea `#[scheduled]` +> se registra ahí. En el arranque el framework relee el inventario y cablea todo: sin +> reflexión, sin lista de registro manual. Así es como `main` nunca cambia a medida que +> Lumen crece. + +Así que añadir el controlador *es* montarlo: declara el bean, anota el impl, y la +tabla de rutas crece. La función generada `routes(state)` de la macro sigue ahí (es lo +que llama el mount thunk), y el `RouteDescriptor` que emite por endpoint alimenta la +vista `/mappings` del actuator y el generador de OpenAPI, pero nunca llamas a ninguno a +mano. + +Cada petición a una ruta de wallet pasa por la cadena canónica que obtuviste gratis en +el [Inicio rápido](./02-quickstart.md) —la capa de problemas RFC 9457, la propagación +del id de correlación y la repetición por idempotencia— antes de llegar a tu handler. +Tú escribiste los dos handlers; el resto del ciclo de vida de la petición es del +framework. + +> **Note** `main` nunca cambia a medida que Lumen crece. La capa de seguridad JWT se +> descubre desde un bean `FilterChain` en [Seguridad](./14-security.md); el endpoint de +> streaming se añade como un bean `RouteContributor` en +> [Producción](./20-production.md). Cada uno es un *nuevo bean que encuentra el escaneo*, +> no una línea editada en una raíz de composición: el framework absorbe cada adición. + +> **Tip** **Punto de control.** Ejecuta `cargo run` y lee la línea `:: routes ::` del +> informe de arranque: `/api/v1/wallets` y `/api/v1/wallets/:id` aparecen ahora en +> ella. Las añadiste declarando un bean, no tocando un router. (Las mutaciones +> responderán `401` hasta que existan los beans de seguridad; eso es lo esperado y llega +> en [Seguridad](./14-security.md).) + +## Paso 7 — Demostrarlo en proceso + +Ahora demuestra que todo el conjunto hace el viaje de ida y vuelta. Los tests HTTP de +Lumen ejercitan el router *real y completamente cableado* **en proceso** con +`tower::ServiceExt::oneshot`: sin socket vinculado, sin puerto por el que competir. + +> **Note** **Término clave — `bootstrap()` y `oneshot`.** `bootstrap()` es el hermano +> de `run()`: ensambla la misma app —el mismo escaneo de componentes y el mismo +> automontaje—, pero devuelve un valor `Bootstrapped` *sin servir*, exponiendo el +> `api_router` cableado. `tower::ServiceExt::oneshot` alimenta una `Request` a ese +> `Router` y devuelve la `Response`, todo en el proceso del test. Juntos ejecutan la +> ruta de petición real sin un servidor activo. + +La ruta de arranque del test es un pequeño helper, `build_router()`, en `src/web.rs`. +Está limitado a las compilaciones de test y llama a `bootstrap()`, devolviendo el +`axum::Router` exacto que sirve `main`: + +```rust,ignore +// src/web.rs — the in-process router the tests drive (no socket bound). +#[cfg(test)] +pub(crate) async fn build_router() -> axum::Router { + firefly::FireflyApplication::new(APP_NAME) + .version(VERSION) + .bootstrap() + .await + .expect("lumen bootstrap") + .api_router +} +``` + +Como `bootstrap()` ejecuta el *mismo* escaneo de componentes y automontaje que `run()`, +el test ejercita la pila de controladores real y completamente cableada —las rutas +generadas por la macro, el contrato JSON y el mapeo de códigos de estado—, la misma +ruta de código que toca un cliente real, menos la red. `APP_NAME` y `VERSION` son las +dos constantes que Lumen mantiene junto a su superficie HTTP (las conociste en el +Inicio rápido). + +Los tests propiamente dichos viven en `src/http_test.rs`, un `mod` `#[cfg(test)]` +compilado dentro del crate para que pueda alcanzar el `build_router` interno del crate. +Cada test arranca **un** contexto de aplicación y ejercita cada petición contra él —el +modelo `@SpringBootTest` de Spring Boot—, de modo que los singletons se mantienen +consistentes a lo largo de las peticiones de un test (la wallet que abre un comando es +la wallet que lee una consulta posterior). Un par de pequeños helpers de petición +mantienen los tests legibles: + +```rust,ignore +// src/http_test.rs +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::response::Response; +use axum::Router; +use http_body_util::BodyExt; +use tower::ServiceExt; + +use crate::build_router; +use crate::domain::WalletView; +use crate::security::{mint_token, CUSTOMER_ROLE}; + +/// A bearer token for a customer — mutations require authentication, which the +/// framework auto-discovers from the security beans. +fn bearer() -> String { + format!("Bearer {}", mint_token("u-alice", &[CUSTOMER_ROLE])) +} + +/// Sends one request against the (cloned) shared app and returns the response. +async fn send(app: &Router, req: Request) -> Response { + app.clone().oneshot(req).await.unwrap() +} + +/// Decodes a JSON response body into a typed value. +async fn body_json(res: Response) -> T { + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} +``` + +> **Note** Como la seguridad se autodescubre desde los beans `FilterChain` y +> `BearerLayer` (el tema de [Seguridad](./14-security.md)), el `POST` mutante lleva una +> cabecera `Authorization: Bearer …`. El `GET` de solo lectura no necesita ninguna. Si +> aún no has añadido los beans de seguridad, ejecuta los tests de mutación sin la +> cabecera y espera un `401`: eso *es* el framework aplicando la cadena que descubrió. + +Aquí está el primer test de extremo a extremo, el viaje de ida y vuelta open-then-get. +El `axum::Router` está respaldado por `Arc` y es barato de clonar, así que cada +`oneshot` clona la app compartida: + +```rust,ignore +#[tokio::test] +async fn open_then_get_round_trips_through_cqrs() { + let app = build_router().await; + + // POST /api/v1/wallets → 201 Created with the opened view. + let res = send( + &app, + Request::post("/api/v1/wallets") + .header("content-type", "application/json") + .header("authorization", bearer()) + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "owner": "alice", "openingBalance": 1_000 + })) + .unwrap(), + )) + .unwrap(), + ) + .await; + assert_eq!(res.status(), StatusCode::CREATED, "open should 201"); + let opened: WalletView = body_json(res).await; + assert_eq!(opened.owner, "alice"); + assert_eq!(opened.balance, 1_000); + assert_eq!(opened.version, 1); + + // GET /api/v1/wallets/:id → 200 OK with the same view. + let res = send( + &app, + Request::get(&format!("/api/v1/wallets/{}", opened.id)) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(res.status(), StatusCode::OK); + let fetched: WalletView = body_json(res).await; + assert_eq!(fetched.id, opened.id); + assert_eq!(fetched.balance, 1_000); +} +``` + +Lo que acaba de ocurrir: el `POST` abre una wallet y el framework responde `201` con el +`WalletView`; el `GET` vuelve a leer la misma wallet y responde `200` con la vista +correspondiente. Ambas peticiones pasaron por la pila completa del controlador montado, +el despacho CQRS y el contrato JSON, en un único proceso y sin red. + +Las rutas de error se prueban de la misma forma. Un id que nunca se abrió es un problema +`404`, y el test asevera el tipo de contenido `application/problem+json`, de modo que el +contrato RFC 9457 forma parte de la suite, no solo de la prosa: + +```rust,ignore +#[tokio::test] +async fn unknown_wallet_is_404_problem() { + let app = build_router().await; + let res = send( + &app, + Request::get("/api/v1/wallets/wlt_does_not_exist") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let ct = res.headers().get("content-type").unwrap().to_str().unwrap(); + assert!(ct.contains("application/problem+json")); +} +``` + +> **Design note.** `oneshot` contra `build_router()` ejecuta toda la pila del +> controlador en el proceso del test sin servidor activo ni socket vinculado, de modo +> que el test ejercita la ruta de petición real a plena velocidad y sin contención de +> puertos. [Testing](./18-testing.md) lo integra en una estrategia completa. + +> **Tip** **Punto de control.** Ejecuta `cargo test -p firefly-sample-lumen` y observa +> cómo pasan el test del viaje de ida y vuelta y el del problema 404 contra el router +> real, ensamblado por el framework. (El ejemplo completo también prueba +> depósito/retiro, la saga de transferencia y la cadena de seguridad; esos dependen de +> maquinaria de capítulos posteriores.) + +## Resumen — qué cambió en Lumen + +| Antes | Después de este capítulo | +|--------|--------------------| +| un router público vacío | un controlador `WalletApi` declarado con `#[derive(Controller)]` + `#[rest_controller]` y dos endpoints reales | +| sin contrato de cliente | `POST /api/v1/wallets` → `201` + `WalletView`, `GET /api/v1/wallets/:id` → `200`/`404`, todo JSON | +| errores sin considerar | `FireflyError` tipado → RFC 9457 `application/problem+json` con el estado correcto, vía `cqrs_to_web` | +| nada que probar | un viaje de ida y vuelta con `tower::oneshot` que ejercita el router completo en proceso, asertos de tipo de contenido incluidos | + +Ahora también sabes: + +- Que un controlador es *solo un bean más un `impl` anotado*: colaboradores + `#[autowired]` en el struct, atributos de verbo en los métodos, y que la macro deriva + la tabla de enrutamiento de tu código. +- Que nunca montas un controlador: `mount_controllers(&container)` resuelve y fusiona + cada `#[rest_controller]` en el arranque, de modo que añadir el bean *es* añadir las + rutas, y `main` nunca cambia. +- Que `WebResult` más un constructor de `FireflyError` convierte cualquier error del + handler en el `application/problem+json` correcto, sin escribir respuestas a mano. +- Que `bootstrap()` es la costura de test: `build_router()` ejercita el router + completamente cableado en proceso con `tower::oneshot`, sin socket vinculado. + +El controlador es deliberadamente delgado: habla HTTP y delega la lógica de la wallet +al bus. Esa costura es lo que rellenan los siguientes capítulos: el modelo de lectura +que sirve el `GET`, el dominio que aplica las reglas, y los handlers CQRS a los que +despacha el `POST`. + +## Ejercicios + +1. **Añade una ruta.** Dale a `WalletApi` un método `list` `#[get("/wallets")]` que + devuelva `WebResult>>`. Ejecuta Lumen y observa cómo la nueva + ruta aparece en la línea `:: routes ::` del informe de arranque y en + `WalletApi::routes`: nunca tocas una tabla de enrutamiento. +2. **Da forma a un error.** Haz que `cqrs_to_web` (o un pequeño handler propio) + devuelva `FireflyError::conflict("wallet already closed")` y confirma que la + respuesta es un `409` con `application/problem+json`. Prueba también `bad_request` y + `forbidden`, y lee el `type`/`title`/`status` renderizado de cada uno frente a la + tabla del [Paso 5](#step-5--map-typed-errors-to-rfc-9457-problems). +3. **Negocia el formato.** Cambia el tipo de retorno del handler `GET` a + `Negotiate` (Paso 4), ejecuta Lumen y pide la misma wallet dos veces: + una con `Accept: application/json` y otra con `Accept: application/xml`. Confirma que + un único handler sirve ambas formas de cable. +4. **Escribe tú mismo el viaje de ida y vuelta.** Copia + `open_then_get_round_trips_through_cqrs`, cambia el propietario y el saldo de + apertura, y asevera que el `balance` devuelto coincide. Ejecuta + `cargo test -p firefly-sample-lumen` y observa cómo pasa contra el router real. +5. **Honra la idempotencia.** Haz `POST /api/v1/wallets` dos veces con la misma cabecera + `Idempotency-Key` y un cuerpo idéntico; confirma que la segunda respuesta repite el + resultado almacenado. Luego cambia el cuerpo bajo la misma clave y observa el `409`. + Nada de esto lo escribiste tú: vino con la cadena de middleware. + +## Adónde ir después + +- Mira cómo la macro convierte tus tipos `#[rest_controller]` y `#[derive(Schema)]` en + una especificación viva en **[OpenAPI y documentación de la API](./06a-openapi.md)**. +- Dale al endpoint `GET` un almacén de respaldo real con + **[Persistencia y repositorios reactivos](./07-persistence.md)**. +- Pon las reglas de la wallet detrás del bus en + **[Diseño orientado al dominio](./08-domain-driven-design.md)** y + **[CQRS](./09-cqrs.md)**: la maquinaria a la que despachan `bus.send(...)` / + `bus.query(...)`. diff --git a/docs/book/src-es/06a-openapi.md b/docs/book/src-es/06a-openapi.md new file mode 100644 index 00000000..534e12a3 --- /dev/null +++ b/docs/book/src-es/06a-openapi.md @@ -0,0 +1,696 @@ +# OpenAPI, Swagger UI y ReDoc + +En [Tu primera API HTTP](./06-first-http-api.md) le diste a Lumen sus primeros +endpoints reales: un `#[rest_controller]` cuyos métodos `#[post]` / `#[get]` se +montan a sí mismos en el arranque. Este capítulo muestra lo que esas mismas +declaraciones *también* te dieron, gratis: un documento **OpenAPI 3.1** completo y +en vivo, una página de **Swagger UI** y una página de **ReDoc**, todo servido sin +una sola línea extra de código de aplicación. La especificación se genera a partir +del inventario en vivo que el framework ya descubrió —cada ruta de controlador más +cada DTO con `#[derive(Schema)]`— y `FireflyApplication` monta los endpoints de +documentación durante el arranque. + +Nada en este capítulo cambia una sola línea de `samples/lumen`. El controlador que +escribiste ya lleva los resúmenes, las etiquetas y los DTOs con `#[derive(Schema)]` +que lee el generador. Lo que se busca aquí es *ver* que esas declaraciones de +enrutamiento **son** la documentación de la API, y aprender cómo enriquecer, +sobrescribir y exportar la especificación cuando lo necesites. + +Al terminar este capítulo, serás capaz de: + +- Llegar a las tres superficies de documentación de Lumen —la especificación + OpenAPI, Swagger UI y ReDoc— y explicar por qué residen en el puerto de gestión + y no en el público. +- Derivar un esquema de componente reutilizable a partir de un DTO con + `#[derive(Schema)]`, y entender cómo respeta el renombrado de serde, los campos + opcionales, los enums y los tipos anidados. +- Seguir cómo el cuerpo de la petición, el cuerpo de la respuesta y los parámetros + de ruta/consulta/cabecera de una operación se *infieren* a partir de la propia + firma de un handler. +- Adjuntar metadatos por operación (resumen, descripción, etiquetas, estado, + `deprecated`) y sobrescribir la inferencia con `request = ` / `response = ` cuando + una firma no puede expresar el DTO. +- Exportar la especificación con la CLI de `firefly` y generar a partir de ella un + cliente Rust tipado. + +## Conceptos que conocerás + +Antes del primer endpoint, aquí están las ideas en las que se apoya este capítulo. +Cada una se reintroduce en contexto allí donde se usa por primera vez; esta es la +versión breve. + +> **Note** **Término clave — OpenAPI.** *OpenAPI* (antes Swagger) es una +> descripción de una API REST neutral respecto al lenguaje y legible por máquina: +> cada path, operación, parámetro, cuerpo de petición, respuesta y esquema +> reutilizable, como un único documento JSON (o YAML). Las herramientas lo leen +> para renderizar documentación, generar clientes y ejecutar pruebas de contrato. +> Firefly emite **OpenAPI 3.1**. + +> **Note** **Término clave — esquema de componente.** Un *esquema de componente* es +> un JSON Schema con nombre y reutilizable para un tipo de dato, registrado bajo +> `#/components/schemas/{Type}` y referenciado desde las operaciones mediante un +> `$ref`. El análogo de Java/Spring es un modelo anotado con `@Schema`; en Firefly +> incluyes un tipo con `#[derive(Schema)]`. + +> **Note** **Término clave — Swagger UI / ReDoc.** Ambas son aplicaciones de +> navegador que renderizan un documento OpenAPI como documentación interactiva y +> legible para humanos. *Swagger UI* tiene un panel "Try it out" que lanza +> peticiones en vivo; *ReDoc* es una referencia limpia de tres paneles. Firefly +> sirve ambas, cada una apuntando a la misma especificación. + +> **Note** **Término clave — el inventario.** Las macros de Firefly emiten +> descriptores en tiempo de compilación a un registro `inventory`: un +> `RouteDescriptor` por cada método `#[rest_controller]` y un `SchemaDescriptor` +> por cada tipo con `#[derive(Schema)]`. El generador de OpenAPI lee ese registro +> en lugar de volver a analizar tu código fuente. Así es como un framework de Rust +> obtiene un comportamiento de "escanear la aplicación" al estilo de springdoc sin +> reflexión en tiempo de ejecución. + +## Paso 1 — Llega a las tres superficies de documentación + +No escribes ni registras nada para obtener documentación de la API. Arranca Lumen +exactamente como en el [Quickstart](./02-quickstart.md): + +```bash +cargo run +``` + +Entre las líneas de arranque, el framework imprime las URLs de documentación: + +```text +:: api docs (management) :: swagger-ui http://0.0.0.0:8081/swagger-ui | redoc http://0.0.0.0:8081/redoc | spec http://0.0.0.0:8081/v3/api-docs +``` + +Abre cada una en un navegador (o haz `curl` a la especificación). Los endpoints, en +el puerto de **gestión**, por defecto son: + +| Path | Sirve | +|------|--------| +| `/v3/api-docs` | la especificación JSON OpenAPI 3.1 (el path de springdoc en Spring Boot) | +| `/openapi.json` | la misma especificación (un alias de retrocompatibilidad) | +| `/swagger-ui` y `/swagger-ui.html` | Swagger UI, apuntando a la especificación | +| `/redoc` | ReDoc, apuntando a la especificación | + +
+ +#[rest_controller]routes + status codes +#[derive(Schema)]DTO component schemas + + + + + + + + + +openapi.json +(/v3/api-docs) + + +Swagger UI +ReDoc + +
Sin paso de generación de código, sin framework de anotaciones. En el arranque, FireflyApplication recolecta los atributos de enrutamiento y cada tipo con #[derive(Schema)] en un único documento OpenAPI 3.1 (servido en /v3/api-docs en el puerto de gestión) y apunta a él Swagger UI y ReDoc.
+
+ +Lo que acaba de ocurrir: durante el pipeline de arranque (la etapa de montaje de +documentación que conociste en [Bootstrap](./04b-bootstrap.md)), `FireflyApplication` +construyó un documento OpenAPI a partir del inventario en vivo y fusionó un pequeño +router que sirve estos paths sobre la superficie de gestión. No hay ningún framework +de anotaciones que aprender más allá de los atributos de enrutamiento del capítulo 6, +ni paso de generación de código. Esta es la contraparte en Rust de springdoc-openapi. + +> **Tip** **Punto de control.** Con `cargo run` en ejecución, `curl +> localhost:8081/v3/api-docs` devuelve un cuerpo JSON que empieza por +> `{"openapi":"3.1.0",...}`, y `http://localhost:8081/swagger-ui` renderiza la API +> del wallet en un navegador. Si `curl` conecta pero da 404, confirma que estás +> apuntando a `8081` (gestión), no a `8080` (público). + +## Paso 2 — Entiende por qué la documentación reside en el puerto de gestión + +Fíjate en que las URLs de arriba están todas en `:8081`, el puerto de gestión +—junto al actuator y al panel de administración— y **no** en la API pública de +`:8080`. + +> **Note** **Término clave — superficie de gestión.** La *superficie de gestión* es +> el conjunto de endpoints HTTP operativos —health, info, métricas, admin y ahora +> la documentación de la API— servidos en un puerto separado de tu API de negocio, +> para operadores y herramientas en lugar de para usuarios finales. Esto refleja el +> puerto de gestión dedicado de Spring Boot Actuator. + +Por qué separarlos: Swagger UI, ReDoc y la especificación en bruto exponen tu +superficie de API **completa** y cada esquema, una cuestión del plano de control. +Pertenecen allí donde los operadores ya acceden a `/actuator/*` y `/admin/`, +manteniendo el puerto público del plano de datos libre de endpoints de +introspección de la API. + +Esa separación crea una arruga que el framework resuelve por ti. Como la +documentación se *carga* desde el origen de gestión (`:8081`) pero la API +*responde* en el puerto público (`:8080`), el documento declara la **URL base de la +API pública** como su `server` de OpenAPI. Así, el "Try it out" de Swagger UI y las +muestras de ReDoc apuntan a la API (`:8080`), no al origen de gestión desde el que +se cargaron. `FireflyApplication` deriva esa URL de la dirección de enlace de la API +—un host comodín como `0.0.0.0` no es utilizable por un cliente, así que recurre a +`localhost`: + +```text +http://localhost:8080 +``` + +Detrás de un proxy inverso querrás en su lugar una URL pública real. Define +`FIREFLY_OPENAPI_SERVER_URL` y sobrescribirá el valor derivado: + +```bash +FIREFLY_OPENAPI_SERVER_URL=https://api.lumen.example cargo run +``` + +Lo que acaba de ocurrir: el `servers[0].url` de la especificación pasa a ser el +valor que proporcionaste, de modo que cada llamada de "Try it out" va a tu hostname +público. (Un path desconocido en **cualquiera** de los dos listeners sigue +respondiendo con el mismo 404 `application/problem+json` RFC 9457 que conociste en +el [capítulo 6](./06-first-http-api.md), así que la superficie de documentación +también degrada de forma limpia). + +> **Tip** **Punto de control.** `curl -s localhost:8081/v3/api-docs | jq '.servers'` +> muestra una entrada cuya `url` es `http://localhost:8080` por defecto: la API +> pública, no el origen `:8081` desde el que la obtuviste. + +## Paso 3 — Convierte un DTO en un esquema de componente con `#[derive(Schema)]` + +Un tipo de dato se convierte en un `#/components/schemas/{Type}` reutilizable al +derivar `Schema`. Como Rust no tiene reflexión en tiempo de ejecución, el JSON +Schema se calcula **en tiempo de expansión de macro** recorriendo los campos del +struct, de modo que lo que acaba en la especificación se decide cuando compilas, no +en el arranque. + +> **Note** **Término clave — `#[derive(Schema)]`.** Este derive es el análogo en +> Rust de un modelo Spring con `@Schema`. Lee el struct (o el enum sin campos) en +> tiempo de compilación, emite un fragmento de JSON Schema y lo envía al inventario +> para que el generador pueda registrarlo como un componente con nombre y hacerle +> `$ref` desde las operaciones. + +Aquí está la vista del modelo de lectura de Lumen, exactamente como la escribiste en +`src/domain.rs`: + +```rust,ignore +// src/domain.rs +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Schema)] +pub struct WalletView { + /// The wallet id. + pub id: String, + /// The owner's display name. + pub owner: String, + /// The current balance, in minor units (cents). + pub balance: i64, + /// The aggregate version (number of events applied). + pub version: i64, +} +``` + +El derive recorre los cuatro campos y registra un esquema equivalente a: + +```json +{ "type": "object", + "properties": { + "id": {"type": "string"}, + "owner": {"type": "string"}, + "balance": {"type": "integer"}, + "version": {"type": "integer"} + }, + "required": ["id", "owner", "balance", "version"] } +``` + +Lo que acaba de ocurrir: cada campo `String` se convirtió en `{"type":"string"}`, +cada `i64` en `{"type":"integer"}` y —como ninguno está envuelto en `Option`— los +cuatro aterrizaron en `required`. El mapeo refleja lo que produce un modelo +Java/Spring con `@Schema`: + +- `String` / `str` / `char` → `string`; `bool` → `boolean`; todos los tipos enteros + (`i8`…`u128`, `usize`, …) → `integer`; `f32` / `f64` → `number`. +- `Uuid` → `string` con `format: uuid`; los date-times de chrono / time → `string` + con `format: date-time`; las fechas → `format: date`; las horas → `format: time`. +- `Option` es un envoltorio transparente: describe `T` pero hace la propiedad + **no requerida** (de modo que los opcionales se caen de la lista `required`). +- `Box` / `Arc` / `Rc` también son transparentes; `Vec` / `HashSet` / + `BTreeSet` / … → un `array` del esquema del elemento; `HashMap` / `BTreeMap` → un + `object` abierto con `additionalProperties`. +- Cualquier *otro* tipo con nombre se asume que es un DTO hermano que también deriva + `Schema`, y se emite como un `$ref`, de modo que un DTO anidado queda **enlazado**, + no incrustado, y los dos esquemas de componente se componen. + +> **Tip** **Punto de control.** `curl -s localhost:8081/v3/api-docs | jq +> '.components.schemas.WalletView'` imprime el esquema de objeto de arriba. Cada DTO +> que deriva `Schema` aparece bajo `.components.schemas`. + +### El renombrado de serde se respeta + +`#[derive(Schema)]` lee las directivas serde del struct para que los nombres de las +propiedades en el esquema coincidan con la forma del **cable** JSON —`rename`, +`rename_all` y `skip`— no con los identificadores de Rust. El `TransferResult` de +Lumen lleva renombrados de campos, y el esquema los sigue: + +```rust,ignore +// src/transfer.rs +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, firefly::Schema)] +pub struct TransferResult { + pub status: String, + pub from: String, + pub to: String, + pub amount: i64, + #[serde(rename = "stepsExecuted")] + pub steps_executed: Vec, + #[serde(rename = "stepsRolledBack")] + pub steps_rolled_back: Vec, +} +``` + +El esquema nombra las propiedades de array `stepsExecuted` / `stepsRolledBack` —el +JSON exacto que serializa el handler— no los identificadores de Rust en snake_case. +Un `#[serde(rename_all = "camelCase")]` a nivel de struct se aplica a cada campo de +la misma forma, y un campo con `#[serde(skip)]` se omite por completo del esquema. +La regla práctica: el esquema describe lo que va por el cable, así que siempre +coincide con tu JSON serializado. + +> **Design note.** Por esto el esquema es fiel al cable sin que mantengas una +> segunda copia de los nombres de los campos: el único conjunto de atributos serde +> que controla la serialización controla también el esquema. No hay una anotación +> separada que mantener sincronizada, ni forma de que la documentación se desvíe de +> los bytes. + +### Los enums sin campos se vuelven enumeraciones de cadenas + +Un enum sin campos (de variantes unitarias) que deriva `Schema` emite una +enumeración `string` de JSON Schema, el tratamiento de springdoc para un `enum` de +Java. El renombrado de serde se respeta aquí también, de modo que los valores +permitidos coinciden con la forma del cable. La muestra por capas `lumen-ledger` +modela así el ciclo de vida de un wallet: + +```rust,ignore +// lumen-ledger: interfaces/.../wallet_status.rs +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Schema)] +#[serde(rename_all = "lowercase")] +pub enum WalletStatus { + #[default] + Active, + Frozen, + Closed, +} +``` + +registra: + +```json +"WalletStatus": { "type": "string", "enum": ["active", "frozen", "closed"] } +``` + +Lo que acaba de ocurrir: cada variante se convirtió en una cadena permitida, +pasada a minúsculas por el `rename_all` a nivel de struct. Un campo de DTO de este +tipo entonces hace `$ref` al componente enum registrado en lugar de convertirse en +una cadena sin tipo; el `WalletResponse` de `lumen-ledger` usa exactamente esto para +su campo `status: WalletStatus`, de modo que los dos esquemas de componente se +componen. (`#[derive(Schema)]` solo admite enums sin campos; un enum con datos en +una variante se rechaza en tiempo de compilación). + +## Paso 4 — Deja que la macro infiera los modelos de petición y respuesta + +**No** nombras los modelos de petición y respuesta en el atributo del verbo. La +macro los infiere a partir de la propia firma del handler, en tiempo de compilación: + +- el **cuerpo de la petición** es el tipo interno del primer parámetro `Json` *o* + `Valid` (de modo que el extractor validador también documenta su cuerpo), y +- la **respuesta** es el `Json` que se encuentra dentro del tipo de retorno, tras + desenvolver `WebResult<…>` / `Result<…>` y mirar a través de una tupla + `(StatusCode, Json)`. + +Toma el handler `open` de Lumen, sin cambios respecto al capítulo 6: + +```rust,ignore +// src/web.rs +#[post( + "/wallets", + summary = "Open a wallet", + description = "Opens a new wallet for an owner with an optional opening balance.", + status = 201 +)] +async fn open( + State(api): State, + Json(body): Json, +) -> WebResult<(axum::http::StatusCode, Json)> { + let view: WalletView = api.bus.send(body).await.map_err(cqrs_to_web)?; + Ok((axum::http::StatusCode::CREATED, Json(view))) +} +``` + +Lo que acaba de ocurrir: a partir de la firma por sí sola, la macro registró +`OpenWallet` como el esquema de la petición (el parámetro `Json`) y +`WalletView` como el esquema de la respuesta (el `Json` dentro de la +tupla `(StatusCode, …)` dentro de `WebResult<…>`). Ambos derivan `Schema`, así que la +operación hace `$ref` a `#/components/schemas/OpenWallet` y +`#/components/schemas/WalletView`, sin declaración de petición/respuesta en el +atributo. + +> **Note** Un `$ref` se emite **solo** cuando el tipo inferido es realmente un +> componente `#[derive(Schema)]` registrado. El `transfer_compliance` de Lumen +> devuelve `Json`; `serde_json::Value` no es un esquema +> registrado, así que el generador no emite ningún `$ref` de petición/respuesta para +> él en lugar de referenciar un componente que no existe. El documento permanece +> válido sin importar lo que devuelvan tus handlers: nunca hay `$ref`s colgantes. + +> **Tip** **Punto de control.** `curl -s localhost:8081/v3/api-docs | jq +> '.paths."/api/v1/wallets".post.requestBody'` muestra un `$ref` a +> `#/components/schemas/OpenWallet`, y la respuesta `201` hace `$ref` a `WalletView`. + +### Los parámetros de ruta, consulta y cabecera también se infieren + +La misma inferencia dirigida por la firma cubre los **parámetros** de operación, de +modo que Swagger UI y ReDoc renderizan una entrada para cada uno, sin una lista de +parámetros escrita a mano: + +- Los parámetros de **ruta** vienen de la plantilla de la ruta: cada segmento `:id` + (axum) / `{id}` se convierte en un parámetro `in: path` requerido. El + `GET /wallets/:id` de Lumen obtiene un parámetro de ruta `id` requerido + automáticamente. +- Los parámetros de **consulta** vienen de un extractor `Query` / + `ValidQuery`: el generador expande los campos `#[derive(Schema)]` de `T` en un + parámetro `in: query` cada uno (requerido si y solo si el campo no es opcional). + Un argumento `PageRequest` añade los parámetros de consulta estándar de Spring Data + `page` / `size` / `sort`. +- Los parámetros de **cabecera** se declaran en el atributo del verbo: + `header("Idempotency-Key", required, description = "…")` emite un parámetro + `in: header` (y el handler lo lee como cualquier cabecera de axum). Una declaración + `query("…")` añade un parámetro de consulta extra de la misma forma. + +El `WalletApi` de Lumen mantiene sus handlers simples —solo de ruta—, así que su +inferencia de parámetros son únicamente los segmentos `:id`. La historia más rica de +consulta/cabecera es lo que ejercita el `WalletController` de la muestra por capas +`lumen-ledger`. Su endpoint de listado paginado enlaza una consulta de filtro *y* el +resolutor de paginación del framework: + +```rust,ignore +// lumen-ledger: web/.../wallet_controller.rs +#[get("/wallets/page", summary = "List wallets by status (paged)")] +async fn list_paged( + State(api): State, + Query(query): Query, + PageRequest(pageable): PageRequest, +) -> WebResult>> { + let page = api.service.list_by_status(query.status, pageable).await.map_err(service_to_web)?; + Ok(Json(page)) +} +``` + +Lo que acaba de ocurrir: `Query` expandió el único campo `status` del +esquema `StatusQuery` en un parámetro `in: query`, y `PageRequest` añadió `page`, +`size` y `sort`, de modo que Swagger UI renderiza cuatro entradas de consulta para +este endpoint con cero boilerplate de parámetros. Su handler `open` muestra la forma +de cabecera, declarando una cabecera de petición `Idempotency-Key` directamente en +el atributo del verbo: + +```rust,ignore +// lumen-ledger: web/.../wallet_controller.rs +#[post( + "/wallets", + summary = "Open a wallet", + status = 201, + header("Idempotency-Key", description = "optional client-supplied key to make retries safe") +)] +async fn open(/* … */) -> WebResult<(StatusCode, Json)> { /* … */ } +``` + +— de modo que los llamadores ven y pueden rellenar la cabecera en Swagger UI, y el +handler la lee del `HeaderMap` como cualquier otra cabecera. + +## Paso 5 — Adjunta metadatos por operación + +Más allá del path, cada atributo de verbo admite metadatos opcionales que aterrizan +en la operación OpenAPI. La forma completa es: + +```text +#[get("/x", summary = "…", description = "…", tags = ["A", "B"], status = 200, deprecated, request = T, response = T)] +``` + +| Argumento | Efecto en la operación | +|----------|-------------------------| +| `summary = "…"` | el resumen de una línea | +| `description = "…"` | la descripción más larga | +| `tags = ["A", "B"]` | etiquetas de agrupación (sobrescriben la etiqueta del controlador, abajo) | +| `status = 201` | el código de estado de éxito (por defecto 201 para `POST`, si no 200) | +| `deprecated` | marca la operación como `deprecated: true` (flag escueta; `deprecated = false` para desactivar) | +| `request = T` | el nombre del esquema del cuerpo de la petición — sobrescribe la inferencia | +| `response = T` | el nombre del esquema de la respuesta de éxito — sobrescribe la inferencia | + +La operación `transfer` de Lumen usa summary, description, un `tags` explícito y un +`status`: + +```rust,ignore +// src/web.rs +#[post( + "/transfers", + summary = "Transfer funds (saga)", + description = "Moves funds between two wallets as a compensating saga (debit then credit).", + tags = ["Transfers"], + status = 200 +)] +async fn transfer( + State(api): State, + Json(body): Json, +) -> WebResult> { /* … */ } +``` + +Lo que acaba de ocurrir: la macro estampó el resumen, la descripción, la etiqueta +`Transfers` y el estado `200` en la operación, y luego *aun así* infirió +`TransferRequest` y `TransferResult` de la firma. Los metadatos y la inferencia se +componen: solo deletreas lo que la firma no puede expresar. + +### Etiquetas a nivel de controlador + +`#[rest_controller(tag = "…")]` establece una etiqueta por defecto para **cada** +operación del controlador, el análogo del `@Tag(name = …)` de Spring. Lumen etiqueta +toda su superficie de wallet: + +```rust,ignore +// src/web.rs +#[rest_controller(path = "/api/v1", tag = "Wallets")] +impl WalletApi { /* … */ } +``` + +La resolución de etiquetas por operación está en capas: + +1. un `tags = [...]` explícito por método gana; si no, +2. se aplica el valor por defecto de `#[rest_controller(tag)]`; si no, +3. el generador deriva una etiqueta del nombre del tipo del controlador quitando un + sufijo final `Api` / `Controller` (`WalletApi` → `Wallet`, `CatalogController` + → `Catalog`). + +Lumen establece la etiqueta del controlador explícitamente a `"Wallets"`, así que en +su especificación `open`, `get`, `deposit` y `withdraw` llevan la etiqueta +**Wallets** (el valor por defecto del controlador), mientras que `transfer`, +`transfer_compliance` y `transfer_2pc` llevan **Transfers** (su sobrescritura por +método `tags = ["Transfers"]`). Swagger UI agrupa las operaciones bajo esos dos +encabezados. + +### Sobrescribir la inferencia con `request = ` / `response = ` + +Cuando un tipo de cuerpo no puede leerse de la firma —un handler que toma un +`axum::body::Bytes` en bruto, devuelve un `impl IntoResponse` o de otro modo oculta +su DTO— nómbralo explícitamente. `request = T` / `response = T` toman el **nombre** +del esquema (el último segmento de path del tipo, coincidiendo con aquello bajo lo +que `#[derive(Schema)]` lo registra) y **tienen prioridad** sobre la inferencia: + +```rust,ignore +#[post("/import", summary = "Bulk import", request = ImportBatch, response = ImportReport)] +async fn import(/* a non-Json body */) -> impl axum::response::IntoResponse { /* … */ } +``` + +Lo que acaba de ocurrir: aunque la firma no revela ningún `Json`, la operación +ahora hace `$ref` a `ImportBatch` e `ImportReport` (siempre que ambos deriven +`Schema`). Lumen nunca necesita esto —el cuerpo de cada handler es un `Json` de un +tipo `#[derive(Schema)]`, así que la inferencia lo cubre—, pero la vía de escape está +ahí para los casos que una firma no puede expresar. + +## Paso 6 — Lee el ejemplo trabajado de principio a fin + +Juntándolo todo para el `WalletApi` de Lumen: + +- El controlador es `#[rest_controller(path = "/api/v1", tag = "Wallets")]`. +- Sus DTOs con `#[derive(Schema)]` —`OpenWallet`, `WalletView`, `AmountBody`, + `TransferRequest`, `TransferResult`, `TccTransferResult`— se convierten cada uno en + una entrada `#/components/schemas/*` y son referenciados con `$ref` por las + operaciones que los usan. +- La petición/respuesta de cada operación se infiere de su parámetro `Json` y de + su retorno; su summary / description / tags / status vienen del atributo del verbo; + y las operaciones `transfers/*` se agrupan bajo **Transfers**. +- `transfer_compliance` toma `Json` (un esquema registrado, así que + su petición hace `$ref` a `TransferRequest`) pero devuelve `Json`, + así que su respuesta no lleva **ningún** `$ref`, y eso es correcto, no una carencia. + +Cada operación obtiene además una respuesta `default` RFC 9457 que referencia +`#/components/schemas/ProblemDetail`, que el generador siempre añade al documento. +Así, la forma de error uniforme del [capítulo 6](./06-first-http-api.md) queda +documentada automáticamente para cada endpoint: Swagger UI muestra una respuesta de +error en cada operación sin que escribas ninguna. + +> **Tip** **Punto de control.** `curl -s localhost:8081/v3/api-docs | jq +> '.components.schemas | keys'` lista cada esquema registrado, incluyendo +> `ProblemDetail`. `jq '.paths."/api/v1/transfers".post.responses | keys'` muestra +> tanto la respuesta `200` como la `default` (problem). + +## Paso 7 — Una tabla de descriptores, tres superficies + +La tabla de descriptores de `#[rest_controller]` la leen **tres** superficies, de +modo que nunca pueden desviarse: + +- el **documento OpenAPI** en `/v3/api-docs`, +- la tabla de rutas **`/admin/api/mappings`** del panel de administración + ([Observabilidad y administración](./15-observability.md)), y +- el bloque `:: routes (N) ::` del **informe de arranque** + ([Bootstrap](./04b-bootstrap.md)). + +Añade una ruta, y las tres se actualizan a partir del mismo registro en el siguiente +build. El informe de arranque incluso imprime los recuentos de operaciones y +esquemas de componente para que puedas confirmar que la especificación está en vivo: + +```text +:: openapi :: N operations | K component schemas (served at /v3/api-docs) :: +``` + +Lo que acaba de ocurrir: como el documento, la vista de mappings de admin y el log +de arranque leen todos un único inventario, "lo que hace la API" tiene una única +fuente de verdad. No hay un segundo archivo de especificación mantenido a mano que se +quede atrás respecto a tu código. + +## Paso 8 — Exporta la especificación con la CLI + +La CLI de `firefly` puede escribir un documento OpenAPI para herramientas y CI: + +```bash +firefly openapi # OpenAPI 3.1 JSON to stdout +firefly openapi --format yaml -o openapi.yaml +``` + +Hay una salvedad de alcance que vale la pena entender (cubierta en su totalidad en +[La CLI](./19-cli.md)). Un binario *compilado* no puede arrancar una aplicación +arbitraria para enumerar sus rutas en vivo: las rutas viven en el propio crate del +consumidor, y no hay contenedor de inyección de dependencias que introspeccionar +desde una herramienta genérica. Así que `firefly openapi` emite un **esqueleto** +sellado con metadatos: el bloque `info` (leído de `firefly.yaml` / `Cargo.toml`), el +componente `ProblemDetail` siempre presente y `paths` vacío. La forma del cable es +idéntica a la que sirve una app en vivo, solo que la lista de rutas está en blanco. + +Para capturar las rutas **reales** de Lumen, ejecuta el servicio y obtén +`/v3/api-docs`. Ese documento, construido por el `from_inventory()` del framework, *es* +la especificación en vivo: + +```bash +cargo run --bin lumen & +curl -s http://localhost:8081/v3/api-docs | jq . +``` + +> **Tip** **Punto de control.** `firefly openapi | jq '.openapi'` imprime `"3.1.0"` +> incluso fuera de una app en ejecución, y `jq '.components.schemas.ProblemDetail'` +> está presente. El `paths` del esqueleto es `{}`; la especificación en vivo en +> `:8081/v3/api-docs` tiene tus rutas de wallet rellenadas. + +## Paso 9 — Genera un cliente tipado a partir de la especificación + +La dirección inversa: dado un documento OpenAPI, generar un cliente Rust tipado +sobre el `RestClient` del framework, el análogo en Rust del SDK WebClient generado a +partir de OpenAPI de springdoc. + +```bash +# capture the live spec, then generate a client from it +curl -s http://localhost:8081/v3/api-docs -o wallet-openapi.json +firefly openapi-client --spec wallet-openapi.json -o src/generated.rs --client-name WalletClient +``` + +Lo que acaba de ocurrir: el generador recorrió la especificación y emitió un `struct` +de modelo por cada esquema de objeto (y un `enum` por cada enumeración de cadena), +con los renombrados de serde y los campos opcionales preservados, más una `async fn` +por operación —parámetros de ruta/consulta tipados, un cuerpo de petición JSON y el +tipo de la respuesta de éxito—, cada una llamando a `RestClient` por debajo. El +cliente generado tiene la misma forma que el que escribirías a mano; la muestra por +capas `lumen-ledger` incluye exactamente un SDK así, que conocerás en +[Microservicios por capas](./22-layered-microservices.md). + +> **Tip** **Punto de control.** Tras el segundo comando, `src/generated.rs` existe y +> contiene un `pub struct WalletClient` más los modelos `WalletResponse` / +> `WalletStatus` que reflejan los esquemas de componente de la especificación. Las +> formas no mapeadas degradan a `serde_json::Value` en lugar de hacer fracasar la +> generación. + +## Resumen + +En este capítulo viste que el controlador que ya escribiste *es* la documentación de +la API: + +- Firefly sirve una especificación **OpenAPI 3.1** en vivo (`/v3/api-docs`, + con alias `/openapi.json`), **Swagger UI** (`/swagger-ui`) y **ReDoc** (`/redoc`) + en el puerto de **gestión**, construida a partir del inventario en el arranque con + cero código de aplicación. +- La especificación anuncia la **URL base de la API pública** como su `server` + (recurriendo a `localhost`, sobrescribible con `FIREFLY_OPENAPI_SERVER_URL`), de + modo que "Try it out" apunta a la API, no al origen de la documentación. +- `#[derive(Schema)]` convierte un DTO en un `#/components/schemas/{Type}` en tiempo + de expansión de macro, respetando serde `rename` / `rename_all` / `skip`, tratando + `Option` / `Box` / `Arc` / `Rc` como transparentes, mapeando colecciones a arrays y + mapas, haciendo `$ref` a DTOs anidados y renderizando los enums sin campos como + enumeraciones de cadenas. +- Los cuerpos de petición, los cuerpos de respuesta y los parámetros de + ruta/consulta/cabecera se **infieren** de la firma del handler (cuerpos `Json` / + `Valid`, parámetros de consulta `Query` y `PageRequest`, segmentos de ruta + `:id`, parámetros `header(...)` declarados), con un `$ref` emitido solo para + esquemas realmente registrados, de modo que el documento nunca queda colgando. +- Los metadatos por operación (`summary`, `description`, `tags`, `status`, + `deprecated`) y el `tag` a nivel de controlador dan forma a cada operación; + `request = ` / `response = ` sobrescriben la inferencia cuando una firma no puede + expresar el DTO. +- Cada operación lleva una respuesta `default` RFC 9457 `ProblemDetail`, de modo que + el contrato de error uniforme queda documentado automáticamente. +- Una única tabla de descriptores alimenta la especificación, la vista + `/admin/api/mappings` y el informe de arranque —una única fuente de verdad—, y los + comandos `firefly openapi` / `openapi-client` exportan la especificación y generan + un cliente tipado a partir de ella. + +Nada en `samples/lumen` cambió: las declaraciones de enrutamiento que ya escribiste +produjeron Swagger UI, ReDoc y una especificación OpenAPI 3.1 válida, gratis. + +## Ejercicios + +1. **Lee la especificación en vivo.** Con `cargo run` en ejecución, `curl -s + localhost:8081/v3/api-docs | jq '.paths | keys'`. Confirma que cada ruta de wallet + y de transferencia del capítulo 6 está presente, luego `jq '.components.schemas | + keys'` para ver cada DTO con `#[derive(Schema)]` más `ProblemDetail`. +2. **Observa cómo fluye un renombrado.** En `jq`, inspecciona + `.components.schemas.TransferResult.properties` y confirma que las claves de las + propiedades son `stepsExecuted` / `stepsRolledBack` (los nombres serde del cable), + no los identificadores en snake_case. Luego elimina temporalmente un + `#[serde(rename = "…")]` en `src/transfer.rs`, recompila y observa cómo cambia el + nombre de la propiedad del esquema. +3. **Mueve la URL del servidor.** Arranca Lumen con + `FIREFLY_OPENAPI_SERVER_URL=https://api.lumen.example cargo run`, luego + `curl -s localhost:8081/v3/api-docs | jq '.servers'`. Confirma que la URL cambió: + este es el valor que llamará el "Try it out" de Swagger UI. +4. **Deprecia una operación.** Añade la flag escueta `deprecated` a un atributo de + verbo en `src/web.rs` (p. ej. `#[post("/wallets/:id/withdraw", summary = "Withdraw funds", + status = 200, deprecated)]`), recompila y confirma que + `jq '.paths."/api/v1/wallets/{id}/withdraw".post.deprecated'` es `true` y que + Swagger UI tacha la operación. +5. **Exporta y compara.** Ejecuta `firefly openapi --format yaml -o skeleton.yaml`, + luego `curl -s localhost:8081/v3/api-docs | jq . > live.json`. Observa que el + esqueleto de la CLI tiene `paths` vacío mientras que el documento en vivo lleva tus + rutas, y que ambos comparten el mismo bloque `info` y el componente `ProblemDetail`. + +## Adónde ir después + +- Construye el modelo de lectura tras el `WalletView` que describen estas + documentaciones en + **[Persistencia y repositorios reactivos](./07-persistence.md)**. +- Mira dónde se construye y monta el documento OpenAPI en el pipeline de arranque en + **[Bootstrap](./04b-bootstrap.md)**, y la vista `/admin/api/mappings` con la que + comparte fuente en **[Observabilidad y administración](./15-observability.md)**. +- Consume un cliente generado a partir de OpenAPI contra un servicio upstream real en + **[Microservicios por capas](./22-layered-microservices.md)**, apoyándote en + **[Clientes HTTP](./13-http-clients.md)**. diff --git a/docs/book/src-es/07-persistence.md b/docs/book/src-es/07-persistence.md new file mode 100644 index 00000000..1663e46f --- /dev/null +++ b/docs/book/src-es/07-persistence.md @@ -0,0 +1,1111 @@ +# Persistencia y repositorios reactivos + +Lumen ya tiene una API de monederos que devuelve un `WalletView`, pero ¿de dónde +procede esa vista? Al final de [Tu primera API HTTP](./06-first-http-api.md) la +respuesta honesta era "de un mapa en memoria". Este capítulo dota a Lumen de un +vocabulario de persistencia real y muestra la ruta de actualización exacta desde +ese mapa didáctico hasta una base de datos duradera, sin tocar un solo punto de +llamada. + +El hilo conductor es un movimiento que el libro repite: *depende del contrato, +intercambia el backend*. El almacén de lectura de Lumen ya está planteado como un +repositorio; aquí aprendes el contrato del framework del que es una miniatura, la +superficie CRUD reactiva que transmite filas de forma perezosa, los adaptadores +relacional y documental que implementan esa superficie sobre Postgres / MySQL / +SQLite / MongoDB, y el límite transaccional que hace atómico un cambio con varias +escrituras. La build didáctica permanece libre de infraestructura todo el camino: +cada pieza duradera se ejercita contra un SQLite en memoria o un doble en +proceso, así que nada de lo aquí descrito necesita un servidor en ejecución. + +Al terminar este capítulo, serás capaz de: + +- Explicar el **patrón repositorio** como la costura entre el lado de consulta y + el almacenamiento, y reconocer el `ReadModel` de Lumen como un repositorio + artesanal en miniatura. +- Componer una consulta `Filter`, renderizarla como SQL parametrizado y leer un + sobre de resultado paginado `Page`. +- Manejar la **superficie CRUD reactiva** —repositorios `Mono` / `Flux`— contra + un doble en memoria y un adaptador SQLite/Postgres real con streaming. +- Declarar un repositorio al estilo de Spring Data con `#[derive(Entity)]` + + `#[derive(SqlxRepository)]`, y añadir consultas derivadas y personalizadas con + `#[firefly::repository]`. +- Activar el **bloqueo optimista** y construir un pool a partir de la + configuración con una sola llamada esperada a `auto_configure`. +- Hacer atómico un cambio con varias escrituras mediante + `#[firefly::transactional]` y su enlistamiento ambiental. + +## Conceptos que conocerás + +Antes del primer listado, estas son las ideas en las que se apoya este capítulo. +Cada una se reintroduce en contexto donde se usa por primera vez; esta es la +versión corta. + +> **Note** **Término clave — repositorio.** Un *repositorio* es un objeto que +> oculta cómo se almacenan las entidades tras un conjunto pequeño de operaciones +> que revelan la intención —`find_by_id`, `save`, `delete`—. Quienes lo llaman +> dependen de la *interfaz* del repositorio, no de SQL ni de un `HashMap`. Esto +> es exactamente el `Repository` / `CrudRepository` de Spring Data. + +> **Note** **Término clave — puerto y adaptador.** Un *puerto* es una interfaz de +> la que depende tu código; un *adaptador* es una implementación concreta elegida +> en el momento del cableado. `firefly-data` posee los puertos (los traits de +> repositorio, el DSL de consultas); `firefly-data-sqlx` y `firefly-data-mongodb` +> son adaptadores. Intercambiar el adaptador intercambia la base de datos sin +> cambiar los puntos de llamada: esto es *arquitectura hexagonal* (puertos y +> adaptadores). + +> **Note** **Término clave — `Mono` / `Flux`.** Estos son los *publishers* +> reactivos de [El modelo reactivo](./05-reactive-model.md): un `Mono` resuelve +> a lo sumo un valor, y un `Flux` a un flujo perezoso y con contrapresión de +> muchos. El repositorio reactivo los devuelve para que una lectura de base de +> datos pueda transmitirse fila a fila al cliente. Si has usado una biblioteca de +> reactive-streams (Project Reactor, RxJava), son los mismos `Mono` / `Flux`. + +> **Note** **Término clave — bloqueo optimista.** Una estrategia de concurrencia +> en la que cada fila lleva un número de *versión*; una escritura solo tiene éxito +> si la versión que cargó aún coincide con la almacenada; de lo contrario se +> rechaza en lugar de sobrescribir silenciosamente un cambio concurrente. Esto es +> el `@Version` de Spring Data. + +> **Design note.** La capa de datos de Firefly es el patrón repositorio expresado +> como Rust idiomático: dependes de un trait —`Repository` (bloqueante) o +> `ReactiveCrudRepository` (reactivo)— y un adaptador aporta el SQL. +> Depende del puerto, intercambia el backend. `firefly-data` en sí mismo no posee +> ningún driver ni implica ningún motor SQL; eso es lo que hace que el intercambio +> sea mecánico. + +## Paso 1 — Ver el almacén de lectura de Lumen como un repositorio + +Lumen separa su modelo de escritura de su modelo de lectura: la forma de +Segregación de Responsabilidad entre Comandos y Consultas (CQRS) que desarrollas +en [CQRS](./09-cqrs.md). El lado de escritura es el `Ledger` con event sourcing; +el lado de lectura es un `ReadModel` plano y optimizado para consultas que sirve +`GET /api/v1/wallets/:id`. En `samples/lumen` el modelo de lectura es un mapa en +memoria —pequeño, exacto y sin dependencias—, la línea base adecuada para +enseñar. + +Abre `samples/lumen/src/ledger.rs` y lee el tipo del modelo de lectura: + +```rust,ignore +// samples/lumen/src/ledger.rs — the CQRS query side. +use std::collections::HashMap; +use std::sync::Mutex; + +use firefly::prelude::*; +use crate::domain::WalletView; + +/// The in-memory read model: a map of wallet id → WalletView, upserted by the +/// projection and served by the GetWallet query. It carries +/// `#[derive(Repository)]` (Spring's `@Repository`), so `container.scan()` +/// registers it as a data-access singleton — autowired as `Arc` into +/// the handler and projection beans. A real service would back this with +/// firefly's reactive repository over Postgres; an in-memory map keeps the +/// teaching baseline dependency-free. +#[derive(Debug, Default, Repository)] +pub struct ReadModel { + rows: Mutex>, +} + +impl ReadModel { + /// Upserts a projected view, replacing any previous row for the id. + pub fn upsert(&self, view: WalletView) { + self.rows + .lock() + .expect("read model lock") + .insert(view.id.clone(), view); + } + + /// Looks a projected view up by id. + pub fn find(&self, id: &str) -> Option { + self.rows.lock().expect("read model lock").get(id).cloned() + } +} +``` + +Qué acaba de ocurrir: dos decisiones de diseño son deliberadas. + +- La superficie es un **repositorio en miniatura**. `upsert(view)` y `find(id)` + son las únicas operaciones que necesita el lado de consulta, así que esas son + las únicas operaciones que expone: un subconjunto artesanal de dos métodos del + contrato de repositorio del framework que conoces en el Paso 4. +- Las claves y los valores son tipos de dominio simples: un `WalletView` + indexado por su `id`. Así, cuando más adelante intercambies el mapa por un + adaptador de base de datos, la *forma* que ve el resto de Lumen no se mueve: + `find` sigue devolviendo `Option` y `upsert` sigue tomando uno. + +> **Note** **Término clave — `#[derive(Repository)]`.** Este derive marca un tipo +> como un bean de acceso a datos: el `@Repository` de Spring. El escaneo de +> componentes lo registra como singleton, de modo que se autoinyecta (como +> `Arc`) en el handler de consulta y en la proyección que lo alimenta. +> El derive trata sobre *cablear* el objeto en el contenedor; el almacenamiento +> que hay detrás es lo que contenga la struct, aquí un `Mutex>`. + +Por qué importa: el handler `GetWallet` depende de "dame la vista para este id", +no de un `HashMap`. Ese es todo el sentido de tratar el almacén de lectura como +un repositorio, y la razón por la que el resto de este capítulo puede sustituir +el mapa por una base de datos real sin que el handler lo note. + +> **Tip** **Punto de control.** Desde un checkout del framework, ejecuta +> `cargo test -p lumen --lib ledger` y observa cómo pasan las pruebas de ida y +> vuelta del modelo de lectura. Has confirmado que la línea base en memoria +> funciona antes de intercambiar nada por debajo. + +## Paso 2 — Componer una consulta con el DSL `Filter` + +Antes de bajar el almacén de lectura a una base de datos real, necesitas una +forma de *pedir* filas: un valor de consulta que los adaptadores puedan +renderizar a SQL. `firefly-data` proporciona uno: el DSL `Filter`. + +> **Note** **Término clave — DSL `Filter`.** Un `Filter` es un valor componible +> que agrupa una lista de predicados (campo, operador, valor), cero o más órdenes +> de clasificación y una ventana de página. Se renderiza como una cláusula `WHERE` +> parametrizada mediante `to_sql()` —nunca con interpolación de cadenas—, de modo +> que los valores se enlazan como `$1`, `$2`, … y la inyección de SQL es +> estructuralmente imposible. + +Construye una consulta de "monederos ricos" —`balance >= 100_000`, los más nuevos +primero, primera página de 20—: + +```rust +use firefly_data::{Direction, Filter, Op, Predicate}; +use serde_json::json; + +let filter = Filter::default() + .where_eq("owner", json!("alice")) + .add(Predicate { field: "balance".into(), op: Op::Gte, value: json!(100_000) }) + .order_by("version", Direction::Desc) + .paged(0, 20); + +let (where_clause, args) = filter.to_sql(); +// where_clause: a parameter-indexed " WHERE ..." fragment +// args: the bound values, in order +assert!(where_clause.contains("WHERE")); +assert_eq!(args.len(), 2); +``` + +Qué acaba de ocurrir, bloque a bloque: + +- `.where_eq("owner", json!("alice"))` añade un predicado de igualdad; es azúcar + para `.add(Predicate { field, op: Op::Eq, value })`. +- `.add(Predicate { … op: Op::Gte … })` añade el predicado `balance >= 100_000` + de forma explícita: cada operador es alcanzable de esta manera. +- `.order_by("version", Direction::Desc)` añade un orden de clasificación (los + más nuevos primero). +- `.paged(0, 20)` fija una ventana de página de base cero: página `0`, tamaño + `20`. +- `to_sql()` devuelve el par `(where_clause, args)`. Dos predicados produjeron + dos argumentos enlazados, que es lo que confirma `assert_eq!(args.len(), 2)`. + +Los operadores de `Op` cubren `Eq`, `Ne`, `Lt`, `Lte`, `Gt`, `Gte`, `Like`, +`ILike`, `In` e `IsNil`. `IsNil` renderiza `IS NULL` y **no** consume ninguna +ranura de argumento, de modo que una lista de predicados y su lista de argumentos +siempre permanecen alineadas. + +> **Note** `Filter::to_sql()` renderiza el valor predeterminado de PostgreSQL +> (marcadores `$1`, comillado `"id"`). `Filter::to_sql_with(&dialect)` renderiza +> el *mismo* árbol de consulta para otro backend: conoces `SqlDialect` en el Paso +> 6, donde es la costura que hace que una sola cadena de consulta se ejecute en +> tres bases de datos. + +> **Tip** **Punto de control.** Coloca ese fragmento en un `#[test]` y ejecútalo. +> Ambas aserciones pasan: la cláusula contiene `WHERE`, y se enlazaron exactamente +> dos valores. Ahora tienes un valor de consulta que los adaptadores del Paso 6 +> saben ejecutar. + +## Paso 3 — Leer el sobre `Page` + +Una consulta que pagina necesita una forma estable que devolver. `Page` es el +sobre canónico de resultado paginado con un layout JSON versionado, de modo que +cualquier cliente que respete el contrato lo deserializa de forma uniforme: un +SDK generado lo consume sin tratamiento específico por servicio: + +```rust,ignore +pub struct Page { + pub content: Vec, + pub number: usize, // zero-based page index + pub size: usize, + pub total_elements: u64, + pub total_pages: usize, // derived from total_elements / size +} +``` + +Qué acaba de ocurrir: un endpoint *listar monederos* —una extensión natural de +Lumen— devuelve un `Page` para que un cliente pueda paginar las +cuentas sin cargar nunca la tabla entera. `content` lleva las filas de esta +página; `number` / `size` hacen eco de la ventana solicitada; `total_elements` y +`total_pages` permiten que una interfaz de usuario renderice un paginador. + +> **Note** `Page` es el lado de *respuesta* de la paginación: lo que vuelve. +> También existe un lado de *petición*, `Pageable` (número de página, tamaño, +> orden), que conoces en el Paso 5. Mantenlos diferenciados: quien llama envía un +> `Pageable`, y un repositorio consciente del recuento devuelve un `Page`. + +## Paso 4 — Conocer el contrato de repositorio + +`ReadModel` es un subconjunto de dos métodos de un contrato real del framework. +Hay dos, que comparten la misma idea en capas distintas. + +El puerto **bloqueante**, `Repository`, es el contrato `async_trait` +object-safe; `MemoryRepository` lo implementa para pruebas, y un adaptador lo +respalda con un driver en producción: + +```rust,ignore +#[async_trait] +pub trait Repository: Send + Sync { + async fn find_by_id(&self, id: &K) -> Result; + async fn find(&self, filter: &Filter) -> Result, DataError>; + async fn save(&self, entity: T) -> Result; + async fn delete(&self, id: &K) -> Result<(), DataError>; + // find_page(&Pageable), count, … with defaults +} +``` + +Sobre él, `firefly-data` añade la superficie CRUD **reactiva**, construida sobre +`Mono` / `Flux`. Es puramente aditiva: nada de la API bloqueante `Repository` +cambia: + +| Método | Devuelve | +|------------------------------|---------------------------------------------| +| `find_all()` | `Flux` | +| `find_all_by_id(ids)` | `Flux` | +| `find_by_id(id)` | `Mono` | +| `exists_by_id(id)` | `Mono` | +| `save(e)` | `Mono` | +| `save_all(es)` | `Flux` | +| `delete_by_id(id)` | `Mono<()>` | +| `delete_all()` | `Mono<()>` | +| `count()` | `Mono` | +| `Specification` + `Pageable` | `ReactiveSpecificationRepository` (Paso 5) | + +Qué acaba de ocurrir: estos son los métodos de `ReactiveCrudRepository` de +Spring Data, nombre por nombre. Un detalle importa para el resto del capítulo: un +`find_by_id` "sin fila" resuelve a un `Mono` **vacío**, el equivalente reactivo de +que `ReadModel::find` de Lumen devuelva `None`. + +> **Note** **Término clave — `block()` / `collect_list()`.** Los publishers son +> perezosos: nada se ejecuta hasta que los conduces. En un contexto `async`, +> `Mono::block().await` conduce un `Mono` hasta su resultado, devolviendo +> `Result, FireflyError>` —`Ok(None)` es el fallo del `Mono` vacío—. +> `Flux::collect_list()` reúne un flujo en un `Mono>`, así que +> `flux.collect_list().block().await` devuelve `Result>, _>`. (Un +> `Mono` también implementa `IntoFuture`, de modo que puedes hacer +> `repo.save(x).await` directamente cuando lo prefieras). + +Este es el contrato del que el `ReadModel` de Lumen es un subconjunto artesanal. +Bájalo a `ReactiveCrudRepository` y `find_by_id` / `save` / +`count` vienen del framework. El siguiente paso hace exactamente eso, en memoria. + +## Paso 5 — Manejar la superficie reactiva en memoria + +`ReactiveMemoryRepository` es el gemelo reactivo de `MemoryRepository`: la forma +sin infraestructura de ejercitar la API reactiva real. Es la versión reactiva del +almacén de lectura de Lumen, que guarda vistas de monedero: + +```rust +use firefly_data::{ReactiveCrudRepository, ReactiveMemoryRepository}; + +#[derive(Clone, PartialEq, Debug)] +struct WalletView { id: String, owner: String, balance: i64, version: i64 } + +#[tokio::main] +async fn main() { + // The closure tells the repository how to read an entity's id. + let repo = ReactiveMemoryRepository::new(|w: &WalletView| w.id.clone()); + + // save -> Mono, driven with block(). + repo.save(WalletView { id: "wlt_1".into(), owner: "alice".into(), balance: 1000, version: 1 }) + .block().await.unwrap(); + + // find_all -> Flux, collected to a Vec. + let all = repo.find_all().collect_list().block().await.unwrap().unwrap(); + assert_eq!(all.len(), 1); + + // find_by_id miss -> empty Mono (Lumen's `ReadModel::find` returning None). + assert_eq!(repo.find_by_id("ghost".into()).block().await.unwrap(), None); + + // count -> Mono. + assert_eq!(repo.count().block().await.unwrap(), Some(1)); +} +``` + +Qué acaba de ocurrir, línea a línea: + +- `ReactiveMemoryRepository::new(|w| w.id.clone())` construye un almacén vacío + cuyos ids se derivan mediante la closure de extracción de clave. +- `repo.save(...).block().await.unwrap()` conduce el `Mono` de `save` hasta su + finalización; el `unwrap()` descarta el `Result`, y el `Some(view)` interno es + el valor persistido. +- `repo.find_all().collect_list().block().await.unwrap().unwrap()` encadena los + tres operadores reactivos: `find_all()` devuelve un `Flux`, `collect_list()` lo + pliega en un `Mono>`, y `block().await` lo conduce. El primer `unwrap()` + desenvuelve el `Result`, y el segundo desenvuelve el `Option>`. +- `repo.find_by_id("ghost".into()).block().await.unwrap()` es el caso de fallo: + resuelve a `None`, el contrato del `Mono` vacío del Paso 4. + +Por qué importa: intercambiar `ReadModel` por este repositorio es mecánico. +`upsert` pasa a ser `save`, `find` pasa a ser `find_by_id(...).block().await`, y +el handler `GetWallet` mantiene su forma `Option`. Acabas de demostrar +la costura sin ninguna base de datos en el bucle. + +### El ordenamiento y la paginación vienen gratis + +`ReactiveSortingRepository` añade ordenamiento y paginación de colección +completa —`find_all_sorted(RequestSort) -> Flux` y `find_all_paged(Pageable) +-> Flux`— y no escribes **ningún** código para ello. Es un `impl` general sobre +cualquier repositorio que sea a la vez un `ReactiveCrudRepository` y un +`ReactiveSpecificationRepository`, de modo que cada `ReactiveMemoryRepository` y +cada repositorio SQL lo adquieren automáticamente. + +> **Note** **Término clave — `Pageable` / `RequestSort`.** Estos son el lado de +> *petición* de la paginación (`Pageable` / `Sort` de Spring). +> `RequestSort::by(["owner"])` ordena de forma ascendente por un campo; +> `RequestSort::of([Order::desc("id")])` construye una lista de orden explícita. +> `Pageable::of(page, size, sort)` los agrupa y, de forma crucial, **`page` es +> de base 1** y la llamada devuelve un `Result` (una página fuera de rango es un +> error, no un panic), así que haces `.unwrap()` / `?` sobre ella. + +```rust +use firefly_data::{ + Pageable, ReactiveCrudRepository, ReactiveMemoryRepository, ReactiveSortingRepository, + RequestSort, +}; + +#[derive(Clone, PartialEq, Debug, serde::Serialize)] +struct WalletView { id: String, owner: String, balance: i64 } + +#[tokio::main] +async fn main() { + let repo = ReactiveMemoryRepository::new(|w: &WalletView| w.id.clone()); + for (id, owner) in [("w1", "carol"), ("w2", "alice"), ("w3", "bob")] { + repo.save(WalletView { id: id.into(), owner: owner.into(), balance: 0 }) + .block().await.unwrap(); + } + + // find_all_sorted(RequestSort) — ordered by owner ascending, streamed as a Flux. + let sorted = repo + .find_all_sorted(RequestSort::by(["owner"])) + .collect_list().block().await.unwrap().unwrap(); + assert_eq!(sorted[0].owner, "alice"); + + // find_all_paged(Pageable) — page 1 (1-based), size 2, sorted; a Flux window. + let page = repo + .find_all_paged(Pageable::of(1, 2, RequestSort::by(["owner"])).unwrap()) + .collect_list().block().await.unwrap().unwrap(); + assert_eq!(page.len(), 2); +} +``` + +Qué acaba de ocurrir: `find_all_sorted` ejecutó una `Specification` de tipo +match-all con el orden proyectado sobre ella; `find_all_paged` ejecutó la misma +con una ventana `LIMIT`/`OFFSET`. Observa `Pageable::of(1, 2, …).unwrap()`: la +página `1` es la *primera* página, y el `unwrap()` gestiona el `Result`. + +> **Note** `find_all_paged` transmite la página como una ventana `Flux` en lugar +> de almacenar en búfer un sobre `Page`. Recurre a `Page` (Paso 3) más una +> consulta de recuento cuando realmente necesites totales; recurre a la ventana +> con streaming cuando no. + +> **Tip** **Punto de control.** Ejecuta ambos `main` anteriores (cada uno como un +> pequeño binario o un `#[tokio::test]`). El primero comprueba un ciclo de ida y +> vuelta save/find/count y un fallo de `Mono` vacío; el segundo comprueba el orden +> de ordenar-luego-paginar. Cada repositorio reactivo en el resto del capítulo se +> comporta de forma idéntica: solo cambia el almacenamiento que hay detrás. + +## Paso 6 — Bajar a un repositorio SQL real con streaming + +El repositorio en memoria demuestra la *forma*. Ahora hazlo duradero. El +adaptador relacional, `firefly-data-sqlx`, sirve PostgreSQL, MySQL y SQLite desde +una sola base de código, y **SQLite-en-memoria es el valor predeterminado sin +infraestructura**: el mismo papel que desempeña el mapa en memoria en +`samples/lumen`, pero ejercitando el adaptador real. + +> **Note** **Término clave — enum `Db`.** `Db` etiqueta un pool de conexiones con +> su backend: `Db::Postgres(PgPool)`, `Db::MySql(MySqlPool)`, +> `Db::Sqlite(SqlitePool)`. El repositorio elige el `SqlDialect` correspondiente +> en tiempo de ejecución a partir de esa etiqueta, de modo que "nueva base de +> datos relacional" es un nuevo pool, no un nuevo adaptador. + +```rust +use firefly_data::{ReactiveCrudRepository, TableConfig}; +use firefly_data_sqlx::{AnyRow, ColumnValue, Db, SqlxReactiveRepository}; +use firefly_kernel::FireflyError; + +#[derive(Debug, Clone, PartialEq)] +struct WalletView { id: String, owner: String, balance: i64 } + +# tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); +sqlx::query(r#"CREATE TABLE "wallet_views" ("id" TEXT PRIMARY KEY, "owner" TEXT NOT NULL, "balance" BIGINT NOT NULL)"#) + .execute(&pool).await.unwrap(); + +let repo: SqlxReactiveRepository = SqlxReactiveRepository::new( + Db::Sqlite(pool), + TableConfig::new("wallet_views", "id", ["id", "owner", "balance"]), + // RowMapper: decode a WalletView from each row — backend-agnostic via AnyRow. + |row: &AnyRow| Ok::<_, FireflyError>(WalletView { + id: row.get_str("id")?, + owner: row.get_str("owner")?, + balance: row.get_i64("balance")?, + }), + // RowWriter: the entity's (column, value) pairs for the upsert. + |w: &WalletView| vec![ + ColumnValue::new("id", w.id.clone()), + ColumnValue::new("owner", w.owner.clone()), + ColumnValue::new("balance", w.balance), + ], +); +repo.save(WalletView { id: "wlt_1".into(), owner: "alice".into(), balance: 1000 }) + .block().await.unwrap(); +# }); +``` + +Qué acaba de ocurrir, argumento por argumento: + +- `Db::Sqlite(pool)` etiqueta el pool de SQLite; el repositorio lee su backend de + la etiqueta. +- `TableConfig::new("wallet_views", "id", ["id", "owner", "balance"])` nombra la + tabla, su columna id y las columnas a proyectar: el `RowMapper` debe decodificar + filas con la forma de exactamente estas columnas. +- La closure **`RowMapper`** decodifica una fila. `AnyRow` es el envoltorio de + fila agnóstico del backend; `get_str` / `get_i64` leen columnas por nombre, así + que la misma closure funciona en los tres backends relacionales. +- La closure **`RowWriter`** produce los pares `(columna, valor)` que el adaptador + renderiza en un `UPSERT` consciente del dialecto (`ON CONFLICT … DO UPDATE` para + Postgres/SQLite, `ON DUPLICATE KEY UPDATE` para MySQL). + +Por qué importa: cambiar a Postgres o MySQL es `Db::Postgres(pg_pool)` / +`Db::MySql(my_pool)`: los puntos de llamada del repositorio no cambian. Esa es la +ruta de actualización que promete el comentario de `samples/lumen`: el `ReadModel` +en memoria pasa a ser un `SqlxReactiveRepository`, y el +handler `GetWallet` no se entera. Las lecturas se transmiten desde el flujo de +filas de sqlx a un `Flux`, de modo que una tabla de un millón de filas nunca cae +entera en memoria. + +> **Design note.** Esto es arquitectura hexagonal: puertos y adaptadores. Tu +> servicio depende del *puerto* del repositorio (`ReactiveCrudRepository`); el +> *adaptador* (el crate relacional, el crate de Mongo) se elige en el momento del +> cableado. Cambiar Postgres por MySQL es un cambio de pool, no un cambio de +> código: los puntos de llamada nunca se mueven. Añadir una base de datos *nueva* +> es "escribir un crate `firefly-data-` que implemente los puertos", no +> "reescribir la capa de datos". `firefly-data` incluye tres impls de +> `SqlDialect` (`PostgresDialect`, `MySqlDialect`, `SqliteDialect`) y un descenso +> `Specification::to_mongo()`, de modo que el mismo árbol de consulta se renderiza +> correctamente por backend. + +> **Tip** **Punto de control.** Ejecuta ese fragmento como una prueba. Crea una +> tabla `wallet_views` en `sqlite::memory:`, guarda una fila a través del +> adaptador real y vuelve sin error: el almacén de lectura de producción, +> ejercitado de extremo a extremo con cero infraestructura externa. + +El constructor toma un `Db`, un `TableConfig`, un `RowMapper` y un `RowWriter`; +tres builders encadenables añaden comportamiento transversal, cada uno +devolviendo un repositorio nuevo: + +- `.with_auditor(Auditor)` — estampa `created_at` / `updated_at` / `created_by` / + `updated_by` en cada escritura (insert frente a update se decide según si la + fila ya existe): auditoría automática. +- `.with_soft_delete(SoftDeletePolicy)` — oculta las filas con borrado lógico de + cada lectura y convierte `delete_by_id` en una estampa `deleted_at` en lugar de + un `DELETE` físico: borrado lógico (soft delete). +- `.with_version_column("version")` — activa el bloqueo optimista (Paso 8). + +## Paso 7 — Declarar el repositorio al estilo de Spring Data + +Rara vez construyes el repositorio a mano como en el Paso 6. Para una entidad +tipada, dos derives te dan la experiencia de Spring Data "declara un repositorio, +obtén la implementación". Así es exactamente como el sample +[`lumen-ledger`](./22-layered-microservices.md) cablea su persistencia. + +> **Note** **Término clave — `#[derive(Entity)]`.** Este derive genera el mapeo +> `@Table` / `@Id` / `@Version` / `@Column` de una entidad a partir de sus campos. +> Las columnas escalares (`String`, `i64`, `Uuid` como texto, `DateTime` como +> texto) se mapean automáticamente; un campo no escalar (un enum tipado) usa +> `#[firefly(with(read = "...", write = "..."))]` para nombrar sus convertidores: +> el límite `@Enumerated(STRING)`. + +```rust,ignore +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, firefly::Entity)] +#[firefly(table = "wallets")] +pub struct Wallet { + #[firefly(id)] + pub id: Uuid, + pub account_number: String, + pub owner: String, + pub balance: i64, + pub currency: String, + // A typed enum maps through explicit converters — @Enumerated(STRING). + #[firefly(with(read = "WalletStatus::from_token", write = "WalletStatus::as_str"))] + pub status: WalletStatus, + // Optimistic-locking version (@Version) — bumped by the store on update. + #[firefly(version)] + pub version: i64, + // Audit stamps, managed by the store's Auditor. + pub created_at: DateTime, + pub updated_at: DateTime, +} +``` + +Luego `#[derive(SqlxRepository)]` sobre una struct que contiene el +`SqlxReactiveRepository` de la entidad: + +```rust,ignore +use firefly::data::{DataError, Pageable}; +use firefly::data_sqlx::SqlxReactiveRepository; +use uuid::Uuid; + +#[derive(firefly::SqlxRepository)] +pub struct WalletRepository { + repo: SqlxReactiveRepository, +} +``` + +Qué acaba de ocurrir: `#[derive(SqlxRepository)]` registra `WalletRepository` +como un bean `@Repository` **construido a partir del datasource `Db` +inyectado** (cableando el bloqueo `@Version` de la entidad y la auditoría +`@CreatedDate`/`@LastModifiedDate`), e implementa `ReactiveCrudRepository` por +delegación. No hay factoría `#[bean]` ni CRUD escrito a mano: el derive construye +el `SqlxReactiveRepository` interno a partir del `Db` autoinyectado, exactamente +como el `interface WalletRepository extends ReactiveCrudRepository` +de Spring Data. + +> **Note** **Término clave — id `Uuid` (cualquiera).** El `ID` del repositorio no +> tiene cota, como el `CrudRepository` de Spring Data: el adaptador sqlx +> acepta cualquier clave `serde::Serialize` a través de su trait `SqlKey`, de modo +> que un `Uuid`, un `i64`, un `String`, un enum o una struct de clave compuesta +> funcionan todos sin baile de newtypes. La clave se enlaza en su forma serde-JSON +> contra la columna id. + +### Consultas derivadas y personalizadas — `#[firefly::repository]` + +Más allá del CRUD, la macro `#[firefly::repository]` deriva una consulta +directamente a partir de un *nombre de método*: `find_by_owner(&str)` pasa a ser +`WHERE owner = ?`. Aplícala a un bloque `impl` de métodos stub tipados; la macro +descarta el cuerpo de marcador de posición (`unimplemented!()`) y genera uno real +que ordena los argumentos y delega en el motor de ejecución. El **tipo de retorno +selecciona la operación**: + +| Forma de retorno | Llamada generada | +|---------------------------------|---------------------------| +| `Result, DataError>` | `find_by_derived` | +| `Result, DataError>` | `find_by_derived` (primer)| +| `Result` | `count_by_derived` | +| `Result` | `exists_by_derived` | +| `Result` | `delete_by_derived` | + +Este es el bloque real de consultas derivadas del `WalletRepository` de +`lumen-ledger`: + +```rust,ignore +use firefly::data::{DataError, Pageable}; + +#[firefly::repository] +impl WalletRepository { + /// `SELECT … WHERE owner = ?` — every wallet of one owner. + pub async fn find_by_owner(&self, owner: &str) -> Result, DataError> { + unimplemented!() + } + + /// `SELECT COUNT(*) WHERE status = ?`. + pub async fn count_by_status(&self, status: &str) -> Result { + unimplemented!() + } + + /// Paged `SELECT … WHERE status = ?` — a trailing `Pageable` makes it paged. + pub async fn find_by_status( + &self, + status: &str, + page: Pageable, + ) -> Result, DataError> { + unimplemented!() + } +} +``` + +Qué acaba de ocurrir: los *nombres* de los métodos son la gramática —prefijo +(`find` / `count` / `exists` / `delete`), luego `By`, y luego una cadena de +condiciones de propiedad `And` / `Or` (`find_by_owner_and_status`)—. Un método +cuyo **último argumento es un `Pageable`** es una consulta derivada *paginada*: el +orden y la ventana del pageable se añaden al `WHERE` generado. Construye la página +con `Pageable::of(page, size, sort)`: recuerda que `page` es de **base 1** y la +llamada devuelve un `Result`: + +```rust,ignore +use firefly::data::{Pageable, RequestSort}; + +# async fn ex(wallets: &WalletRepository) -> Result<(), firefly::data::DataError> { +let page = Pageable::of(1, 20, RequestSort::by(["account_number"])).unwrap(); +let active = wallets.find_by_status("active", page).await?; +# Ok(()) +# } +``` + +Cuando la gramática de nombres no puede expresar la consulta, anota el stub con +`#[query(...)]` y escribe tú mismo el SQL. Un marcador de posición `:name` enlaza +el argumento llamado `name`, y el tipo de retorno selecciona la operación +exactamente igual que para los métodos derivados —`Vec` / `Option` para una +lista, `i64` para un recuento, `bool` para un exists, `u64` para una sentencia +*modificadora* (el recuento de filas afectadas)—: + +```rust,ignore +use firefly::data::DataError; + +#[firefly::repository] +impl WalletRepository { + // Native SQL; :status binds to the `status` argument. + #[query("SELECT id, owner FROM wallets WHERE status = :status ORDER BY id DESC")] + async fn list_by_status(&self, status: &str) -> Result, DataError> { + unimplemented!() + } + + // u64 return -> a modifying statement; the value is the affected-row count. + #[query("UPDATE wallets SET status = :to WHERE status = :from")] + async fn retire(&self, from: &str, to: &str) -> Result { + unimplemented!() + } +} +``` + +Qué acaba de ocurrir: `#[query("…")]` es una abreviatura de `#[query(sql = "…")]` +(SQL nativo). Para una consulta portable y orientada a entidades, usa la forma +similar a JPQL `#[query(jpql = "…", entity = "Wallet")]`, cuyo `FROM ` se +transpila a la tabla configurada para que la misma cadena se ejecute en Postgres, +MySQL o SQLite. Por debajo, el parser de nombres de método baja una consulta +derivada a través del `SqlDialect` activo, y `#[query]` baja a los helpers +`query_list` / `query_count` / `query_exists` / `query_execute` del repositorio. + +> **Tip** Recurre a la gramática de nombres de método para predicados sencillos y +> a un `Pageable` final para lecturas paginadas; usa `#[query(...)]` para +> cualquier cosa que la gramática de nombres no pueda expresar. Un servicio +> relacional `Account` / `Order` escribe estas; el propio lado de lectura de +> Lumen, con event sourcing, permanece como un `ReadModel` artesanal en memoria +> porque sus necesidades de consulta son exactamente dos métodos. + +## Paso 8 — Activar el bloqueo optimista + +Una entidad versionada necesita protección contra actualizaciones perdidas. +Nombrar la columna de versión en el repositorio convierte un `save` en un +**upsert condicional protegido por versión**: cada escritura incrementa la versión +y protege la actualización-en-conflicto sobre la versión con la que se cargó la +entidad (`WHERE version = `). Si un escritor concurrente avanzó la versión +almacenada, la actualización protegida coincide con cero filas y el save se +rechaza en lugar de sobrescribir silenciosamente el otro cambio. + +```rust,ignore +use firefly_data::{DataError, Repository, TableConfig}; +use firefly_data_sqlx::{AnyRow, ColumnValue, Db, SqlxRepository}; +use firefly_kernel::FireflyError; + +#[derive(Debug, Clone)] +struct Account { id: String, balance: i64, version: i64 } + +# async fn ex(pool: sqlx::PgPool) -> Result<(), Box> { +let repo: SqlxRepository = SqlxRepository::new( + Db::Postgres(pool), + TableConfig::new("accounts", "id", ["id", "balance", "version"]), + |row: &AnyRow| Ok::<_, FireflyError>(Account { + id: row.get_str("id")?, + balance: row.get_i64("balance")?, + version: row.get_i64("version")?, + }), + |a: &Account| vec![ + ColumnValue::new("id", a.id.clone()), + ColumnValue::new("balance", a.balance), + // The loaded version — the conditional upsert guards on it. + ColumnValue::new("version", a.version), + ], +) +.with_version_column("version"); + +// Two callers loaded the same Account at version 1. The first save wins +// (the row is now version 2); the second save's guard (WHERE version = 1) +// matches nothing, so it fails with OptimisticLock — the caller reloads + retries. +let stale = repo.save(Account { id: "acc_1".into(), balance: 50, version: 1 }).await; +assert!(matches!(stale, Err(DataError::OptimisticLock))); +# Ok(()) +# } +``` + +Qué acaba de ocurrir: `.with_version_column("version")` hizo que cada `save` +fuera condicional respecto a la versión cargada. El `SqlxRepository::save` +bloqueante expone una escritura obsoleta como `DataError::OptimisticLock`; el +`save` reactivo la expone a través de su canal `FireflyError` (un 409), que +`firefly_data_sqlx::is_optimistic_lock(&err)` detecta para que un servicio pueda +mapearlo a un conflicto de dominio. (La detección de conflictos se aplica en +Postgres y SQLite; en MySQL la versión se incrementa pero la protección no se +aplica). + +> **Note** Una escritura obsoleta falla en lugar de sobrescribir silenciosamente +> un cambio concurrente; quien llama recarga y reintenta. El lado de *escritura* +> de Lumen alcanza la misma garantía contra actualizaciones perdidas de forma +> distinta —a través del `append` de concurrencia optimista del event store (ver +> [Event Sourcing](./11-event-sourcing.md))—, pero un repositorio relacional +> `Account` / `Order` usa una columna de versión. + +> **Tip** **Punto de control.** El +> `models/src/repositories/wallet/v1/wallet_repository.rs` de `lumen-ledger` tiene +> una prueba, `optimistic_locking_rejects_a_stale_write`, que carga una fila dos +> veces, escribe una vez a través de cada handle y comprueba que la segunda es un +> conflicto `is_optimistic_lock`. Ejecútala: `cargo test -p lumen-ledger-models`. + +## Paso 9 — Construir el pool a partir de la configuración + +En cada fragmento hasta ahora el pool se construía a mano. En un servicio real, +los ajustes de conexión viven en la configuración, y Firefly los convierte en un +pool en vivo —más un gestor de transacciones registrado— en una sola llamada +esperada al arranque. No hay ningún contenedor de inyección de dependencias en el +bucle: cargas la configuración, enlazas una struct `serde` simple y haces `await` +sobre una función. + +`DataSourceProperties` es esa struct, enlazada desde el árbol de configuración +`firefly.datasource.*`: + +```rust,ignore +use firefly_data_sqlx::DataSourceProperties; + +// Bound from `firefly.datasource.*` (e.g. an application.yaml / env overrides). +pub struct DataSourceProperties { + pub url: String, // scheme picks the backend (see below) + pub max_connections: u32, // `0` leaves the driver default + pub min_connections: u32, // `0` leaves the driver default + pub acquire_timeout_ms: u64, // `0` leaves the driver default + pub idle_timeout_ms: u64, // `0` leaves the driver default + pub max_lifetime_ms: u64, // `0` leaves the driver default +} +``` + +Qué acaba de ocurrir: el **esquema de la URL selecciona el backend** (cada uno +tras su feature de cargo): `postgres://` / `postgresql://` → PostgreSQL, +`mysql://` → MySQL, `sqlite:` → SQLite. Así, un cambio de configuración de +`postgres://…` a `mysql://…` mueve todo el servicio a MySQL sin editar código: la +promesa de base de datos enchufable, dirigida desde la configuración. + +Tres puntos de entrada construyen el `Db`: + +- `Db::connect(url).await -> Result` — un pool a partir de una + URL, usando los valores predeterminados del driver. +- `Db::connect_with(&props).await` — un pool que respeta el `DataSourceProperties` + completo (tamaños, timeouts, lifetimes). +- `data_sqlx::auto_configure(&props).await` — la **ruta de arranque de una sola + llamada**: construye el pool *y* registra un `SqlxTransactionManager`, de modo + que `#[firefly::transactional]` resuelve su gestor sin cableado manual. El `Db` + devuelto construye luego tus repositorios tipados. + +La forma de una secuencia de arranque es: cargar configuración → enlazar +`DataSourceProperties` → `await auto_configure` una vez → construir repositorios a +partir del `Db` devuelto: + +```rust,ignore +use firefly_data::TableConfig; +use firefly_data_sqlx::{auto_configure, AnyRow, ColumnValue, DataSourceProperties, SqlxReactiveRepository}; +use firefly_kernel::FireflyError; + +#[derive(Debug, Clone, PartialEq)] +struct WalletView { id: String, owner: String, balance: i64 } + +# async fn boot(props: DataSourceProperties) -> Result<(), Box> { +// One awaited call: builds the pool AND registers the SqlxTransactionManager. +let db = auto_configure(&props).await?; + +// The returned Db builds typed repositories — no DI container involved. +let wallets: SqlxReactiveRepository = SqlxReactiveRepository::new( + db.clone(), + TableConfig::new("wallet_views", "id", ["id", "owner", "balance"]), + |row: &AnyRow| Ok::<_, FireflyError>(WalletView { + id: row.get_str("id")?, + owner: row.get_str("owner")?, + balance: row.get_i64("balance")?, + }), + |w: &WalletView| vec![ + ColumnValue::new("id", w.id.clone()), + ColumnValue::new("owner", w.owner.clone()), + ColumnValue::new("balance", w.balance), + ], +); +// Because auto_configure registered the manager, a `#[firefly::transactional]` +// fn that writes through `wallets` is now atomic with no further wiring. +# Ok(()) +# } +``` + +Qué acaba de ocurrir: `auto_configure(&props)` hizo los dos trabajos de arranque a +la vez —construyó el pool y registró el `SqlxTransactionManager` en el proceso—, +de modo que el límite transaccional del Paso 10 no necesita cableado adicional. + +> **Design note.** La configuración dirige el runtime, no un contenedor. Una +> struct `serde` simple se enlaza desde `firefly.datasource.*`, y un único +> `auto_configure` esperado construye el pool y registra el gestor de +> transacciones: el cableado es explícito, comprobado por el compilador y visible +> en un solo lugar, en lugar de ensamblado por reflexión en tiempo de ejecución. + +### Migraciones de esquema + +La tabla que leen esos repositorios necesita existir primero. `firefly-migrations` +es un ejecutor de migraciones SQL solo hacia adelante. Los ficheros se nombran +`V{version}__{description}.sql` (p. ej. `V001__init.sql`); cada uno se ejecuta una +vez, en orden de versión, dentro de una transacción: + +```rust,ignore +use firefly_migrations::{run, DirSource}; + +let src = DirSource { dir: "migrations".into() }; +run(&mut db, &src)?; // applies pending migrations in order +let status = firefly_migrations::inspect(&mut db, &src)?; // applied + pending +``` + +La [CLI](./19-cli.md) lo envuelve: `firefly db init`, +`firefly db migrate -m "create wallet_views"`, +`firefly db upgrade --url sqlite://lumen.db` y `firefly db status`. Un +`V001__wallet_views.sql` que cree la tabla del modelo de lectura es todo el +esquema que necesita el lado de lectura duradero de Lumen. + +## Paso 10 — Hacer atómico un cambio con varias escrituras + +Un único `save` es atómico por sí solo. Una *transferencia* —cargar una cuenta, +abonar otra, escribir dos asientos contables— debe ser atómica en su conjunto: +las cuatro escrituras se confirman, o ninguna lo hace. Para eso sirve +`#[firefly::transactional]`. + +> **Note** **Término clave — `#[firefly::transactional]`.** Anota una `async fn` +> que devuelve `Result<_, E>` (donde `E: From`) y el cuerpo se ejecuta +> dentro de una transacción: **commit en `Ok`, rollback en `Err`**. Esto es el +> `@Transactional` de Spring, hecho declarativo en Rust. + +```rust,ignore +use firefly::transactional::TxError; +use firefly_data_sqlx::SqlxReactiveRepository; + +#[derive(Debug, Clone)] struct Account { id: String, balance: i64 } +#[derive(Debug, Clone)] struct Entry { id: String, account: String, delta: i64 } + +#[firefly::transactional] // defaults: REQUIRED, datasource isolation, read-write +async fn transfer( + accounts: &SqlxReactiveRepository, + ledger: &SqlxReactiveRepository, + from: Account, + to: Account, + amount: i64, +) -> Result<(), TxError> { + // All four writes enlist in the same ambient transaction. If any await + // returns Err, the whole unit of work rolls back; otherwise it commits. + accounts.save(Account { balance: from.balance - amount, ..from.clone() }) + .into_future().await?; + accounts.save(Account { balance: to.balance + amount, ..to.clone() }) + .into_future().await?; + ledger.save(Entry { id: "e1".into(), account: from.id, delta: -amount }) + .into_future().await?; + ledger.save(Entry { id: "e2".into(), account: to.id, delta: amount }) + .into_future().await?; + Ok(()) +} +``` + +Qué acaba de ocurrir, y por qué es transparente: + +> **Note** **Término clave — enlistamiento ambiental.** Mientras un ámbito +> transaccional está activo, el gestor guarda la transacción abierta en un +> task-local. Cada escritura de `SqlxReactiveRepository` / `SqlxRepository` +> *dentro de la fn* se enruta a esa transacción activa en lugar de a una conexión +> fresca del pool. Así, una secuencia simple de llamadas `repo.save(...).await?` +> es atómica **sin cambiar el código del repositorio**: no enhebras una conexión +> ni un `&mut Tx` a través de cada llamada. + +El atributo acepta el vocabulario completo de Spring: `propagation` (`required` / +`requires_new` / `nested` / `supports` / `not_supported` / `mandatory` / +`never`), `isolation` (`read_committed` / `repeatable_read` / `serializable` / +…), `read_only`, `timeout_ms` y `manager = ""` —el +`@Transactional("txManager")` de Spring, que se ejecuta contra un +`TransactionManager` explícito (p. ej. `self.tx_manager()`) en lugar del registro +global del proceso—. Esto es exactamente lo que hace el +`WalletServiceImpl::transfer_tx` de `lumen-ledger`: + +```rust,ignore +// lumen-ledger/core/src/services/wallet/v1/wallet_service_impl.rs (excerpt) +#[firefly::transactional(manager = "self.tx_manager()")] +async fn transfer_tx(&self, from: Uuid, to: Uuid, amount: i64) + -> Result +{ + // … preconditions checked before any write … + let saved_source = self.persist(source).await?; // debit + self.persist(dest).await?; // credit — if this fails, + Ok(saved_source) // the debit rolls back +} +``` + +### Reglas de rollback — nombrar un patrón, no un tipo de excepción + +Por defecto, cada `Err` hace rollback. Spring nombra *tipos* de excepción para +refinar eso; como el `Result` de Rust ya separa el fallo del éxito, el análogo de +Firefly nombra un **patrón** de error (cualquier patrón de match para el tipo de +error de la fn, alternativas `A | B` incluidas). Entonces: + +- `no_rollback_for = "P"` — **el `noRollbackFor` de Spring**: un `Err` que + coincide con `P` **confirma** en lugar de hacer rollback; +- `rollback_only_for = "P"` — hace rollback **solo** para los errores que + coinciden con `P`, confirmando el resto; +- con ambos, `no_rollback_for` gana en caso de solapamiento. + +```rust,ignore +// Persist the audit row even when the domain rejects the charge, but still roll +// back on any infrastructure failure — @Transactional(noRollbackFor = …). +#[firefly::transactional(no_rollback_for = "BillingError::Rejected(_)")] +async fn charge(&self, req: Charge) -> Result { + self.audit.save(/* … */).await?; // committed even on a Rejected error + self.gateway.settle(req).await // a Backend error still rolls back +} +``` + +> **Warning** No existe `rollback_for`. El `rollbackFor` de Spring es *aditivo*: +> añade tipos de excepción a las runtime-exceptions que ya hacen rollback. Rust no +> tiene división checked/unchecked (cada `Err` hace rollback por defecto), así que +> una regla aditiva sería inocua. Por eso `rollback_only_for` se nombra así para +> señalar que *restringe* (en lugar de ampliar) el conjunto de rollback, de modo +> que un port de Spring nunca se invierte silenciosamente. Escribir `rollback_for` +> es un error de compilación amigable que te apunta a las dos reglas anteriores. + +Para control programático existen `firefly::transactional::transactional(opts, f)` +y `transactional_on(&manager, opts, f)` para un gestor explícito, con builders +`TxOptions`, `Propagation` e `Isolation`. El adaptador sqlx — +`SqlxTransactionManager`, registrado una vez al arranque (la ruta `auto_configure` +del Paso 9 lo hace por ti)— aporta el comportamiento real: propagación completa, +aislamiento, read-only, un timeout de sentencia y anidamiento `NESTED` basado en +`SAVEPOINT`. + +> **Tip** **Punto de control.** La protección contra escrituras parciales está +> probada de extremo a extremo en el `tests/transactional.rs` de +> `firefly-data-sqlx` y en las pruebas de servicio de `lumen-ledger`: una +> transferencia cuyo abono falla tras el cargo deja *ambas* cuentas sin cambios. +> Ejecuta `cargo test -p lumen-ledger-core` para verlo. + +### Almacén documental — `firefly-data-mongodb` + +Los mismos puertos alcanzan una base de datos documental. `MongoRepository` +pone una colección de MongoDB tras los **mismos** traits `ReactiveCrudRepository` ++ `ReactiveSpecificationRepository`, bajando una `Specification` mediante +`Specification::to_mongo()` exactamente como los adaptadores relacionales la bajan +mediante `to_sql`. Un mixin `BaseDocument` (embebido con `#[serde(flatten)]`) +lleva las estampas de auditoría y la columna de borrado lógico, y las lecturas se +transmiten de forma perezosa desde el cursor del driver como un `Flux`: + +```rust,no_run +use firefly_data::ReactiveCrudRepository; +use firefly_data_mongodb::{BaseDocument, MongoRepository}; +use mongodb::bson::{Bson, Document}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +struct WalletDocument { + #[serde(rename = "_id")] id: String, + owner: String, + balance: i64, + #[serde(flatten)] base: BaseDocument, +} + +# async fn run() -> Result<(), Box> { +let client = mongodb::Client::with_uri_str("mongodb://localhost:27017").await?; +let collection = client.database("lumen").collection::("wallet_views"); +let repo: MongoRepository = + MongoRepository::new(collection, |w: &WalletDocument| Bson::String(w.id.clone())); + +repo.save(WalletDocument { + id: "wlt_1".into(), owner: "alice".into(), balance: 1000, base: BaseDocument::new(), +}).block().await?; +# Ok(()) +# } +``` + +Qué acaba de ocurrir: como los cuatro backends se sitúan tras los mismos puertos, +un servicio que codifica contra `ReactiveCrudRepository` / `Specification` se mueve +de Postgres a MySQL, SQLite o MongoDB intercambiando el constructor del adaptador. + +> **Note** Lumen no incorpora ninguno de estos adaptadores en su build +> predeterminada: son features de cargo opcionales en la fachada `firefly` +> (`firefly = { version = "26.6", features = ["data-sqlx"] }`), reexportadas como +> `firefly::data_sqlx` / `firefly::data_mongodb`. La build didáctica permanece +> ligera; la build de producción añade exactamente el driver que necesita. Esta es +> la misma historia de una sola dependencia de [Quickstart](./02-quickstart.md): +> ningún starter que olvidar, ningún desfase de versiones. + +## Resumen + +Lumen tiene ahora una historia de persistencia clara, aunque su build didáctica +permanezca libre de infraestructura: + +- **El almacén de lectura es un repositorio.** `ReadModel` (en + `samples/lumen/src/ledger.rs`) es un bean de acceso a datos `#[derive(Repository)]` + que envuelve un `Mutex>` y expone exactamente + `upsert(view)` y `find(id)` —las dos operaciones que necesita el lado de + consulta—. El handler `GetWallet` depende del *contrato*, no del mapa. +- **La línea base es en memoria por elección.** El mapa mantiene la huella de + dependencias en un solo crate de Firefly; el comentario del código nombra la + actualización explícitamente. +- **La actualización es un intercambio de adaptador.** Bajar `ReadModel` a + `ReactiveCrudRepository` —`SqlxReactiveRepository` para + Postgres/MySQL/SQLite, `MongoRepository` para Mongo— convierte `upsert` en + `save` y `find` en `find_by_id`, sin cambiar la forma `Option` del + handler. Una nueva base de datos es un nuevo pool (relacional) o un nuevo crate + de adaptador, nunca una reescritura. + +También sabes ahora: + +- Cómo componer un `Filter`, renderizarlo con `to_sql()` y leer un `Page`. +- Que la superficie reactiva devuelve `Mono` / `Flux`; los conduces con + `block().await` (→ `Result, _>`) y `collect_list()`, y un fallo es un + `Mono` vacío. +- Que `Pageable::of(page, size, sort)` es de **base 1** y devuelve un `Result`. +- Que `#[derive(Entity)]` + `#[derive(SqlxRepository)]` te dan un repositorio al + estilo de Spring Data, y `#[firefly::repository]` añade consultas derivadas y + consultas `#[query(...)]`. +- Que `with_version_column` es el bloqueo optimista `@Version`, que + `auto_configure` construye el pool y registra el gestor de transacciones en una + sola llamada esperada, y que `#[firefly::transactional]` hace atómico un cambio + con varias escrituras mediante enlistamiento ambiental, con *patrones* de + rollback, no `rollback_for`. + +## Ejercicios + +1. **Reviste `ReadModel` como un trait.** Define + `trait WalletViews { fn upsert(&self, v: WalletView); fn find(&self, id: &str) -> Option; }`, + impleméntalo para el `ReadModel` en memoria y haz que el handler `GetWallet` + tome `&dyn WalletViews`. Confirma que el resto de Lumen sigue compilando: prueba + de que el lado de consulta depende del contrato, no del mapa. + +2. **Respalda el modelo de lectura con SQLite.** Usando el listado de + `SqlxReactiveRepository` del Paso 6, crea una tabla `wallet_views` en + `sqlite::memory:`, haz `save` de dos vistas y `find_all().collect_list()` sobre + ellas. Comprueba que ambas vuelven. Este es el almacén de lectura de + producción, ejercitado de extremo a extremo contra el adaptador real sin + infraestructura externa. + +3. **Pagina los monederos.** Construye un `Filter` que seleccione monederos con + `balance >= 100_000` ordenados por `version` descendente, página `(0, 20)`, e + imprime su `to_sql()`. Luego describe (una frase cada uno) cómo se renderizaría + el mismo filtro bajo `MySqlDialect` y `SqliteDialect` mediante `to_sql_with`. + +4. **Añade una consulta derivada.** En un repositorio `#[derive(SqlxRepository)]`, + añade un método `#[firefly::repository]` `find_by_owner(&self, owner: &str) -> + Result, DataError>`, y luego una variante paginada + `find_by_status(&self, status: &str, page: Pageable) -> Result, DataError>`. + Construye el `Pageable` con `Pageable::of(1, 20, RequestSort::by(["id"]))` y + confirma que compila. Observa que `page` es la *primera* página, no la segunda. + +5. **Traza el intercambio.** Enumera las líneas exactas de + `samples/lumen/src/ledger.rs` que cambiarían si `ReadModel` pasara a ser un + `SqlxReactiveRepository`, y cuáles líneas del handler + `GetWallet` *no* cambiarían, confirmando que el límite del adaptador se + sostiene. + +## Adónde ir después + +- Modela el negocio en sí —el value object `Money` y el agregado `Wallet`— en + **[Diseño guiado por el dominio](./08-domain-driven-design.md)**. +- Ve cómo se separan el almacén de lectura y el almacén de escritura, y cómo el + lado de consulta lee desde el repositorio que acabas de plantear, en + **[CQRS](./09-cqrs.md)**. +- Observa cómo cobra vida la proyección que *escribe* en `ReadModel` en + **[EDA y mensajería](./10-eda-messaging.md)** y + **[Event Sourcing](./11-event-sourcing.md)**. +- Ve la capa de persistencia completa al estilo de Spring Data cableada entre + crates en **[Microservicios en capas](./22-layered-microservices.md)**. diff --git a/docs/book/src-es/08-domain-driven-design.md b/docs/book/src-es/08-domain-driven-design.md new file mode 100644 index 00000000..07ef5182 --- /dev/null +++ b/docs/book/src-es/08-domain-driven-design.md @@ -0,0 +1,892 @@ +# Diseño dirigido por el dominio + +Lumen ya puede abrir monederos y volver a leerlos, y tiene un sitio donde colocar +el modelo de lectura. Pero fíjate bien y verás que falta algo: todavía nada *posee* +las reglas. ¿Dónde está «no puedes retirar más de lo que tienes»? ¿Dónde está «un +importe debe ser positivo»? ¿Dónde está «un monedero debe tener un propietario»? +Ahora mismo eso viviría como sentencias `if` dispersas en un handler — exactamente +el tipo de regla que un futuro desarrollador puede saltarse escribiendo directamente +en el almacén. + +El **diseño dirigido por el dominio (DDD)** lo resuelve haciendo que el modelo sea +responsable de sus propios invariantes. En este capítulo construirás el núcleo de +dominio de Lumen desde sus principios fundamentales: el *value object* `Money` +(inmutable, céntimos enteros, aritmética exacta) y el *aggregate* `Wallet` que +custodia las reglas de descubierto, importe-positivo y propietario-requerido — y +cada comando emite el evento de dominio que registra lo sucedido. Ambos ficheros +están tomados literalmente de +[`samples/lumen`](https://github.com/fireflyframework/fireflyframework-rust/tree/main/samples/lumen), +de modo que el crate que vas haciendo crecer aquí coincide línea por línea con el +servicio terminado. + +Esto es modelado de Rust puro: no hay HTTP, ni base de datos, ni runtime del +framework en la ruta caliente. El framework aporta exactamente dos derives — +`#[derive(AggregateRoot)]` y `#[derive(DomainEvent)]` — y por lo demás se aparta de +tu camino, que es justo de lo que se trata: tus reglas viven en métodos corrientes +que puedes probar con tests unitarios sin ninguna E/S. + +Al terminar este capítulo, serás capaz de: + +- Distinguir un **value object** de una **entidad / aggregate**, y saber por qué + `Money` es lo primero y `Wallet` lo segundo. +- Construir un value object `Money` que es inmutable, almacenado como céntimos + enteros y cerrado bajo las operaciones que un monedero necesita (`add` / + `subtract` / `require_positive`). +- Construir el aggregate `Wallet` de forma que sus invariantes de descubierto, + importe-positivo y propietario-requerido sean *físicamente* inalcanzables desde + fuera — validados antes de emitir ningún evento. +- Usar `#[derive(AggregateRoot)]` y `#[derive(DomainEvent)]` para incrustar el búfer + de eventos del framework y sellar eventos tipados, escribiendo tú solo las reglas. +- Mapear los fallos de dominio a una familia tipada `DomainError` cuyas cadenas + `Display` afloran literalmente como detalles de problema RFC 9457. +- Demostrar cada invariante con tests unitarios corrientes — sin base de datos, sin + HTTP. + +## Conceptos que conocerás + +Antes de la primera línea de código, aquí están las ideas de DDD en las que se apoya +este capítulo. Cada una se reintroduce en su contexto donde se usa por primera vez; +esta es la versión breve. + +> **Note** **Término clave — value object.** Un *value object* es un tipo de dominio +> definido por completo por sus atributos — **no tiene identidad** — y es +> **inmutable**: cada operación devuelve un valor *nuevo* en lugar de mutar en el +> sitio. Dos value objects con atributos iguales son iguales, punto. `Money` es el +> ejemplo de manual. El análogo en Java/DDD es exactamente un value object (un +> `@Embeddable` de JPA, o un `record` de Java usado como valor). + +> **Note** **Término clave — entidad y aggregate.** Una *entidad* tiene una +> **identidad** que persiste a través de los cambios (un monedero sigue siendo «el +> mismo monedero» a medida que su saldo se mueve). Un *aggregate* es un grupo de +> entidades y value objects tratado como una sola unidad, con una única **raíz del +> aggregate** como su único punto de entrada — la frontera de consistencia a través +> de la cual debe fluir todo cambio. Aquí `Wallet` es la raíz del aggregate. En +> términos de Spring/JPA esto es la `@Entity` que posee sus hijos y custodia sus +> invariantes. + +> **Note** **Término clave — evento de dominio.** Un *evento de dominio* es un +> registro inmutable de algo que ocurrió en el dominio, en pasado (`WalletOpened`, +> `MoneyDeposited`). El aggregate *emite* uno cada vez que cambia de estado, de modo +> que el cambio queda capturado como un hecho en lugar de quedar implícito. Esta es +> la misma noción que un `ApplicationEvent` de Spring publicado desde un método de +> dominio, pero aquí los eventos son además la fuente de verdad persistida (lo verás +> por completo en [Event Sourcing](./11-event-sourcing.md)). + +> **Note** **Término clave — invariante.** Un *invariante* es una regla que debe +> cumplirse para que el modelo sea válido — «el saldo nunca baja de cero», «el +> propietario nunca está en blanco». El trabajo de un aggregate es hacer que sus +> invariantes sean imposibles de violar desde fuera. No hay anotación de Spring para +> esto; es la disciplina que la frontera del aggregate existe para imponer. + +El capítulo construye dos ficheros: `src/money.rs` (el value object) y +`src/domain.rs` (el aggregate, sus eventos, la familia de errores y la vista del +modelo de lectura). Declaraste ambos en la lista `mod` allá en +[Quickstart](./02-quickstart.md), así que nada cambia en `main.rs` — estás +rellenando módulos que el punto de entrada ya nombra. + +## Paso 1 — Definir la forma del value object `Money` + +Empieza por la representación. `Money` resuelve *cómo se almacena y se compara un +importe*; el aggregate `Wallet` (del Paso 5 en adelante) resolverá el +*comportamiento*. Acertar con la representación importa aquí más que casi en ningún +otro sitio: los importes se almacenan como **unidades menores** enteras (céntimos), +de modo que la aritmética es exacta — sin deriva del punto flotante binario, el +clásico bug de corrección que un tipo monetario existe para evitar. + +Crea `src/money.rs` y declara el struct y sus imports: + +```rust,ignore +// samples/lumen/src/money.rs +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// An exact monetary amount, expressed in integer minor units (cents). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Money { + /// The amount in minor units (cents). Kept private so the only way to a + /// `Money` is through the validating constructors. + cents: i64, +} +``` + +Qué acaba de pasar, decisión a decisión: + +- **Céntimos enteros, nunca un float.** El único campo es `cents: i64`, mantenido + *privado* para que la única vía hacia un `Money` sea a través de un constructor. + 10,00 € es `Money::cents(1_000)`; 12,50 € tras una suma es `Money::cents(1_250)`. + Las matemáticas son exactas por construcción — no hay ningún `f64` por ningún lado + que pueda derivar. +- **Un value object se compara por valor.** Los derives `PartialEq, Eq, PartialOrd, + Ord` hacen que dos `Money` sean iguales exactamente cuando sus céntimos son + iguales, y ordenables para que el monedero pueda preguntar «¿es este importe mayor + que el saldo?». +- **`Copy`, porque es un valor.** `Money` es un único `i64`, así que se copia + libremente; nunca andas haciendo malabares con referencias a él. +- **`#[serde(transparent)]` es el contrato de cable.** `Money` se serializa como el + *entero de céntimos pelado* — un saldo de 10,00 € es el número JSON `1000`, no + `{ "cents": 1000 }`. Ese es el contrato que comparten el modelo de lectura y los + payloads de eventos, y es la razón por la que el campo puede seguir siendo privado + sin perjudicar la forma de cable. + +> **Note** **Término clave — unidades menores.** Las *unidades menores* son la unidad +> indivisible más pequeña de una moneda — céntimos para euros y dólares. Almacenar el +> dinero como un recuento entero de unidades menores (1000 céntimos, no 10,00 euros) +> mantiene la aritmética exacta. Esta es la misma disciplina que una columna `BIGINT` +> de céntimos en la base de datos o un `long` de Java de unidades menores. + +> **Tip** **Punto de control.** `src/money.rs` existe con un struct `Money` que tiene +> un campo privado `cents: i64`. Todavía no compilará — los constructores vienen a +> continuación — pero la forma está fijada. + +## Paso 2 — Dar a `Money` operaciones inmutables y validadoras + +Un value object expone únicamente operaciones que *devuelven valores nuevos*. Añade +los constructores, los accesores y las tres operaciones que un monedero necesita. +Añade este bloque `impl` a `src/money.rs`: + +```rust,ignore +impl Money { + /// A zero amount — the opening balance of a brand-new wallet. + pub const ZERO: Money = Money { cents: 0 }; + + /// Builds a `Money` from a raw minor-unit (cent) count. + pub const fn cents(cents: i64) -> Self { + Money { cents } + } + + /// Builds a `Money` from a whole-currency unit count (`from_units(10)` is €10.00). + pub const fn from_units(units: i64) -> Self { + Money { cents: units * 100 } + } + + /// The amount in minor units (cents) — the wire representation. + pub const fn cents_value(self) -> i64 { + self.cents + } + + /// Whether this amount is strictly positive (`> 0`). + pub const fn is_positive(self) -> bool { + self.cents > 0 + } + + /// Whether this amount is zero. + pub const fn is_zero(self) -> bool { + self.cents == 0 + } + + /// Returns a new `Money` that is `self + other` (immutable addition). + #[must_use] + pub const fn add(self, other: Money) -> Money { + Money { cents: self.cents + other.cents } + } + + /// Returns `self - other`, or `MoneyError::Overdraw` if that would go below zero. + pub fn subtract(self, other: Money) -> Result { + if other.cents > self.cents { + return Err(MoneyError::Overdraw); + } + Ok(Money { cents: self.cents - other.cents }) + } + + /// Validates that this amount is strictly positive, returning it unchanged on success. + pub fn require_positive(self) -> Result { + if self.is_positive() { + Ok(self) + } else { + Err(MoneyError::NonPositive) + } + } +} +``` + +Qué acaba de pasar — aquí hay cuatro decisiones de diseño que cargan peso: + +- **Inmutable.** `add` es `#[must_use]` y `const`; devuelve un `Money` *fresco* y + deja los operandos intactos. Lo mismo hace `subtract`. No hay `add_assign` — un + value object se reemplaza, no se edita. `#[must_use]` hace que el compilador avise + si llamas a `add` y olvidas usar el resultado, atrapando en tiempo de compilación + el bug de «creía que esto mutaba en el sitio». +- **Cerrado bajo las operaciones del monedero.** `add` para abonos, `subtract` + (falible, protegiendo contra el descubierto) para cargos, y `require_positive` + para la guarda que todo comando mutador ejecuta antes de emitir un evento. + «Cerrado» significa que toda operación que realiza un monedero toma `Money` y + produce `Money` (o un `MoneyError`), de modo que los importes nunca se filtran a + enteros pelados. +- **`subtract` es donde vive el descubierto.** Devuelve `Result`: + restar más de lo que tienes es `MoneyError::Overdraw`, no un saldo negativo + silencioso. Este es el *único* sitio donde se comprueba la regla de «nunca por + debajo de cero» — el aggregate la reutiliza en lugar de reimplementarla. +- **`const` donde es posible.** `ZERO`, `cents`, `from_units`, `cents_value`, + `is_positive`, `is_zero` y `add` son `const fn`, así que `Money::ZERO` y + `Money::cents(100)` pueden usarse en contextos const. `subtract` y + `require_positive` no son `const` porque devuelven un `Result`. + +> **Note** **Término clave — `#[must_use]`.** Anotar una función con `#[must_use]` le +> indica al compilador que avise cuando se ignora su valor de retorno. En una +> operación inmutable como `add` es el guardarraíl que convierte «olvidé que el +> resultado es un valor nuevo» en una advertencia en tiempo de compilación en lugar +> de una actualización perdida. + +## Paso 3 — Renderizar e informar de los fallos de `Money` a mano + +Dos piezas más completan el value object: un `Display` legible para humanos y el +error tipado que devuelven sus operaciones. Lumen escribe a mano tanto `Display` +como `std::error::Error` para `MoneyError` en lugar de derivarlos — eso mantiene +honesta la promesa de una sola dependencia del libro hasta el fondo, hasta los enums +de error. + +Añade el tipo de error y los dos impls de `Display` a `src/money.rs`: + +```rust,ignore +/// The typed error a `Money` operation can fail with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MoneyError { + /// An amount was expected to be strictly positive (`> 0`) but was not. + NonPositive, + /// A subtraction would drop the balance below zero. + Overdraw, +} + +impl fmt::Display for MoneyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MoneyError::NonPositive => f.write_str("amount must be positive"), + MoneyError::Overdraw => f.write_str("amount exceeds balance"), + } + } +} + +impl std::error::Error for MoneyError {} + +impl fmt::Display for Money { + /// Renders the amount as a fixed two-decimal major-unit string + /// (`1250` cents → `"12.50"`), the human-readable form used in logs. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let sign = if self.cents < 0 { "-" } else { "" }; + let abs = self.cents.abs(); + write!(f, "{sign}{}.{:02}", abs / 100, abs % 100) + } +} +``` + +Qué acaba de pasar: + +- **`MoneyError` es un enum cerrado** con dos casos — exactamente las dos formas en + que una operación de `Money` puede fallar. Como deriva `PartialEq, Eq`, los tests + pueden afirmar `err == MoneyError::Overdraw` directamente. +- **`Display` lleva el texto del mensaje**, y `impl std::error::Error for + MoneyError {}` lo convierte en un error de primera clase para que `?` y los trait + objects funcionen. El bloque vacío basta porque `Error` tiene métodos por defecto. +- **El propio `Display` de `Money`** convierte `1250` en `"12.50"` para los logs y el + banner — la forma de unidad mayor que lee un humano, mantenida aparte de la forma + de unidad menor que lleva el cable. + +> **Design note.** `MoneyError` escribe a mano `Display` y `std::error::Error` en +> lugar de derivarlos con `thiserror`. Eso es a propósito: Lumen depende de +> exactamente un crate de Firefly más `axum` y `serde`, y el libro mantiene esa +> promesa hasta el fondo — incluso los enums de error. Dos impls de trait por tipo de +> error es un precio pequeño por una lista de dependencias honesta, y `Money` mismo +> es un tipo congelado y comparable por valor que Rust hace inmutable como garantía +> del compilador. + +> **Tip** **Punto de control.** Ejecuta `cargo test --lib money` (o `cargo build`). +> `src/money.rs` ahora compila por sí solo: un value object de campo privado, tres +> operaciones, un error de dos variantes y un `Display` que imprime `"12.50"`. La +> aritmética es exacta y el tipo no puede construirse en un estado inválido. + +## Paso 4 — Montar el aggregate `Wallet` y sus eventos + +`Money` resolvió la representación. El aggregate `Wallet` posee el *comportamiento*: +es la frontera de consistencia, el único punto de entrada a través del cual debe +fluir todo cambio a un monedero, de modo que los invariantes no pueden saltarse. + +El `Wallet` de Lumen está event-sourced — cada comando produce un evento de dominio, +y el estado del monedero es el *resultado* de plegar esos eventos. Toda la +maquinaria del event store llega en [Event Sourcing](./11-event-sourcing.md); aquí +construyes solo la forma DDD. El aggregate incrusta el `AggregateRoot` del framework +(un búfer de eventos no confirmados más una versión), y dos derives hacen el trabajo +mecánico. + +> **Note** **Término clave — `#[derive(AggregateRoot)]`.** Este derive encuentra el +> campo `AggregateRoot` de `firefly` incrustado en tu struct y genera una constante +> asociada `AGGREGATE_TYPE` más accesores `aggregate()` / `aggregate_mut()` sobre él. +> El propio `AggregateRoot` incrustado lleva el búfer de eventos no confirmados, el id +> del aggregate y la versión — de modo que tu struct contiene solo el estado +> proyectado y las reglas. El análogo en Spring/Axon es una raíz `@Aggregate` que el +> framework gestiona. + +> **Note** **Término clave — `#[derive(DomainEvent)]`.** Este derive sella un struct +> de payload de evento con un discriminador estable `EVENT_TYPE` (el nombre de su +> struct) y genera una conversión `to_domain_event(...)` hacia el evento de cable del +> framework. Tú declaras el payload como un struct serializable corriente; el derive +> aporta la etiqueta de tipo para que nunca deletrees los nombres de evento como +> literales de cadena pelados en los sitios de llamada. + +Crea `src/domain.rs` con sus imports, la constante `AGGREGATE_TYPE` y los tres +payloads de evento: + +```rust,ignore +// samples/lumen/src/domain.rs +use firefly::eventsourcing::{AggregateRoot, DomainEvent}; +use firefly::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::money::{Money, MoneyError}; + +/// The aggregate-type discriminator stamped onto every event a Wallet raises. +/// `#[derive(AggregateRoot)]` also exposes it as `Wallet::AGGREGATE_TYPE`. +pub const AGGREGATE_TYPE: &str = "Wallet"; + +/// Payload of the event raised when a wallet is opened. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DomainEvent)] +pub struct WalletOpened { + pub wallet_id: String, + pub owner: String, + /// The opening balance, in minor units (cents). + pub opening_balance: i64, +} + +/// Payload of the event raised when money is credited to a wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DomainEvent)] +pub struct MoneyDeposited { + pub wallet_id: String, + /// The deposited amount, in minor units (cents). + pub amount: i64, +} + +/// Payload of the event raised when money is debited from a wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DomainEvent)] +pub struct MoneyWithdrawn { + pub wallet_id: String, + /// The withdrawn amount, in minor units (cents). + pub amount: i64, +} +``` + +Qué acaba de pasar, línea a línea: + +- **`use firefly::eventsourcing::{AggregateRoot, DomainEvent};`** importa los dos + *tipos* del framework — el struct raíz incrustable y el struct de evento de cable. +- **`use firefly::prelude::*;`** trae al ámbito las macros derive del framework, + incluyendo `AggregateRoot`, `DomainEvent` y `Schema` (usada por la vista del modelo + de lectura en el Paso 8). Todo te llega a través de la única fachada `firefly`. +- **`AGGREGATE_TYPE`** es el discriminador de cadena sellado en cada evento que emite + el monedero. Se declara como una constante pública *y* el derive lo reexpone como + `Wallet::AGGREGATE_TYPE`, de modo que ambas formas de escribirlo nombran el mismo + valor. +- **Cada payload de evento está en pasado** (`WalletOpened`, no `OpenWallet`) y lleva + `#[derive(DomainEvent)]`. El derive le da a cada uno una const `EVENT_TYPE` igual a + su nombre de struct (`"WalletOpened"`, etc.), que usas en los sitios de emisión en + lugar de teclear la cadena a mano. + +## Paso 5 — Declarar el struct raíz del aggregate + +Ahora el aggregate en sí. Incrusta el `AggregateRoot` del framework como un campo +llamado `root`, lleva el estado proyectado (`owner`, `balance`, `opened`) y deriva +`AggregateRoot` para generar los accesores y la constante `AGGREGATE_TYPE`. + +Añade el struct a `src/domain.rs`: + +```rust,ignore +/// The event-sourced wallet aggregate. +#[derive(Debug, Clone, AggregateRoot)] +#[firefly(aggregate_type = "Wallet")] +pub struct Wallet { + /// The framework aggregate root — uncommitted-event buffer + version. + pub root: AggregateRoot, + /// The owner's display name. + pub owner: String, + /// The current balance as a `Money` value object. + pub balance: Money, + /// Whether the wallet has been opened (an empty stream is "absent"). + pub opened: bool, +} +``` + +Qué acaba de pasar: + +- **`root: AggregateRoot`** es el campo incrustado del framework. Contiene el búfer + de eventos no confirmados, el id del aggregate (`root.id`) y la versión + (`root.version`). El derive localiza este campo por su tipo. +- **`#[firefly(aggregate_type = "Wallet")]`** le dice al derive qué cadena usar para + `Wallet::AGGREGATE_TYPE`. Coincide con la constante `AGGREGATE_TYPE` que declaraste + en el Paso 4 — ambos nombran `"Wallet"`. +- **`owner` / `balance` / `opened`** son el *estado proyectado* — el resultado de + aplicar los eventos del monedero. `balance` es un value object `Money`, así que el + aggregate reutiliza todas las garantías de aritmética exacta de los Pasos 1–3. + `opened` distingue un monedero real de un stream de eventos vacío («ausente»). +- **`Clone`** permite que un handler tome una copia de trabajo de un monedero + rehidratado sin tocar el original — útil bajo la consistencia eventual que + introduce CQRS. + +> **Tip** **Punto de control.** `cargo build` todavía falla — `Wallet` no tiene +> métodos aún y `DomainError` está sin definir — pero los derives deberían resolverse. +> Si el compilador se queja de que no encuentra un campo `AggregateRoot`, confirma +> que el campo incrustado está tipado exactamente como `AggregateRoot` (de +> `firefly::eventsourcing`), no tu propio tipo. + +## Paso 6 — Escribir el método factoría: `open` + +`open` es la *única* forma de traer un monedero a la existencia. Valida las entradas, +construye el aggregate y `raise` el evento de apertura. Usar una factoría en lugar de +un constructor público garantiza que el evento `WalletOpened` nunca se olvide — no hay +canal trasero que produzca un monedero sin registrar su nacimiento. + +Añade un bloque `impl Wallet` con `open`: + +```rust,ignore +impl Wallet { + /// Opens a fresh wallet, raising a `WalletOpened` event. + pub fn open( + id: impl Into, + owner: impl Into, + opening_balance: Money, + ) -> Result { + let id = id.into(); + let owner = owner.into(); + if owner.trim().is_empty() { + return Err(DomainError::OwnerRequired); + } + if opening_balance.cents_value() < 0 { + return Err(DomainError::NonPositiveAmount); + } + let mut wallet = Wallet { + root: AggregateRoot::new(&id, AGGREGATE_TYPE), + owner: owner.clone(), + balance: Money::ZERO, + opened: false, + }; + wallet.raise( + WalletOpened::EVENT_TYPE, + &WalletOpened { + wallet_id: id, + owner, + opening_balance: opening_balance.cents_value(), + }, + ); + wallet.balance = opening_balance; + wallet.opened = true; + Ok(wallet) + } +} +``` + +Qué acaba de pasar, por orden: + +- **Validar primero, construir después.** Dos invariantes se imponen *antes* de + emitir ningún evento: el propietario debe ser no vacío (`OwnerRequired`), y el + saldo de apertura no debe ser negativo (un saldo de apertura *cero* está permitido + explícitamente — la comprobación es `< 0`, no `<= 0`). +- **`AggregateRoot::new(&id, AGGREGATE_TYPE)`** construye la raíz incrustada con el id + de este monedero y su etiqueta de tipo de aggregate, en la versión 0 con un búfer + de eventos vacío. +- **`wallet.raise(WalletOpened::EVENT_TYPE, &WalletOpened { ... })`** registra el + evento de nacimiento. `WalletOpened::EVENT_TYPE` es el discriminador que generó + `#[derive(DomainEvent)]` (la cadena `"WalletOpened"`), así que el sitio de llamada + nunca lo deletrea a mano. `raise` es un pequeño helper que añades en el Paso 9; + serializa el payload y lo empuja sobre `root`. +- **El estado se actualiza después del evento.** `wallet.balance = opening_balance` y + `wallet.opened = true` ponen el estado proyectado para que coincida con lo que el + evento describe. El evento es el hecho; los campos son la proyección cacheada de + él. + +> **Note** **Término clave — método factoría.** Un *método factoría* es una función +> estática (asociada) que construye una instancia plenamente válida, en lugar de +> exponer un constructor público. Es la única puerta hacia el aggregate, así que +> puede imponer los invariantes de nacimiento y garantizar que el evento +> `WalletOpened` siempre se emita. El análogo en Spring/DDD es una factoría estática +> sobre la raíz del aggregate (o un servicio de dominio que lo produce). + +## Paso 7 — Escribir los métodos de comportamiento: `deposit` y `withdraw` + +Los dos comandos mutadores siguen una sola forma: comprobar que el monedero existe, +validar el importe, aplicar la operación de `Money`, emitir el evento, actualizar el +estado. La regla de descubierto se impone exactamente una vez — por +`Money::subtract`, que ya construiste en el Paso 2. + +Añade estos métodos al mismo bloque `impl Wallet`: + +```rust,ignore + /// Credits `amount` to the wallet, raising a `MoneyDeposited` event. + pub fn deposit(&mut self, amount: Money) -> Result<(), DomainError> { + self.require_opened()?; + let amount = amount.require_positive()?; + self.raise( + MoneyDeposited::EVENT_TYPE, + &MoneyDeposited { wallet_id: self.root.id.clone(), amount: amount.cents_value() }, + ); + self.balance = self.balance.add(amount); + Ok(()) + } + + /// Debits `amount` from the wallet, raising a `MoneyWithdrawn` event. + pub fn withdraw(&mut self, amount: Money) -> Result<(), DomainError> { + self.require_opened()?; + let amount = amount.require_positive()?; + let remaining = self.balance.subtract(amount)?; // Overdraw → InsufficientFunds + self.raise( + MoneyWithdrawn::EVENT_TYPE, + &MoneyWithdrawn { wallet_id: self.root.id.clone(), amount: amount.cents_value() }, + ); + self.balance = remaining; + Ok(()) + } + + fn require_opened(&self) -> Result<(), DomainError> { + if self.opened { + Ok(()) + } else { + Err(DomainError::NotFound(self.root.id.clone())) + } + } +``` + +Qué acaba de pasar — lee `withdraw` con cuidado, porque es donde la frontera de +consistencia se gana el sueldo: + +- **El orden es *validar y luego mutar*.** `require_opened()`, `require_positive()` y + la comprobación de descubierto de `subtract` se ejecutan todas **antes** de emitir + el evento. Si la retirada produjera un descubierto, `Money::subtract` devuelve + `MoneyError::Overdraw`, el `?` lo convierte en `DomainError::InsufficientFunds` + (mediante el impl `From` que añades en el Paso 8), y el método retorna *sin emitir + nada*. +- **El invariante es inalcanzable desde fuera.** «El saldo nunca baja de cero» no + puede violarse, porque la única ruta hacia una retirada pasa primero por este + guante. No hay setter en `balance`, ni forma de saltarse `subtract`. Esa es la + diferencia entre una guarda a nivel de servicio (una convención que alguien puede + olvidar) y un invariante de aggregate (una restricción física). +- **`require_opened` convierte «ausente» en un error tipado.** Un comando contra un + monedero que nunca se abrió devuelve `DomainError::NotFound(id)`, que la frontera + web mapea más adelante a un 404. Tanto `deposit` como `withdraw` lo comprueban + primero. +- **`self.root.id.clone()`** lee el id de la raíz incrustada para sellar cada evento + con el monedero al que pertenece. + +> **Note** La razón de ser de un aggregate es ser la *única* forma de cambiar su +> estado. Como `deposit` y `withdraw` toman `&mut self` y no hay setters públicos, +> toda mutación se canaliza a través de estos métodos y pasa por sus guardas. Un +> futuro desarrollador no puede «simplemente escribir en el almacén» y saltarse las +> reglas — las reglas son la puerta. + +## Paso 8 — Añadir la familia tipada `DomainError` + +Los errores son un enum cerrado con cadenas `Display` estables — estables porque los +tests afirman sobre ellas y afloran literalmente como el `detail` del problema +RFC 9457 una vez mapeadas en la frontera HTTP (cableas ese mapeo en +[CQRS](./09-cqrs.md) y [Tu primera API HTTP](./06-first-http-api.md)). + +Añade `DomainError` y sus impls a `src/domain.rs`: + +```rust,ignore +/// The typed domain-error family. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DomainError { + /// A command referenced an amount that was not strictly positive. + NonPositiveAmount, + /// A withdrawal (or transfer debit) exceeded the available balance. + InsufficientFunds, + /// A command targeted a wallet that was never opened. + NotFound(String), + /// The owner name was empty when opening a wallet. + OwnerRequired, +} + +impl std::fmt::Display for DomainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DomainError::NonPositiveAmount => f.write_str("amount must be positive"), + DomainError::InsufficientFunds => f.write_str("insufficient funds"), + DomainError::NotFound(id) => write!(f, "wallet {id} not found"), + DomainError::OwnerRequired => f.write_str("owner is required"), + } + } +} + +impl std::error::Error for DomainError {} + +impl From for DomainError { + fn from(e: MoneyError) -> Self { + match e { + MoneyError::NonPositive => DomainError::NonPositiveAmount, + MoneyError::Overdraw => DomainError::InsufficientFunds, + } + } +} +``` + +Qué acaba de pasar: + +- **`From for DomainError` es el puente.** Es lo que permite que + `withdraw` escriba `self.balance.subtract(amount)?` y haga que un descubierto + aritmético aflore como `InsufficientFunds`. El value object informa del hecho + aritmético (`Overdraw`); el aggregate lo traduce a lenguaje de dominio + (`InsufficientFunds`). El operador `?` llama a este impl `From` automáticamente. +- **Las cadenas `Display` son el contrato.** `"insufficient funds"`, `"amount must + be positive"`, `"wallet {id} not found"`, `"owner is required"` — estas cadenas + exactas se convierten en el `detail` del problema RFC 9457 en la frontera web, y + los tests afirman sobre ellas, así que son estables. +- **`Display` y `Error` escritos a mano, de nuevo.** Como `MoneyError`, `DomainError` + detalla sus impls de `Display` y `Error` en lugar de derivarlos con `thiserror`: + sin crate extra, una sola dependencia. + +> **Note** Lumen devuelve un `DomainError` tipado en lugar de lanzar una excepción. +> `InsufficientFunds` / `NonPositiveAmount` / `OwnerRequired` se convierten en +> problemas 422 y `NotFound` se convierte en un 404, decidido por un `match` en la +> frontera web — un valor devuelto, comprobado por el compilador, sin tabla de +> excepción-a-estado que mantener sincronizada. Escribirás ese `match` en +> [CQRS](./09-cqrs.md). + +> **Tip** **Punto de control.** `cargo build` ahora resuelve `Wallet::open` / +> `deposit` / `withdraw` y su tipo de error. El helper `raise` todavía falta +> (siguiente paso), así que la build aún no está en verde — pero toda regla de +> dominio queda ya expresada como código. + +## Paso 9 — Añadir el helper `raise` y la vista del modelo de lectura + +Dos piezas terminan `src/domain.rs`. Primero, el helper privado `raise` que llaman +los métodos de comando — serializa un payload `#[derive(DomainEvent)]` y lo empuja +sobre la raíz incrustada. Segundo, la vista plana del modelo de lectura que el +aggregate entrega. + +> **Note** **Término clave — modelo de lectura / proyección.** Un *modelo de lectura* +> (o *proyección*) es una vista plana y optimizada para consulta de un aggregate, +> separada del rico aggregate en sí. El aggregate es el modelo de *escritura* — +> impositor de reglas, emisor de eventos; el modelo de lectura es lo que devuelven +> las consultas — serializable, sin comportamiento. Mantenerlos separados es el +> corazón de CQRS ([CQRS](./09-cqrs.md)). El análogo en Spring es una proyección / +> DTO de lectura de JPA distinta de la entidad gestionada. + +Añade el método `view` y el helper `raise` al bloque `impl Wallet`, y luego el struct +`WalletView`: + +```rust,ignore + /// The current read-model view of this aggregate. + pub fn view(&self) -> WalletView { + WalletView { + id: self.root.id.clone(), + owner: self.owner.clone(), + balance: self.balance.cents_value(), + version: self.root.version, + } + } + + /// Serialises a `#[derive(DomainEvent)]` payload and raises it onto the + /// embedded root under `event_type`. + fn raise(&mut self, event_type: &str, payload: &P) { + let bytes = serde_json::to_vec(payload).expect("domain event payload serialises"); + self.root.raise(event_type, bytes); + } +} + +/// The read-model projection of a wallet — the wire shape served by +/// `GET /api/v1/wallets/:id` and stored in the read-model repository. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Schema)] +pub struct WalletView { + pub id: String, + pub owner: String, + /// The current balance, in minor units (cents). + pub balance: i64, + /// The aggregate version (number of events applied). + pub version: i64, +} +``` + +Qué acaba de pasar: + +- **`raise` es el único sitio donde se serializan los eventos.** Toma el + discriminador `EVENT_TYPE` y un payload serializable, codifica el payload a bytes + con `serde_json::to_vec`, y llama a `self.root.raise(event_type, bytes)` — el + método del framework sobre `AggregateRoot` que añade al búfer de eventos no + confirmados e incrementa la versión. Todo método de comando se enruta a través de + este helper, así que la serialización vive en exactamente un punto. +- **`view()` produce el modelo de lectura bajo demanda.** Copia `id`, `owner`, + `balance` (como céntimos pelados vía `cents_value()`) y `version` (de + `root.version`) en un `WalletView` plano. El aggregate nunca se serializa a *sí + mismo* — entrega una vista. +- **`WalletView` deriva `Schema`.** Eso hace que aparezca en los docs OpenAPI + autogenerados como un schema de componente, de modo que la respuesta de + `GET /api/v1/wallets/:id` queda documentada con cero código extra (véase + [OpenAPI](./06a-openapi.md)). +- **`version` permite a un cliente detectar el desfase.** Bajo la consistencia + eventual que introduce CQRS, un cliente puede comparar versiones para advertir que + leyó una proyección desfasada. + +Mantener `Wallet` (rico, impositor de reglas, emisor de eventos) y `WalletView` +(plano, serializable, sin comportamiento) como tipos separados es la misma separación +dominio/persistencia que [Persistencia](./07-persistence.md) trazó alrededor del +repositorio: el aggregate nunca se serializa a sí mismo, y la forma de cable nunca +lleva un invariante. + +> **Tip** **Punto de control.** `cargo build` está en verde. Tanto `src/money.rs` +> como `src/domain.rs` compilan, y `Wallet::open(...).view()` hace un ida y vuelta de +> un monedero desde una llamada de factoría hasta una vista serializable — con cada +> regla impuesta por el camino. + +## Paso 10 — Demostrar los invariantes con tests unitarios + +Como el aggregate es un struct corriente con métodos corrientes, puedes ejercitar +cada regla sin base de datos y sin HTTP. Estos son los tests unitarios que vienen en +`samples/lumen/src/domain.rs`. Añade un bloque `#[cfg(test)] mod tests`: + +```rust,ignore +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_validates_owner_and_balance() { + assert_eq!( + Wallet::open("w1", " ", Money::cents(100)).unwrap_err(), + DomainError::OwnerRequired + ); + assert_eq!( + Wallet::open("w1", "alice", Money::cents(-1)).unwrap_err(), + DomainError::NonPositiveAmount + ); + let w = Wallet::open("w1", "alice", Money::ZERO).unwrap(); + assert!(w.opened); + assert_eq!(w.balance, Money::ZERO); + } + + #[test] + fn withdraw_rejects_overdraft() { + let mut w = Wallet::open("w1", "alice", Money::cents(100)).unwrap(); + assert_eq!( + w.withdraw(Money::cents(101)).unwrap_err(), + DomainError::InsufficientFunds + ); + // The failed command raised no event beyond the open. + assert_eq!(w.root.uncommitted().len(), 1); + } + + #[test] + fn deposit_and_withdraw_update_balance_and_raise_events() { + let mut w = Wallet::open("w1", "alice", Money::cents(100)).unwrap(); + w.deposit(Money::cents(50)).unwrap(); + assert_eq!(w.balance, Money::cents(150)); + w.withdraw(Money::cents(30)).unwrap(); + assert_eq!(w.balance, Money::cents(120)); + } + + #[test] + fn wallet_view_wire_shape() { + let w = Wallet::open("wlt_1", "alice", Money::cents(250)).unwrap(); + let json = serde_json::to_string(&w.view()).unwrap(); + assert_eq!( + json, + r#"{"id":"wlt_1","owner":"alice","balance":250,"version":1}"# + ); + } +} +``` + +Qué acaba de pasar: + +- **`open_validates_owner_and_balance`** demuestra los invariantes de nacimiento: un + propietario en blanco es `OwnerRequired`, un saldo de apertura negativo es + `NonPositiveAmount`, y un saldo de apertura *cero* tiene éxito (el monedero queda + `opened` con saldo `ZERO`). +- **`withdraw_rejects_overdraft`** es el test que carga peso. Tras una retirada + rechazada, el búfer de eventos no confirmados del aggregate (`w.root.uncommitted()`) + sigue teniendo *exactamente un* evento — el `WalletOpened` de la factoría. El + descubierto nunca produjo un `MoneyWithdrawn`, así que nada parcial puede llegar a + persistirse jamás. Esta es la frontera de consistencia hecha concreta. +- **`deposit_and_withdraw_update_balance_and_raise_events`** recorre el camino feliz: + un depósito sube el saldo, una retirada lo baja, y la aritmética es exacta + (`100 + 50 - 30 = 120`). +- **`wallet_view_wire_shape`** fija el contrato de cable. El `view()` de un monedero + recién abierto se serializa exactamente a + `{"id":"wlt_1","owner":"alice","balance":250,"version":1}` — confirmando el + `#[serde(transparent)]` de `Money` (el saldo es el número pelado `250`, no + `{ "cents": 250 }`) y el orden de campos de `WalletView`. + +> **Tip** **Punto de control.** Ejecuta `cargo test --lib`. Todos los tests de +> dominio pasan — y se ejecutaron sin base de datos, sin servidor HTTP y sin runtime +> del framework. Esa es la recompensa de un núcleo de dominio que es solo structs y +> métodos: las reglas son comprobables en microsegundos. + +## Resumen — el núcleo de dominio de Lumen + +Lumen tiene ahora un núcleo de dominio que posee sus reglas: + +- **`src/money.rs`** — un value object `Money`: inmutable, céntimos enteros, campo + privado, `#[serde(transparent)]` para que viaje por el cable como un número pelado, + cerrado bajo `add` / `subtract` / `require_positive`, con un `MoneyError` escrito a + mano (sin `thiserror`) y un `Display` que imprime `"12.50"`. +- **`src/domain.rs`** — el aggregate `Wallet` que lleva `#[derive(AggregateRoot)]` + (que genera `AGGREGATE_TYPE` y los accesores `aggregate()` / `aggregate_mut()` sobre + la raíz incrustada). `open` / `deposit` / `withdraw` imponen los tres invariantes — + propietario requerido, importes positivos, sin descubierto — *antes* de emitir un + evento, de modo que un comando rechazado deja el búfer de eventos intacto. +- **Tres payloads `#[derive(DomainEvent)]`** (`WalletOpened`, `MoneyDeposited`, + `MoneyWithdrawn`), cada uno sellado con un discriminador `EVENT_TYPE` estable, + emitidos a través del único helper privado `raise`. +- **La familia tipada `DomainError`** con cadenas `Display` estables que afloran como + detalles de problema RFC 9457, más el puente `From` que convierte un + descubierto aritmético en `InsufficientFunds`. +- **`WalletView`** — la proyección plana del modelo de lectura que el aggregate + entrega vía `view()`, derivando `Schema` para los docs, mantenida como un tipo + separado del aggregate impositor de reglas. + +Ahora también sabes: + +- La diferencia entre un **value object** (sin identidad, inmutable, comparado por + valor — `Money`) y un **aggregate** (una identidad y una frontera de consistencia — + `Wallet`), y por qué cada regla pertenece a donde le corresponde. +- Que un aggregate hace sus invariantes *físicamente* inalcanzables validando antes + de mutar y no exponiendo setters — la diferencia entre una convención y una + restricción. +- Que `#[derive(AggregateRoot)]` y `#[derive(DomainEvent)]` aportan el búfer de + eventos y los discriminadores de tipo, de modo que el único código de event + sourcing que escribes a mano son las reglas. + +El ciclo de vida completo de los payloads de eventos — cómo se persisten, y el +pliegue `rehydrate` / `apply` que reconstruye un monedero desde su stream — recibe su +tratamiento en [Event Sourcing](./11-event-sourcing.md). Aquí, la forma que importa es +la de DDD: un value object que no puedes corromper y un aggregate que no puedes +saltarte. + +## Ejercicios + +1. **Rompe un invariante, mira cómo aguanta.** En un bloque `#[cfg(test)]`, abre un + monedero con `Money::cents(100)` y llama a `withdraw(Money::cents(200))`. Afirma + que el error es `DomainError::InsufficientFunds` *y* que + `w.root.uncommitted().len()` sigue siendo `1` — demostrando que el comando + rechazado no emitió ningún evento más allá del de apertura. + +2. **Añade una regla `transfer` (solo dominio).** Escribe una función libre + `fn transfer(from: &mut Wallet, to: &mut Wallet, amount: Money) -> + Result<(), DomainError>` que llame a `from.withdraw(amount)?` y luego a + `to.deposit(amount)?`. Comprueba que una transferencia que excede el saldo del + origen falla en el tramo de la retirada y deja el saldo del *destino* sin cambios. + (La versión real y persistida se convierte en la saga en [Sagas](./12-sagas.md).) + +3. **Confirma la forma de cable.** Serializa el `view()` de un monedero recién + abierto con `serde_json::to_string` y afirma que es igual a + `{"id":"wlt_1","owner":"alice","balance":250,"version":1}` — verificando que el + `#[serde(transparent)]` de `Money` y el orden de campos de `WalletView` producen el + contrato que comparten el modelo de lectura y los clientes. + +4. **Justifica el error escrito a mano.** En dos frases, explica por qué `MoneyError` + y `DomainError` implementan `Display` / `Error` a mano en lugar de derivarlos con + `thiserror`, y qué le costaría a la promesa de una sola dependencia del libro + añadir el crate. + +5. **Permite un depósito de cero — y luego decide en contra.** Cambia `deposit` para + que acepte un importe de cero y ejecuta la suite; anota qué test se rompe y por qué + que `require_positive` rechace el cero es la regla correcta para un comando de + monedero. Revierte el cambio. + +## Adónde ir después + +- Separa la ruta de escritura de la de lectura con el bus de comandos/consultas en + **[CQRS](./09-cqrs.md)** — donde los comandos de `Wallet` se convierten en handlers + despachados por el bus y `DomainError` se convierte en el mapeo a problema RFC 9457. +- Expón estas reglas sobre HTTP en + **[Tu primera API HTTP](./06-first-http-api.md)**, donde un `DomainError` devuelto + se renderiza como un documento de problema 422 o 404. +- Persiste los eventos y reconstruye un monedero desde su stream en + **[Event Sourcing](./11-event-sourcing.md)** — el ciclo de vida completo de los + payloads que emitiste aquí. diff --git a/docs/book/src-es/09-cqrs.md b/docs/book/src-es/09-cqrs.md new file mode 100644 index 00000000..b8164dd7 --- /dev/null +++ b/docs/book/src-es/09-cqrs.md @@ -0,0 +1,1008 @@ +# CQRS + +En [Diseño guiado por el dominio](./08-domain-driven-design.md), el agregado +`Wallet` de Lumen aprendió a hacer cumplir sus propias reglas, y el modelo de +lectura encontró un hogar. Pero un controlador todavía necesita una forma de +*entregar* una instrucción al lado de escritura y de hacer una *pregunta* al +lado de lectura, y de hacerlo sin que ambos caminos compartan una ruta de +código, de modo que las lecturas puedan cachearse y las escrituras validarse de +forma independiente. + +Este capítulo traza esa línea nítida. Conecta el bus de comandos/consultas de +Lumen de extremo a extremo, exactamente como lo hace el crate +[`samples/lumen`](https://github.com/fireflyframework/fireflyframework-rust/tree/main/samples/lumen) +que se distribuye: cuatro structs de mensaje, un bean de manejadores y la +costura del controlador que despacha a través del bus y mantiene honesta la +caché de lectura tras una escritura. + +Al terminar este capítulo, serás capaz de: + +- Explicar qué te aporta la **segregación de responsabilidad entre comandos y + consultas (CQRS)** y cómo Firefly mantiene un único bus tipado mientras sigue + reportando comandos y consultas por separado. +- Definir los comandos `OpenWallet` / `Deposit` / `Withdraw` de Lumen y su + consulta `GetWallet` como structs `#[derive(Command)]` / `#[derive(Query)]`, + con validación a nivel de campo y un TTL de caché de consulta generado por ti. +- Escribir el **bean de manejadores** `WalletHandlers`: un `#[derive(Service)]` + cuyo impl `#[handlers]` lleva métodos `#[command_handler]` / + `#[query_handler]` que alcanzan a sus colaboradores a través de campos + `#[autowired]`. +- Entender cómo `FireflyApplication` vierte esos manejadores sobre un `Bus` + proporcionado por el framework e instala el middleware de correlación, caché + de consulta y validación, sin código de cableado en Lumen. +- Despachar desde el controlador con `bus.send` / `bus.query`, mapear un + `CqrsError` al estado RFC 9457 correcto, y hacer cumplir la consistencia + lectura-tras-escritura invalidando la familia de consultas cacheada tras cada + mutación. + +## Conceptos que conocerás + +Antes del primer mensaje, aquí tienes las ideas en las que se apoya este +capítulo. Cada una se reintroduce en su contexto donde se usa por primera vez; +esta es la versión corta. + +> **Note** **Término clave — Segregación de responsabilidad entre comandos y +> consultas (CQRS).** Un patrón que enruta los **comandos** que cambian el +> estado y las **consultas** de solo lectura a través de manejadores separados, +> de modo que ambas mitades puedan evolucionar, escalar y optimizarse de forma +> independiente: lecturas cacheadas, escrituras validadas. El análogo en Spring +> es una aplicación CQRS dividida en componentes `@CommandHandler` / +> `@QueryHandler` (por ejemplo, tal como los nombra Axon Framework). + +> **Note** **Término clave — mensaje.** Un *mensaje* es el valor tipado que le +> entregas al bus: un comando (que muta) o una consulta (que lee). Cada mensaje +> de Lumen es un struct serializable simple. En términos de Spring/Axon, un +> mensaje es el DTO de comando o de consulta que envías (`send`) o consultas +> (`query`) a través de un gateway. + +> **Note** **Término clave — bus.** El *bus* es el despachador de +> comandos/consultas de Firefly. Empareja cada mensaje con exactamente un +> manejador mediante `std::any::TypeId`, lo hace pasar por una cadena de +> middleware y devuelve el resultado del manejador. El análogo en Spring/Axon es +> el `CommandGateway` / `QueryGateway`, salvo que aquí es un único `Arc` +> en proceso que proporciona el framework. + +> **Note** **Término clave — bean de manejadores.** Un *bean de manejadores* es +> un bean de inyección de dependencias ordinario cuyos métodos atienden comandos +> y consultas. Sus colaboradores llegan por inyección de constructor, y el +> framework registra cada método en el bus al arrancar. Esto es el `@Component` +> de Spring que lleva métodos `@CommandHandler` / `@QueryHandler`. + +> **Note** **Término clave — middleware.** Un *middleware* envuelve cada despacho +> con comportamiento transversal —validación, caché, correlación— antes y +> después de que el manejador se ejecute. El análogo en Spring es un +> `HandlerInterceptor` o un `MessageHandlerInterceptor` de Axon. Firefly instala +> por ti una pequeña cadena por defecto. + +> **Design note.** Todo el camino es Rust ordinario: sin proxies, sin reflexión, +> solo un registro tipado indexado por `TypeId` y una llamada a método. Los +> manejadores de Lumen viven en un bean de inyección de dependencias +> (`#[derive(Service)]` + `#[handlers]`), de modo que cada uno alcanza a sus +> colaboradores a través de `self.`. Una aplicación más simple +> puede escribir un manejador como un `async fn` libre en su lugar; la +> [alternativa de fn libre](#step-4--know-the-free-fn-handler-alternative) más +> abajo cubre esa forma. + +## Paso 1 — Entender el trait `Message` + +**Acción.** Antes de escribir ningún mensaje, observa el contrato que satisface +todo comando y consulta. Cada mensaje implementa `Message`. Nunca escribirás +este impl a mano —los derives lo generan—, pero conocer su forma explica a qué +reacciona el middleware: + +```rust,ignore +pub trait Message: Clone + Serialize + Send + Sync + 'static { + fn kind() -> MessageKind { MessageKind::Command } // Command / Query split + fn validate(&self) -> Result<(), CqrsError> { Ok(()) } // ValidationMiddleware + fn cache_ttl(&self) -> Option { None } // QueryCache +} +``` + +**Qué acaba de ocurrir.** Los *supertraits* del trait declaran lo que un mensaje +debe ser, y sus *métodos* son valores por defecto sobreescribibles que el +middleware correspondiente recoge automáticamente: + +- `Clone` representa la invocación del manejador por valor, y `Serialize` siembra + la clave de caché de consulta (la caché calcula el hash del JSON del mensaje). +- `kind()` reporta si el mensaje es un comando o una consulta. El valor por + defecto es `MessageKind::Command`; `#[derive(Query)]` lo sobreescribe. +- `validate()` es el hook de validación previa al despacho que llama el + `ValidationMiddleware`. El valor por defecto acepta todo, así que un mensaje + simple pasa intacto. +- `cache_ttl()` es la suscripción a la caché que lee el middleware `QueryCache`. + El valor por defecto `None` significa "no cacheable", así que los comandos + atraviesan la caché directamente. + +> **Note** **Término clave — `MessageKind`.** Un enum de dos variantes, +> `MessageKind::Command` / `MessageKind::Query`, que registra la naturaleza de +> escritura/lectura de un tipo de mensaje. El bus almacena el kind de cada +> manejador en el momento del registro para poder listar comandos y consultas +> por separado: esa es la segregación en "Segregación de responsabilidad entre +> comandos y consultas". + +> **Tip** **Punto de control.** Deberías ser capaz de decir, de un tirón, para +> qué sirve cada uno de los tres métodos: `kind()` separa comando de consulta, +> `validate()` controla el despacho, `cache_ttl()` suscribe una consulta a la +> caché. El resto del capítulo consiste sobre todo en hacer que los derives los +> rellenen por ti. + +## Paso 2 — Definir los comandos y la consulta de Lumen + +**Acción.** Crea `src/commands.rs`. Los cuatro mensajes son structs simples que +llevan `#[derive(Command)]` / `#[derive(Query)]`, los cuales generan el impl de +`Message`. El atributo de campo `#[firefly(validate)]` hace que un campo sea +obligatorio (el `validate()` generado rechaza un `String` vacío o un número no +positivo), y `#[firefly(cache_ttl = "...")]` se refleja en el `cache_ttl` +generado de la consulta: + +```rust,ignore +// samples/lumen/src/commands.rs +use std::sync::Arc; + +use firefly::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::domain::{DomainError, Wallet, WalletView}; +use crate::ledger::{Ledger, ReadModel}; +use crate::money::Money; + +/// `POST /api/v1/wallets` command — open a new wallet. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Command, Builder, Schema)] +#[serde(default)] +pub struct OpenWallet { + /// The wallet owner's display name — required. + #[firefly(validate)] + #[builder(into)] + pub owner: String, + /// The opening balance, in minor units (cents); must be `>= 0`. + #[serde(rename = "openingBalance")] + #[builder(default)] + pub opening_balance: i64, +} + +/// `POST /api/v1/wallets/:id/deposit` command — credit a wallet. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Command)] +#[serde(default)] +pub struct Deposit { + /// The wallet to credit — required. + #[firefly(validate)] + #[serde(rename = "walletId")] + pub wallet_id: String, + /// The amount to credit, in minor units (cents); must be `> 0`. + #[firefly(validate)] + pub amount: i64, +} + +/// `POST /api/v1/wallets/:id/withdraw` command — debit a wallet. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Command)] +#[serde(default)] +pub struct Withdraw { + #[firefly(validate)] + #[serde(rename = "walletId")] + pub wallet_id: String, + #[firefly(validate)] + pub amount: i64, +} + +/// `GET /api/v1/wallets/:id` query — cached for 30 seconds. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Query)] +#[firefly(cache_ttl = "30s")] +pub struct GetWallet { + /// The wallet id to fetch. + pub id: String, +} +``` + +**Qué acaba de ocurrir.** Tres derives hacen el trabajo pesado: + +- `#[derive(Command)]` / `#[derive(Query)]` generan el impl de `Message` de cada + struct. `Command` mantiene el `kind()` por defecto de `MessageKind::Command`; + `Query` lo sobreescribe a `MessageKind::Query`. Esa única diferencia es toda la + división CQRS: `OpenWallet` / `Deposit` / `Withdraw` se registran como comandos + y `GetWallet` se registra como consulta, sin anotación adicional. +- `#[firefly(validate)]` en un campo lo hace obligatorio: el `validate()` + generado rechaza un `String` vacío o un número no positivo *en código generado + en tiempo de compilación*, no mediante reflexión en tiempo de ejecución. En + `Deposit::amount` rechaza una cantidad cero o negativa antes de que el + manejador se ejecute siquiera, de modo que el agregado nunca llega a invocarse + con datos estructuralmente erróneos. +- `#[firefly(cache_ttl = "30s")]` en `GetWallet` se refleja en el `cache_ttl()` + generado, que el middleware `QueryCache` lee del mensaje para memoizar las + lecturas durante 30 segundos. + +Unas pocas decisiones se hacen eco del capítulo de dominio. Los comandos llevan +céntimos como `i64`, no un objeto de valor `Money`: el manejador construye +`Money`, manteniendo el contrato de transmisión como un número desnudo y la +validación simple. Y `#[serde(rename = ...)]` mantiene el JSON en camelCase +(`openingBalance`, `walletId`) mientras los campos de Rust permanecen en +snake_case. + +> **Note** `OpenWallet` también deriva `Builder` (el `@Builder` de Lombok) y +> `Schema` (que alimenta la documentación OpenAPI). `Builder` le da un +> constructor fluido —`OpenWallet::builder().owner("ada").build()`— con +> `opening_balance` por defecto a cero. Ninguno de los dos derives afecta al +> comportamiento CQRS; van de acompañantes porque `OpenWallet` es también un +> cuerpo de petición. + +> **Tip** **Punto de control.** `cargo build` compila `src/commands.rs`. El +> comportamiento de validación y caché es testeable sin un bus, porque los +> derives colocan los métodos en el propio tipo: +> +> ```rust,ignore +> assert!(OpenWallet::default().validate().is_err()); // empty owner rejected +> assert!(Deposit { wallet_id: "wlt_1".into(), amount: 0 }.validate().is_err()); +> assert!(GetWallet::default().cache_ttl().is_some()); // the 30s TTL +> ``` + +## Paso 3 — Escribir el bean de manejadores + +**Acción.** Añade el bean de manejadores a `src/commands.rs`. Los manejadores de +Lumen viven en un **bean de inyección de dependencias**, el análogo en Rust de +un `@Component` de Spring que lleva métodos `@CommandHandler` / `@QueryHandler`. +`WalletHandlers` es un `#[derive(Service)]` cuyos colaboradores —el `Ledger` del +lado de escritura y el `ReadModel` del lado de lectura— se obtienen con +`#[autowired]` desde el contenedor. La macro a nivel de impl `#[handlers]` (la +hermana CQRS de `#[rest_controller]`) marca los métodos: cada +`#[command_handler]` / `#[query_handler]` es un `async fn(&self, msg) -> +Result<.., CqrsError>`, de modo que un manejador alcanza a sus colaboradores a +través de `self`: sin globales de proceso, sin raíz de composición: + +```rust,ignore +// samples/lumen/src/commands.rs (continued) + +/// Maps a `DomainError` onto the bus's `CqrsError` channel. The web layer +/// restores the precise HTTP status from the detail message. +fn to_cqrs(e: DomainError) -> CqrsError { + CqrsError::handler(e.to_string()) +} + +/// The CQRS **handler bean** — Spring's `@Component` command/query handler. Its +/// collaborators are `#[autowired]` from the DI container; `#[handlers]` +/// registers each method on the bus. +#[derive(Service)] +struct WalletHandlers { + /// The write-side application service (autowired). + #[autowired] + ledger: Arc, + /// The read-side projection store the `GetWallet` query reads (autowired). + #[autowired] + read_model: Arc, +} + +#[handlers] +impl WalletHandlers { + /// Handles `OpenWallet`. + #[command_handler] + async fn open_wallet(&self, cmd: OpenWallet) -> Result { + if cmd.opening_balance < 0 { + return Err(CqrsError::validation("openingBalance must be >= 0")); + } + self.ledger + .open(&cmd.owner, Money::cents(cmd.opening_balance)) + .await + .map_err(to_cqrs) + } + + /// Handles `Deposit`. + #[command_handler] + async fn deposit(&self, cmd: Deposit) -> Result { + self.ledger + .deposit(&cmd.wallet_id, Money::cents(cmd.amount)) + .await + .map_err(to_cqrs) + } + + /// Handles `Withdraw`. + #[command_handler] + async fn withdraw(&self, cmd: Withdraw) -> Result { + self.ledger + .withdraw(&cmd.wallet_id, Money::cents(cmd.amount)) + .await + .map_err(to_cqrs) + } + + /// Handles `GetWallet` — serve from the projected read model, falling back + /// to folding the event stream when the projection has not yet caught up. + #[query_handler] + async fn get_wallet(&self, q: GetWallet) -> Result { + if let Some(view) = self.read_model.find(&q.id) { + return Ok(view); + } + let events = self.ledger.load_events(&q.id).await.map_err(to_cqrs)?; + Ok(Wallet::rehydrate(&q.id, &events).view()) + } +} +``` + +**Qué acaba de ocurrir.** Cada manejador de comando construye el objeto de valor +`Money` a partir del `i64` del comando, delega en el servicio de aplicación +`Ledger` autowired (que rehidrata el agregado, ejecuta el comando de dominio y +persiste; véase [Event Sourcing](./11-event-sourcing.md)), y mapea un +`DomainError` sobre el canal `CqrsError` del bus mediante `to_cqrs`. + +La consulta `get_wallet` es el patrón de lectura-tras-escritura en miniatura: +sirve primero desde el `ReadModel` proyectado, y *solo* si la proyección aún no +se ha puesto al día recurre a replegar el flujo de eventos +(`Wallet::rehydrate(..).view()`). Ese repliegue es lo que evita que una lectura +inmediatamente posterior a una escritura devuelva un saldo obsoleto bajo la +consistencia eventual que introduce la proyección. + +> **Note** Un método `#[handlers]` toma `&self` más exactamente un argumento de +> mensaje y devuelve un `Result<.., CqrsError>`. Como el bean es un bean +> ordinario del contenedor, sus colaboradores llegan por **inyección de +> constructor** a través de campos `#[autowired]`: el mismo cableado que usa +> cualquier otro bean de Firefly, sin un global de proceso que sembrar. Añadir un +> manejador es añadir un método; el framework lo encuentra. + +**Por qué importa.** Tras la macro, cada `#[command_handler]` / +`#[query_handler]` envía un `BeanHandlerRegistration` a un registro `inventory` +de tiempo de compilación. Al arrancar, `FireflyApplication` resuelve +`WalletHandlers` desde el contenedor —cableando su `Ledger` + `ReadModel` +`#[autowired]`— e instala un cierre de bus que captura el bean resuelto, de modo +que cada despacho llama a `self.open_wallet(..)` y compañía. Lumen no escribe +**ninguna** llamada de registro: el framework vierte los manejadores del bean por +ti (Paso 5). + +> **Tip** **Punto de control.** `cargo build` sigue compilando. Puedes ejercitar +> el bean directamente sin HTTP y sin bus, construyéndolo con los mismos +> colaboradores que el contenedor inyectaría: +> +> ```rust,ignore +> let handlers = WalletHandlers { +> ledger: Arc::new(Ledger::new( +> Arc::new(MemoryEventStore::new()), +> Arc::new(InMemoryBroker::new()), +> )), +> read_model: Arc::new(ReadModel::default()), +> }; +> let opened = handlers +> .open_wallet(OpenWallet { owner: "alice".into(), opening_balance: 100 }) +> .await +> .unwrap(); +> assert_eq!(opened.balance, 100); +> ``` + +## Paso 4 — Conocer la alternativa del manejador como `fn` libre + +**Acción.** Nada que escribir para Lumen aquí, pero vale la pena conocer la +segunda forma, porque una aplicación más simple recurre a ella. Un manejador no +tiene por qué ser un bean. La forma de `fn` libre es la opción natural para un +manejador *sin colaboradores* (el sample `macro-quickstart` del framework la +usa): marca un `async fn(msg) -> Result` libre con +`#[command_handler]` / `#[query_handler]`: + +```rust,ignore +// The simpler form — a free fn with no collaborators to inject. +#[command_handler] +pub async fn place_order(cmd: PlaceOrder) -> Result { + Ok(OrderView::from(cmd)) +} +``` + +**Qué acaba de ocurrir.** La macro lee el tipo del argumento (`PlaceOrder`) como +clave de despacho, genera un helper `register_place_order(bus)` **y** envía un +`HandlerRegistration` al registro `inventory` que el framework vierte, de modo +que el manejador como fn libre se descubre e instala exactamente igual que la +forma de bean. + +**Por qué importa.** Como una función libre no puede poseer un `Ledger` ni un +`ReadModel`, esta forma encaja con manejadores que computan puramente a partir +del mensaje (o que alcanzan un global de proceso). En el momento en que un +manejador necesita colaboradores inyectados —como *todos* los de Lumen— la forma +de bean del Paso 3 es la opción natural: obtiene la inyección de constructor +gratis y mantiene el manejador como un método simple sobre un `@Component`. +Lumen solo tiene manejadores de bean; el camino de fn libre no vierte ninguno +propio. + +## Paso 5 — Dejar que el framework cablee el bus + +**Acción.** De nuevo, no hay código de cableado que escribir: ese es el objetivo. +El `Bus` y el `QueryCache` se declaran como `#[bean]`s en `LumenBeans` (el +contenedor `#[derive(Configuration)]` en `src/web.rs`), el controlador +`WalletApi` autowire el `Arc`, y `FireflyApplication` hace el resto al +arrancar. La caché de consulta es una fábrica `#[bean]` sencilla: + +```rust,ignore +// samples/lumen/src/web.rs — LumenBeans (#[derive(Configuration)]). +#[bean] +impl LumenBeans { + /// The read-side query cache honouring `GetWallet`'s 30s TTL (`@Bean`). + #[bean] + fn query_cache(&self) -> QueryCache { + QueryCache::new() + } + // ... event_store, jwt_service, ledger, security beans ... +} +// The read store is *not* a `#[bean]` here — `ReadModel` is its own bean, +// registered by the scan directly. +``` + +**Qué acaba de ocurrir.** Al arrancar, `FireflyApplication`: + +- **Vierte los manejadores de bean descubiertos** con + `firefly::cqrs::register_discovered_handler_beans(&bus, &container)`: resuelve + `WalletHandlers` desde el contenedor —cableando su `Ledger` + `ReadModel`— e + instala cada método `#[command_handler]` / `#[query_handler]` sobre el bus. +- **Vierte cualquier manejador como `fn` libre** con + `firefly::cqrs::register_discovered_handlers(&bus)`, de modo que ambas formas + coexisten (Lumen solo tiene manejadores de bean, así que esto no vierte ninguno + propio). +- **Instala automáticamente la cadena de middleware del bus**: validación + (instalada primero por el núcleo), luego un propagador de correlación, y + después el middleware de caché de lectura `QueryCache` siempre que haya un bean + `QueryCache` presente. + +Lumen no llama a ninguno de estos vertidos. Conceptualmente, el framework +ejecuta: + +```rust,ignore +// What FireflyApplication does for you — no Lumen code calls this. +firefly::cqrs::register_discovered_handlers(&bus); // free-fn handlers +firefly::cqrs::register_discovered_handler_beans(&bus, &container); // WalletHandlers' 4 methods +``` + +> **Note** **¿De dónde sale el `Bus`?** Es un bean de infraestructura +> proporcionado por el framework: el núcleo registra un `Arc` en el +> contenedor antes del escaneo, de modo que el controlador `WalletApi` puede +> autowirearlo (`#[autowired] pub bus: Arc`) y el framework puede verter los +> manejadores descubiertos sobre él. Tú declaras los beans de *aplicación* +> (`QueryCache`, el ledger); el bus se cablea por ti. + +**Por qué importa.** Los manejadores de bean se resuelven desde el *mismo* +contenedor que construye el controlador y la saga, de modo que cada colaborador +—manejador, controlador, proyección— comparte el único `Ledger` y el único +`ReadModel` que el contenedor mantiene. No hay una segunda copia del modelo de +lectura que pueda desincronizarse. + +En la cadena de despacho se incluyen tres entradas de middleware. El framework +las instala automáticamente (una cuarta, autorización, llega en el borde HTTP +con [Seguridad](./14-security.md)). El middleware se ejecuta con el primero +registrado = más externo: + +| Middleware | Comportamiento | +|-----------------------------|---------------------------------------------------------------| +| `ValidationMiddleware` | llama a `Message::validate` antes del despacho, cortocircuita ante un error; instalado primero por el núcleo, así que es el más externo | +| `CorrelationMiddleware` | asegura-o-genera el id de correlación para el despacho (siguiente paso) | +| `QueryCache::middleware()` | memoiza los resultados de mensajes cuyo `cache_ttl` es `Some`; instalado cuando existe un bean `QueryCache` | + +
+ +send / query a message + +msg ↦ TypeIdmatched to a handler +middleware chain + +V + +C + +Q +V = Validation · C = Correlation · Q = QueryCache + +your handlerCommand or Query + +
Un mensaje se empareja con su manejador mediante TypeId, y luego recorre la cadena de middleware registrada —Validation como la más externa, después Correlation, después QueryCache— antes de que el manejador se ejecute. El ámbito de correlación se abre antes de la capa de caché, de modo que todo lo que esta registra lleva el id.
+
+ +> **Tip** **Punto de control.** `cargo run` arranca Lumen y la línea de CQRS del +> informe de arranque cuenta tus manejadores: tres comandos y una consulta. La +> vista de admin `/cqrs` en el puerto de gestión (`:8081`) los lista, con badge +> azul para los comandos y verde para las consultas. + +## Paso 6 — Ver cómo el bus segrega comandos y consultas + +**Acción.** Observa cómo el bus mantiene separadas ambas mitades, aunque +compartan un único registro. El bus despacha comandos y consultas a través de un +único registro indexado por `TypeId`, pero no los trata como intercambiables: +cada manejador registrado lleva el **kind** del mensaje que atiende, expuesto +como `Message::kind() -> MessageKind`: + +```rust,ignore +pub enum MessageKind { Command, Query } +``` + +**Qué acaba de ocurrir.** El valor por defecto es `MessageKind::Command`. +`#[derive(Command)]` mantiene ese valor por defecto; `#[derive(Query)]` +sobreescribe `kind()` para devolver `MessageKind::Query`. Nada en el +`src/commands.rs` de Lumen cambia: `OpenWallet` / `Deposit` / `Withdraw` ya son +comandos y `GetWallet` ya es una consulta, así que la segregación se desprende +de los derives que introdujo el Paso 2. El bus registra el kind de cada mensaje +en el momento del registro y te permite preguntar por ambas mitades por +separado: + +```rust,ignore +use firefly::cqrs::{Bus, MessageKind}; + +let bus = Bus::new(); +// In a unit test you populate a bus explicitly; the app boot resolves the +// `WalletHandlers` bean and drains its methods with +// `register_discovered_handler_beans(&bus, &container)`. +bus.register(|cmd: OpenWallet| async move { /* ... */ }); // three commands + one query + +// Inspect the registry, split by CQRS kind. +let commands = bus.command_handler_names(); // ["...::Deposit", "...::OpenWallet", "...::Withdraw"] +let queries = bus.query_handler_names(); // ["...::GetWallet"] +assert_eq!(bus.handler_count(), 4); + +// The general form both of the above delegate to: +assert_eq!(bus.handler_names_by_kind(MessageKind::Query), queries); + +// Type-level membership and removal. +assert!(bus.has_handler::()); +assert!(bus.unregister::()); // true — one was present +assert!(!bus.has_handler::()); +``` + +`command_handler_names()` y `query_handler_names()` son envoltorios finos sobre +`handler_names_by_kind(MessageKind)`, cada uno devolviendo los nombres de tipo +completamente cualificados ordenados alfabéticamente: la misma lista que devuelve +`handler_names()`, pero filtrada a un solo kind. `handler_count()` es el tamaño +total del registro; `has_handler::()` comprueba la pertenencia de un tipo de +mensaje; y `unregister::()` elimina un manejador, devolviendo si había uno +presente (útil cuando un test quiere intercambiar un manejador sin reconstruir +el bus). + +**Por qué importa.** Esto es exactamente lo que consume la vista de admin +`/cqrs`: como el bus conoce el kind de cada manejador, el panel etiqueta cada +registro con un badge (comandos en azul, consultas en verde) y muestra recuentos +separados de comandos/consultas, en lugar de una sola lista indiferenciada de +manejadores. + +> **Note** Firefly mantiene un único `Bus` y recupera la división +> comando/consulta del `kind()` de cada mensaje (fijado por el derive `Command` / +> `Query`), en lugar de hacerlo a partir de dos buses distintos. +> `command_handler_names()` / `query_handler_names()` son las vistas filtradas +> que renderiza el panel de admin `/cqrs`; `has_handler::()` / +> `unregister::()` comprueban la pertenencia y eliminan un manejador por tipo. + +## Paso 7 — Seguir el id de correlación a través del límite de despacho + +**Acción.** Entiende el middleware que mantiene rastreable una petición lógica. +Un comando rara vez actúa solo. `bus.send(Deposit { .. })` ejecuta un manejador +que puede iniciar la saga de transferencia ([Sagas](./12-sagas.md)) o lanzar una +tarea de seguimiento con `tokio::spawn`, y cada una de ellas abandona la tarea +de la petición original. Para que los logs y las trazas se lean como *una* sola +operación, todas deben compartir un único id de correlación. + +> **Note** **Término clave — id de correlación.** Un único identificador estampado +> en todo lo que se hace para una petición lógica, de modo que sus logs y trazas +> puedan unirse. Firefly lo enhebra a través de un task-local; la capa web fija +> uno por petición HTTP. El análogo en Spring es el `traceId` del MDC propagado +> por Sleuth / Micrometer Tracing. + +`firefly::cqrs::CorrelationMiddleware` lo hace cumplir en el límite de despacho. +El framework lo instala en cada bus de `FireflyApplication`, entre las capas de +validación y de caché de consulta, de modo que nunca lo cableas a mano. Si +construyes un bus tú mismo, añádelo como cualquier otro middleware: + +```rust,ignore +use firefly::cqrs::{Bus, CorrelationMiddleware}; + +let bus = Bus::new(); +bus.use_middleware(CorrelationMiddleware::new()); // earlier-registered = more outer +``` + +**Qué acaba de ocurrir.** En cada despacho el middleware **asegura-o-genera** un +id de correlación: si la petición ya se está ejecutando bajo uno —la capa de +correlación de `firefly-web` fija un id task-local por petición HTTP— reutiliza +ese id, de modo que el comando y la saga/tarea lanzada que dispara trazan todos +al mismo valor. Si no hay ningún id ambiente presente (un trabajo en segundo +plano, un test, un despacho interno), genera uno nuevo para el lapso de ese +despacho y restaura el ámbito previo a la salida, de modo que operaciones +hermanas nunca se filtran ids entre sí. + +```rust,ignore +// Inside a handler (or anything it calls), the id is observable: +let trace = firefly_kernel::correlation_id(); // Some() under the middleware +``` + +**Por qué importa.** En el bus de Lumen, el framework instala primero +`ValidationMiddleware` (así que es el más externo), luego `CorrelationMiddleware` +y después `QueryCache`. El mismo id que la capa HTTP estampó en +`POST /wallets/:id/deposit` fluye hacia el manejador `Deposit`, hacia la saga de +transferencia que pueda iniciar, y hacia los eventos que la saga publica, sin +que ningún manejador toque el id de forma explícita. Como la correlación se sitúa +por delante de `QueryCache` en la cadena, el ámbito de correlación ya está +abierto antes de que se ejecute la capa de caché, de modo que cualquier cosa que +la caché registre lleva el id también: + +
+ + send / query a message + + + + + msg ↦ TypeId + + + + middleware chain + + + V + + C + + Q + + + + + + + V = ValidationMiddleware C = Correlation Q = QueryCache + + + + + + your handler + +
El framework registra primero ValidationMiddleware (la más externa), luego CorrelationMiddleware y después QueryCache: el ámbito de correlación se abre antes de que se ejecute la capa de caché, de modo que todo lo que esta registra lleva el id.
+
+ +> **Design note.** `CorrelationMiddleware` asegura que una petición lógica +> mantenga un único id de correlación a través del límite del comando y de +> cualquier saga o continuación lanzada con `tokio::spawn` que dispare: reutiliza +> un id ambiente cuando está presente (la capa web fija uno por petición HTTP) y +> genera uno en caso contrario, restaurando el ámbito previo a la salida. Firefly +> enhebra el id a través de un task-local que este middleware acota por despacho, +> de modo que un manejador nunca tiene que pasarlo a mano. + +## Paso 8 — Despachar desde el controlador + +**Acción.** Cablea la superficie HTTP al bus. El `#[rest_controller]` +(construido en [Tu primera API HTTP](./06-first-http-api.md)) mantiene el `Bus` y +despacha a través de `send` / `query`. `Bus::query` es un sinónimo legible de +`send`. Un despacho fallido es un `CqrsError`, que la capa web mapea al estado +RFC 9457 correcto: + +```rust,ignore +// samples/lumen/src/web.rs — WalletApi handlers. +#[post("/wallets")] +async fn open( + State(api): State, + Json(body): Json, +) -> WebResult<(axum::http::StatusCode, Json)> { + let view: WalletView = api.bus.send(body).await.map_err(cqrs_to_web)?; + Ok((axum::http::StatusCode::CREATED, Json(view))) +} + +#[get("/wallets/:id")] +async fn get( + State(api): State, + Path(id): Path, +) -> WebResult> { + let view: WalletView = api.bus.query(GetWallet { id }).await.map_err(cqrs_to_web)?; + Ok(Json(view)) +} +``` + +**Qué acaba de ocurrir.** `api.bus.send(body)` empareja el tipo de `body` +(`OpenWallet`) con el manejador de comando `open_wallet` y lo hace pasar por la +cadena de middleware; `api.bus.query(GetWallet { id })` hace lo mismo para la +consulta. El controlador autowire el `Arc` (`#[autowired] pub bus: +Arc`), de modo que `api.bus` ya tiene un receptor: sin estado construido a +mano. + +`cqrs_to_web` es la costura donde un fallo de dominio se convierte en un estado +HTTP. Lee el `CqrsError` y su cadena de detalle —que, recordemos, es el texto +estable de `Display` del `DomainError` del capítulo anterior— y elige el estado: + +```rust,ignore +// samples/lumen/src/web.rs +fn cqrs_to_web(err: CqrsError) -> WebError { + match err { + CqrsError::Validation(detail) => WebError::from(FireflyError::validation(detail)), + CqrsError::Handler(detail) => { + if detail.ends_with("not found") { + WebError::from(FireflyError::not_found(detail)) // 404 + } else if detail == DomainError::InsufficientFunds.to_string() + || detail == DomainError::NonPositiveAmount.to_string() + || detail == DomainError::OwnerRequired.to_string() + { + WebError::from(FireflyError::validation(detail)) // 422 + } else { + WebError::from(FireflyError::not_found(detail)) + } + } + other => WebError::from(FireflyError::internal(other.to_string())), // 500 + } +} +``` + +**Por qué importa.** Por esto el capítulo de dominio insistió en que las cadenas +de `Display` fueran *estables*: son el contrato con el que `cqrs_to_web` hace +coincidencia para recuperar el estado preciso. Un `CqrsError` de validación se +convierte en un problema 422; un detalle de manejador "not found" se convierte +en un 404; un detalle de fondos insuficientes o de cantidad no positiva se +convierte en un 422; cualquier otra cosa cae a un 500, todo renderizado como RFC +9457 `application/problem+json`. + +> **Tip** **Punto de control.** Con `cargo run` levantado, abre una cartera y +> vuelve a leerla: +> +> ```bash +> curl -s -XPOST localhost:8080/api/v1/wallets \ +> -H 'content-type: application/json' \ +> -d '{"owner":"alice","openingBalance":100}' +> # 201 with {"id":"...","owner":"alice","balance":100} +> +> curl -s -XPOST localhost:8080/api/v1/wallets \ +> -H 'content-type: application/json' -d '{"owner":""}' +> # 422 problem+json — the empty owner failed the #[firefly(validate)] check +> ``` + +## Paso 9 — Mantener frescas las lecturas tras una escritura + +**Acción.** Cierra la brecha de lectura-tras-escritura. `GetWallet` se cachea +durante 30 segundos. Sin cuidado, un depósito actualizaría el saldo mientras un +`GetWallet` cacheado seguiría sirviendo el antiguo hasta durante 30 segundos. +Lumen invalida la familia de consultas cacheada tras cada mutación: + +```rust,ignore +// samples/lumen/src/web.rs — deposit handler. +#[post("/wallets/:id/deposit")] +async fn deposit( + State(api): State, + Path(id): Path, + Json(body): Json, +) -> WebResult> { + let cmd = Deposit { wallet_id: id, amount: body.amount }; + let view: WalletView = api.bus.send(cmd).await.map_err(cqrs_to_web)?; + api.query_cache.invalidate_type::(); // read-after-write + Ok(Json(view)) +} +``` + +**Qué acaba de ocurrir.** Aquí es donde `WalletApi` adquiere el campo que [Tu +primera API HTTP](./06-first-http-api.md) aplazó: junto a `bus`, el controlador +**autowire** el `Arc` del contenedor (`#[autowired] pub query_cache: +Arc`), de modo que `api.query_cache` tiene un receptor. El mismo bean +`QueryCache` que el framework registra como middleware del bus es el que el +controlador invalida: una sola caché, leída por el bus e invalidada por el +manejador. + +`QueryCache::invalidate_type::()` desaloja todo resultado cacheado de +exactamente ese tipo de consulta. El manejador de retirada hace lo mismo, y la +saga de transferencia ([Sagas](./12-sagas.md)) —que toca dos carteras— invalida +toda la familia `GetWallet`. + +**Por qué importa.** La consistencia lectura-tras-escritura vive en el *límite +del bus*, no dentro del manejador. El manejador computa el nuevo estado; el +controlador, tras haber mutado, desaloja la caché para que el siguiente +`GetWallet` recompute. El intercambio de backend de la caché de consulta (Redis +/ Postgres) y la invalidación dirigida por eventos reciben su propio tratamiento +en [Caché](./17-caching.md); aquí, lo importante es que una mutación y su +desalojo de caché se sitúan codo con codo en el camino de escritura. + +> **Tip** **Punto de control.** Deposita en la cartera que abriste, luego vuelve +> a leerla: el nuevo saldo llega de inmediato, aunque `GetWallet` esté cacheado +> durante 30 segundos, porque el manejador de depósito desalojó la entrada +> cacheada: +> +> ```bash +> curl -s -XPOST localhost:8080/api/v1/wallets//deposit \ +> -H 'content-type: application/json' -d '{"amount":50}' +> curl -s localhost:8080/api/v1/wallets/ # balance reflects the deposit +> ``` + +## Paso 10 — Despachar de forma reactiva (opcional) + +**Acción.** Cuando quieras un resultado perezoso y componible, usa la superficie +reactiva del bus. El bus envuelve el resultado eventual en un `Mono` perezoso: +la misma búsqueda de manejador, la misma cadena de middleware, ejecutada solo +cuando el `Mono` se suscribe, se bloquea o se espera (await). Estos métodos toman +`&Arc` para que el `Mono` pueda poseer el bus: + +| Método | Devuelve | +|---------------------------------|---------------| +| `Bus::send_mono(cmd)` | `Mono` | +| `Bus::query_mono(q)` | `Mono` | +| `Bus::send_mono_with_context` | `Mono` | +| `Bus::query_mono_with_context` | `Mono` | + +Las variantes `*_with_context` llevan un `ExecutionContext` explícito al despacho +—el id de correlación, el tenant y el principal autenticado— para cuando un +`Mono` se compone fuera del ámbito task-local que establece la capa HTTP (un +trabajo en segundo plano o un pipeline reactivo ensamblado antes de que el +contexto de la petición esté en juego). Los `send_mono` / `query_mono` simples +heredan cualquier contexto que sea ambiente en el momento de la suscripción. + +Un `GetWallet` reactivo, componiendo sobre el `Mono` de [El modelo +reactivo](./05-reactive-model.md): + +```rust,ignore +use std::sync::Arc; +use firefly::cqrs::Bus; + +let balance = bus + .query_mono::<_, WalletView>(GetWallet { id: wallet_id }) + .map(|view| view.balance) + .block() + .await?; // Some() or None +``` + +**Qué acaba de ocurrir.** `query_mono` describe el despacho sin ejecutarlo; +`.map(..)` compone una transformación sobre el `Mono` aún perezoso; +`.block().await` finalmente ejecuta la cadena y produce +`Result, FireflyError>`: `Some` en caso de acierto, `None` si el +`Mono` se completó vacío. + +**Por qué importa.** Como `firefly-reactive` fija su canal de error a +`FireflyError`, un despacho fallido se mapea de `CqrsError` a un `FireflyError` +fiel al estado (validación → 422, manejador ausente → 500), con el `CqrsError` +original preservado como `source()`. Así, un comando reactivo fluye +directamente hacia la pila de problemas RFC 9457 sin dejar de ser inspeccionable. + +## Paso 11 — Demostrar el cableado con tests + +**Acción.** El `src/commands.rs` de Lumen ejercita el bean de manejadores +directamente sin HTTP: el test que se distribuye en el crate. El bean opera sobre +sus colaboradores `#[autowired]`, así que el test lo construye con el mismo +`Ledger` + `ReadModel` que el contenedor inyectaría y llama a sus métodos (el +cableado completo del bus se cubre de extremo a extremo con los tests HTTP, que +arrancan todo el `FireflyApplication`): + +```rust,ignore +#[tokio::test] +async fn handler_bean_operates_on_its_autowired_collaborators() { + let handlers = WalletHandlers { + ledger: Arc::new(Ledger::new( + Arc::new(MemoryEventStore::new()), + Arc::new(InMemoryBroker::new()), + )), + read_model: Arc::new(ReadModel::default()), + }; + + let opened = handlers + .open_wallet(OpenWallet { owner: "alice".into(), opening_balance: 100 }) + .await + .unwrap(); + assert_eq!(opened.balance, 100); + + let after = handlers + .deposit(Deposit { wallet_id: opened.id.clone(), amount: 50 }) + .await + .unwrap(); + assert_eq!(after.balance, 150); + + let fetched = handlers + .get_wallet(GetWallet { id: opened.id.clone() }) + .await + .unwrap(); + assert_eq!(fetched.id, opened.id); +} +``` + +**Qué acaba de ocurrir.** Como el manejador es un método simple sobre un struct +simple, el test no necesita bus ni contenedor de inyección de dependencias: solo +los colaboradores. Abre, deposita y vuelve a leer, afirmando que el saldo se +mueve como se espera. + +El derive de validación también es testeable por sí solo —sin necesidad de +bus—, porque `#[derive(Command)]` genera `validate()` directamente sobre el tipo: + +```rust,ignore +#[test] +fn deposit_validates_required_fields() { + assert!(Deposit::default().validate().is_err()); + assert!( + Deposit { wallet_id: "wlt_1".into(), amount: 0 }.validate().is_err(), + "zero amount fails the #[firefly(validate)] check" + ); + assert!(Deposit { wallet_id: "wlt_1".into(), amount: 10 }.validate().is_ok()); +} + +#[test] +fn get_wallet_carries_cache_ttl() { + assert!(GetWallet::default().cache_ttl().is_some()); +} +``` + +> **Tip** **Punto de control.** `cargo test` está en verde. El test del bean de +> manejadores y los tests de validación/caché pasan sin un servidor en marcha, y +> los tests de integración HTTP arrancan todo el `FireflyApplication` para cubrir +> el bus de extremo a extremo. + +## Resumen — qué cambió en Lumen + +Los caminos de lectura y escritura de Lumen ahora están separados, tipados y +despachados por bus: + +- **`src/commands.rs`** — `OpenWallet` / `Deposit` / `Withdraw` llevan + `#[derive(Command)]` con `#[firefly(validate)]` en los campos obligatorios; + `GetWallet` lleva `#[derive(Query)]` con `#[firefly(cache_ttl = "30s")]`. Los + derives generan el impl de `Message`, las comprobaciones de `validate()`, el + `cache_ttl` de la consulta y el `kind()` de cada mensaje (la división + comando/consulta). +- **El bean `WalletHandlers`** (`#[derive(Service)]` + `#[handlers]`) lleva los + métodos `#[command_handler]` / `#[query_handler]` y hace `#[autowired]` del + `Ledger` + `ReadModel`: un manejador de comandos/consultas `@Component` de + Spring. Los manejadores de comando construyen el objeto de valor `Money` y + delegan en `self.ledger`; la consulta sirve `self.read_model` y recurre a + replegar el flujo para la frescura lectura-tras-escritura. Una aplicación más + simple puede escribir un manejador sin colaboradores como un `async fn` libre + en su lugar (se aplica la misma macro `#[command_handler]`). +- **Inyección de constructor, sin global de proceso.** El bean de manejadores + alcanza a sus colaboradores a través de campos `#[autowired]` que el contenedor + rellena, de modo que no hay ningún `OnceLock` que sembrar ni paso `bind`: el + `#[bean]` `ledger` es una fábrica pura. +- **El bus** es un bean proporcionado por el framework que `WalletApi` + autowire; el framework resuelve el bean de manejadores desde el contenedor y + vierte sus métodos sobre el bus (`register_discovered_handler_beans`, junto al + `register_discovered_handlers` de `fn` libre) e instala automáticamente el + middleware de validación, correlación y `QueryCache`. El controlador despacha + vía `bus.send` / `bus.query`, con `cqrs_to_web` mapeando un `CqrsError` (que + lleva la cadena `Display` del dominio) al estado RFC 9457 correcto: 422 para + reglas de negocio, 404 para no encontrado. +- **La segregación comando/consulta** se desprende de los derives: un único + `Bus`, `command_handler_names()` / `query_handler_names()` filtrando por + `kind()`, y el panel de admin `/cqrs` renderizando ambas mitades por separado. +- **La lectura-tras-escritura** se hace cumplir en el límite del bus: + `query_cache.invalidate_type::()` se ejecuta tras cada mutación. + +Ahora también sabes que el bus expone una superficie reactiva (`send_mono` / +`query_mono`, que devuelven un `Mono` perezoso) cuyo canal de error es +`FireflyError`, de modo que un despacho reactivo fluye directamente hacia la pila +de problemas RFC 9457. + +## Ejercicios + +1. **Observa el cortocircuito de la validación.** En un test, construye un `Bus`, + añade `ValidationMiddleware::new()`, registra un cierre de manejador de + `Deposit` que conduzca un `WalletHandlers` que construyas a mano, y haz + `bus.send` de un `Deposit { wallet_id: "wlt_1".into(), amount: 0 }`. Afirma que + el resultado es un `CqrsError::Validation` y que el ledger nunca se tocó (abre + primero una cartera, luego deposita cero y confirma que su saldo no ha + cambiado). + +2. **Demuestra la caché, luego rómpela.** Contra el router ensamblado por el + framework (`build_router().await`), haz `query(GetWallet { id })` dos veces y + confirma que la segunda se sirve desde caché (instrumenta `ReadModel::find` o + traza un contador). Deposita en la cartera, luego vuelve a hacer `query`: + afirma que el nuevo saldo regresa, probando que `invalidate_type::()` + hizo su trabajo. + +3. **Añade un comando `CloseWallet`.** Define `CloseWallet { #[firefly(validate)] + wallet_id: String }` con `#[derive(Command)]`, luego añade un método + `#[command_handler] async fn close_wallet(&self, cmd: CloseWallet) -> + Result` al impl `#[handlers]` de `WalletHandlers` que + devuelva un `WalletView`, y despáchalo. El framework resuelve el bean y vierte + el nuevo método automáticamente: no añades ninguna llamada de registro. (Aún no + necesitas un `close` de dominio: devolver la vista actual basta para ejercitar + el cableado). + +4. **Composición reactiva.** Reescribe el manejador del controlador `get` para + usar `bus.query_mono::<_, WalletView>(GetWallet { id }).map(|v| v.balance)` y + devolver solo el saldo como JSON. Fíjate en dónde el canal `FireflyError` toma + el relevo de `CqrsError`. + +5. **Inspecciona la división.** En un test, registra los cuatro manejadores de + Lumen en un `Bus`, luego afirma que `bus.command_handler_names()` tiene tres + entradas y `bus.query_handler_names()` tiene una. Confirma que + `bus.handler_count()` es `4` y que `bus.has_handler::()` es `true`. + Esto es exactamente lo que renderiza el panel de admin `/cqrs`. + +## Adónde ir después + +El bus despacha *dentro* del servicio. Para propagar lo que ocurrió *entre* +colaboradores —la proyección del modelo de lectura, los suscriptores externos— +difunde eventos de dominio. Continúa hacia +**[Arquitectura dirigida por eventos y mensajería](./10-eda-messaging.md)**. + +- Los manejadores delegan en el `Ledger`, que rehidrata el agregado y persiste + sus eventos: esa maquinaria es **[Event Sourcing](./11-event-sourcing.md)**. +- Un comando que toca dos carteras se ejecuta como una saga compensatoria en + **[Sagas, flujos de trabajo y TCC](./12-sagas.md)**. +- El intercambio de backend de la caché de consulta y la invalidación dirigida + por eventos reciben su propio tratamiento en **[Caché](./17-caching.md)**. diff --git a/docs/book/src-es/10-eda-messaging.md b/docs/book/src-es/10-eda-messaging.md new file mode 100644 index 00000000..4555f11c --- /dev/null +++ b/docs/book/src-es/10-eda-messaging.md @@ -0,0 +1,1001 @@ +# Arquitectura orientada a eventos y mensajería + +Al final del [capítulo de CQRS](./09-cqrs.md), Lumen ya sabía abrir una cartera, +ingresar, retirar y leer un saldo, pero el lado de comandos y el lado de +consultas hacían un poco de trampa en silencio. El agregado `Wallet` emitía +eventos de dominio bien definidos (`WalletOpened`, `MoneyDeposited`, +`MoneyWithdrawn`), el `Ledger` los persistía y, después, nada los llevaba a +ninguna parte. El modelo de lectura al que sirve la consulta `GetWallet` había +que repararlo sobre la marcha replegando el flujo de eventos cada vez que se leía. + +Al final de *este* capítulo, Lumen cierra ese bucle. Cada evento que el ledger +persiste se **publica** además en un broker, y una **proyección** del modelo de +lectura —un bean cuyo método consume esos eventos publicados— mantiene el lado de +consultas al día sin que el lado de escritura sepa siquiera que existe. Eso es la +arquitectura orientada a eventos: un hecho se publica una vez, y cualquier número +de reacciones independientes se suscriben a él. El rastro de auditoría, la +notificación de bienvenida, el modelo de lectura del saldo: cada uno se convierte +en un suscriptor que puedes añadir meses después sin tocar un solo manejador de +comandos. + +Construiremos el bucle tal como lo construye el propio código fuente de Lumen: un +puente de una sola función que convierte un evento de dominio persistido en un +sobre de transporte, una llamada de publicación al final del commit del ledger y +un bean de proyección que el framework descubre y conecta por ti. Luego haremos +un recorrido por la maquinaria de mensajería que lo rodea —topics con globs, +grupos de consumidores, reintento/dead-letter, filtros, la superficie reactiva, +los eventos en proceso y los transportes de producción— para que sepas qué +herramienta usar en cada caso. + +Al terminar este capítulo, serás capaz de: + +- Distinguir un **evento de dominio** (el hecho duradero del event-sourcing) de + un **evento de mensajería** (el sobre de transporte), y tender un puente del uno + al otro con una sola función de mapeo. +- Publicar cada evento confirmado del `Ledger` a un `Broker`, en el orden que + garantiza que un suscriptor nunca vea un hecho sin confirmar. +- Escribir la **proyección** del modelo de lectura como un bean `#[derive(Service)]` + cuyo método `#[event_listener]` el framework descubre y suscribe por ti, y + entender por qué reconstruir desde el flujo lo hace idempotente. +- Aprovechar el alcance del broker: patrones de topic con globs, grupos de + consumidores, reintento con dead-lettering, filtros por sobre y la superficie + reactiva `Flux`. +- Diferenciar los tres roles de evento del broker —`#[event_listener]`, + `#[application_event_listener]` / `#[transactional_event_listener]` y + `externalize_after_commit`— y cambiar el broker en memoria por Kafka, RabbitMQ, + Postgres o Redis sin modificar ningún manejador. + +## Conceptos que conocerás + +Antes de la primera línea de código, aquí están las ideas en las que se apoya +este capítulo. Cada una se reintroduce en contexto donde se usa por primera vez; +esta es la versión breve. + +> **Note** **Término clave — arquitectura orientada a eventos (EDA).** Un estilo +> en el que los componentes se comunican *publicando hechos* en lugar de llamarse +> entre sí. Un productor anuncia que algo ocurrió; cualquier número de +> *suscriptores* reacciona, y al productor ni le importa ni sabe quiénes son. El +> análogo en Spring es Spring Cloud Stream / Spring for Apache Kafka: una capa de +> publicación/suscripción sobre un broker de mensajes. + +> **Note** **Término clave — broker.** Un *broker* es el transporte que lleva un +> evento publicado a sus suscriptores. `firefly-eda` define un *port* `Broker` +> agnóstico al transporte; el `InMemoryBroker` en proceso es el predeterminado, y +> Kafka, RabbitMQ, Postgres y Redis implementan ese mismo port. Este es el papel +> que desempeñan el `MessageChannel` / `KafkaTemplate` + listener container de +> Spring. + +> **Note** **Término clave — proyección.** Una *proyección* consume un flujo de +> eventos y mantiene una vista derivada y optimizada para consultas (el *modelo de +> lectura*). Es el lado de lectura de la segregación de responsabilidades de +> comandos y consultas (CQRS), que se mantiene al día reaccionando a los eventos +> del lado de escritura. Los desarrolladores de Spring las construyen con métodos +> `@KafkaListener` / `@EventListener` que escriben en un almacén de consultas. + +> **Note** **Término clave — idempotente.** Una operación es *idempotente* cuando +> aplicarla más de una vez tiene el mismo efecto que aplicarla una sola vez. Bajo +> una entrega al-menos-una-vez, un broker puede entregar el mismo evento a un +> suscriptor dos veces; una proyección idempotente absorbe la reentrega sin +> corromper su vista. + +`firefly-eda` es el **port de arquitectura orientada a eventos** del framework. +Define el sobre `Event` por el que fluye todo evento de Firefly, los ports +`Publisher` / `Subscriber` / `Broker`, un `InMemoryBroker` en proceso y la +maquinaria de mensajería: topics con globs, grupos de consumidores, reintento/DLQ, +filtros de eventos y una superficie de suscripción reactiva `Flux`. Los +transportes de producción (Kafka, RabbitMQ, outbox de Postgres, Redis Streams) +implementan los mismos ports y encajan en el momento del cableado, de modo que la +proyección de Lumen nunca cambia cuando cambia el broker. + +> **Design note.** El `Broker` es el port de mensajería de Firefly agnóstico al +> transporte: publicas un `Event` en él y le suscribes manejadores. +> `wrap_listener` añade reintento y dead-lettering; las suscripciones aceptan +> patrones de topic con globs. Como todo transporte de producción implementa el +> mismo port, un manejador nunca cambia cuando cambia el broker: el cableado elige +> el adaptador, el código se queda donde está. + +## Paso 1 — Diferenciar los dos tipos de «evento» + +Antes de cablear nada, conviene ser preciso con la palabra *evento*, porque Lumen +acaba usándola para dos cosas distintas, y confundirlas lleva a echar mano del +port equivocado. + +> **Note** **Término clave — evento de dominio frente a evento de mensajería.** Un +> *evento de dominio* (el del event-sourcing) es el hecho duradero y versionado +> que emite un agregado; vive en el *store* de eventos y es el tema del +> [próximo capítulo](./11-event-sourcing.md). Un *evento de mensajería* es el +> sobre de transporte que lleva un hecho *a los suscriptores*; vive en el *broker*. +> En términos de Spring: un evento de dominio es un registro persistido con JPA en +> tu tabla de eventos; un evento de mensajería es la carga útil que entregas a +> `KafkaTemplate.send(...)`. + +Un **evento de dominio** en el sentido del event-sourcing —el `DomainEvent` de +`firefly::eventsourcing`— es el hecho duradero y versionado que emite el agregado +`Wallet` y que el próximo capítulo convierte en la fuente de la verdad. Vive en el +*store* de eventos. + +Un **evento de mensajería** —el `Event` de `firefly::eda`— es el sobre de +transporte que lleva un hecho *a los suscriptores*. Vive en el *broker*. Lumen +tiende un puente entre ambos con una sola función (`to_envelope`, construida en el +Paso 3): el ledger persiste un `DomainEvent`, luego lo mapea a un `Event` y lo +publica. Este capítulo trata del segundo tipo: poner el hecho en el cable y +reaccionar a él. El primer tipo es el tema del próximo capítulo. + +> **Tip** **Punto de control.** Puedes enunciar, en una frase cada uno, qué es un +> evento de dominio (un hecho duradero en el store) y qué es un evento de +> mensajería (un sobre de transporte en el broker), y qué capítulo es dueño de +> cada uno. Ten presente esa distinción: cada ruta de código de abajo se sitúa con +> firmeza en uno de los dos lados. + +## Paso 2 — Leer el sobre `Event` + +`Event` es el sobre de transporte canónico de Firefly. Tiene una *forma JSON +estable* —nombres de campo fijos y reglas de omisión— para que productores y +consumidores coincidan en los bytes con independencia del broker o el servicio. +Cualquier sistema que respete el contrato interopera: el mismo sobre es compatible +a nivel de cable entre los ports de Firefly en Java, .NET, Go y Python. + +Construye uno con `Event::new`, que además estampa el `correlation_id` a partir +del ámbito de correlación task-local del kernel (de modo que un evento publicado +lleva el mismo id de correlación que la petición que lo produjo): + +```rust +use firefly_eda::Event; + +let ev = Event::new( + "orders.created", // topic — where it is published + "OrderCreated", // event type — the logical name + "orders-svc", // source — the producing service + Some(br#"{"id":"o1"}"#.to_vec()), // payload (base64 on the wire) +) +.with_header("x-tenant", "acme") // arbitrary routing/metadata header +.with_key(b"customer-42".to_vec()); // partition / routing key +``` + +Qué acaba de pasar, campo a campo. `Event::new` toma cuatro argumentos +posicionales —`topic`, `event_type`, `source` y un `payload` opcional— y rellena +el resto: un `id` nuevo, el `time` actual (UTC) y el `correlation_id` ambiente. +Los dos métodos del builder son aditivos: `with_header` inserta una cabecera +string→string en un mapa ordenado (para que la codificación sea determinista), y +`with_key` establece la clave opcional de partición/enrutamiento. + +La `key` lleva la clave de partición/enrutamiento prevista según el contrato de +`Event`; se *omite* del cable cuando está ausente, de modo que los eventos +producidos antes de que existiera el campo siguen siendo idénticos byte a byte. +Una salvedad honesta: los adaptadores actuales todavía no enrutan a partir de +`key`. El adaptador de Kafka deriva la clave de registro de `correlation_id` (con +respaldo en el id del evento), y el adaptador de RabbitMQ enruta por el topic. +Tratar `key` como la clave de partición/enrutamiento es la *intención de diseño* +del contrato, no una garantía de los adaptadores de hoy. + +> **Tip** **Punto de control.** Puedes construir un `Event` y volver a leer sus +> campos: `Event::new("t", "T", "s", None).with_header("k", "v").headers.get("k")` +> devuelve `Some("v")`, y `.with_key(b"abc".to_vec()).key` es +> `Some(b"abc".to_vec())`. Una clave ausente nunca aparece en el cable. + +## Paso 3 — Tender un puente de un evento de dominio al sobre + +Lumen nunca construye un `Event` a mano dentro de un manejador. El ledger es dueño +de una función de mapeo que convierte un `DomainEvent` persistido en el sobre +canónico, llevando el evento de dominio codificado en JSON como carga útil y el id +de la cartera como clave de partición prevista. Colócala en +`samples/lumen/src/ledger.rs` junto a las dos constantes compartidas a las que +hacen referencia tanto el publicador como la proyección: + +```rust +use firefly::eda::Event; +use firefly::eventsourcing::DomainEvent; + +use crate::domain::AGGREGATE_TYPE; // the const "Wallet" + +/// The EDA topic every wallet domain event is published to. The projection +/// and any external subscriber key off it. +pub const EVENTS_TOPIC: &str = "wallets.events"; + +/// The logical EDA source stamped on published events. +pub const EVENT_SOURCE: &str = "lumen"; + +/// Maps a persisted `DomainEvent` onto the canonical EDA `Event` envelope, +/// carrying the JSON-encoded domain event as the payload and the wallet id as +/// the partition key (so per-wallet events stay ordered on a real broker). +pub fn to_envelope(event: &DomainEvent) -> Event { + let payload = serde_json::to_vec(event).expect("domain event serialises"); + Event::new( + EVENTS_TOPIC, + event.event_type.clone(), + EVENT_SOURCE, + Some(payload), + ) + .with_key(event.aggregate_id.clone().into_bytes()) + .with_header("aggregateType", AGGREGATE_TYPE) + .with_header("aggregateId", event.aggregate_id.clone()) + .with_header("version", event.version.to_string()) +} +``` + +Qué acaba de pasar, con tres decisiones de diseño en las que merece la pena +detenerse: + +- El **topic** (`wallets.events`) es una constante compartida. El publicador y la + proyección hacen referencia al *mismo* valor `EVENTS_TOPIC`, de modo que el + nombre del canal nunca puede divergir entre ellos: renombrar la constante mueve + ambos lados a la vez. +- La **key** es el id de la cartera. Es la clave de partición prevista para que, + una vez que un broker enrute a partir de ella, todos los eventos de una cartera + caigan en la misma partición y se mantengan en orden. (El adaptador de Kafka de + hoy basa la clave de los registros en `correlation_id` y RabbitMQ enruta por el + topic, así que esto es la intención de diseño del contrato, no una garantía + actual, exactamente como explicaba el Paso 2.) +- Las **cabeceras** (`aggregateType`, `aggregateId`, `version`) llevan justo los + metadatos de enrutamiento suficientes para que un suscriptor encuentre y vuelva + a replegar el agregado afectado *sin decodificar la carga útil*. Eso es + precisamente lo que hace la proyección de Lumen en el Paso 5: lee `aggregateId` + de una cabecera y nunca toca el cuerpo. + +> **Tip** **Punto de control.** Un test unitario sobre `to_envelope` (Lumen +> incluye uno) afirma que `env.topic == EVENTS_TOPIC`, +> `env.event_type == "WalletOpened"`, `env.key == Some(b"wlt_x".to_vec())` y que +> las cabeceras `aggregateId` / `version` están establecidas. Si eso se cumple, el +> puente es fiel. + +## Paso 4 — Publicar desde el ledger (guarda antes de publicar) + +El `Ledger` es la única ruta de escritura que llaman todos los comandos y la saga +de transferencia. Después de añadir al store los eventos sin confirmar de un +agregado con concurrencia optimista, publica cada uno —`to_envelope` y luego +`broker.publish`— para que la proyección aguas abajo pueda reaccionar. Aquí está +el método `commit` de `Ledger`: + +```rust +use firefly::eda::Broker; +use firefly::eventsourcing::EventSourcingError; + +use crate::domain::{DomainError, Wallet}; + +/// Appends the aggregate's uncommitted events at `expected_version` +/// (optimistic concurrency) then publishes each to the EDA broker. +async fn commit(&self, wallet: &mut Wallet, expected: i64) -> Result<(), DomainError> { + let events = wallet.take_uncommitted(); + if events.is_empty() { + return Ok(()); + } + self.store + .append(&wallet.root.id, expected, events.clone()) + .await + .map_err(|e| match e { + EventSourcingError::Concurrency => { + DomainError::NotFound(format!("{}: concurrent modification", wallet.root.id)) + } + other => DomainError::NotFound(format!("{}: {other}", wallet.root.id)), + })?; + for event in &events { + self.broker + .publish(to_envelope(event)) + .await + .map_err(|e| DomainError::NotFound(format!("publish failed: {e}")))?; + } + Ok(()) +} +``` + +Qué acaba de pasar. `take_uncommitted()` drena los eventos que produjo el comando +de dominio; si no hay ninguno, no hay nada que hacer. Luego `store.append(...)` +los persiste en la versión `expected` —la comprobación de concurrencia optimista—. +Solo *después* de que eso tiene éxito, el bucle convierte cada evento en un sobre +y lo publica. El `broker` aquí es un `Arc`: el ledger programa contra +el *port*, nunca contra un transporte concreto. + +Fíjate en el orden: **añade antes de publicar.** Un suscriptor nunca debe ver un +hecho que no se persistió. Si el append falla —incluida la carrera de concurrencia +optimista—, nunca se alcanza el bucle, así que no se difunde ningún evento. El +store que respalda este ledger es el store de eventos en memoria; el +[próximo capítulo](./11-event-sourcing.md) es donde ese store se gana el nombre de +*event-sourced*. + +> **Note** Añade antes de publicar: un suscriptor nunca debe ver un hecho que no +> se persistió. El hueco entre el append y el publish —donde un fallo podría +> persistir un hecho pero perder la difusión— es exactamente lo que elimina el +> outbox transaccional del [próximo capítulo](./11-event-sourcing.md). + +> **Tip** **Punto de control.** `cargo test -p lumen` sigue pasando: el ida y +> vuelta de abrir/ingresar/retirar del ledger persiste tres eventos y publica tres +> sobres, en ese orden, sin ningún suscriptor todavía conectado. + +## Paso 5 — Observar el fan-out del broker en proceso + +`InMemoryBroker` es el transporte predeterminado: entrega por fan-out, coincidencia +de topics con globs y round-robin por `(topic, group)`, sin dependencias externas. +Es el broker que expone el stack web del framework (y que registra en el contenedor +de DI como el port `Arc`), y es todo lo que necesitan la build +didáctica y la suite de tests. Antes de cablear la proyección de Lumen, observa el +broker de forma aislada: suscribe un manejador, publica un evento: + +```rust +use firefly_eda::{handler, Event, InMemoryBroker}; + +#[tokio::main] +async fn main() { + let broker = InMemoryBroker::new(); + + broker + .subscribe( + "wallets.events", + handler(|ev: Event| async move { + println!( + "observed {} for {}", + ev.event_type, + ev.headers.get("aggregateId").map(String::as_str).unwrap_or("?") + ); + Ok(()) + }), + ) + .unwrap(); + + let ev = Event::new( + "wallets.events", + "WalletOpened", + "lumen", + Some(br#"{"wallet_id":"wlt_1"}"#.to_vec()), + ); + broker.publish(ev).await.unwrap(); + broker.close().unwrap(); +} +``` + +Qué acaba de pasar. `handler(closure)` envuelve una clausura asíncrona como un +callback de entrega con conteo de referencias (el tipo que el broker almacena por +suscripción). `subscribe(topic, handler)` lo registra para el topic; el método +inherente del `InMemoryBroker` concreto es síncrono, así que devuelve un `Result` +al que aplicas `.unwrap()` en vez de `.await`. `publish(ev).await` ejecuta luego +cada manejador coincidente de forma secuencial en la tarea del publicador. +`close()` libera el broker. + +> **Note** `InMemoryBroker::publish` espera cada manejador suscrito de forma +> secuencial en la tarea del publicador; el primer error de un manejador +> cortocircuita y se devuelve al publicador (envuelto en `EdaError::Handler`). +> Tras `close()`, tanto publish como subscribe fallan con `EdaError::Closed`. +> (Cuando echas mano del *port* `dyn Broker` en lugar del tipo concreto —como hace +> el ledger de Lumen— los métodos del trait son `async`, así que también aplicas +> `.await` a `subscribe`. Los métodos inherentes del concreto son síncronos; los +> métodos del port son asíncronos. El mismo broker, dos superficies.) + +> **Tip** **Punto de control.** Ejecuta ese `main`. Imprime +> `observed WalletOpened for wlt_1`. Si cambias el topic de la suscripción por un +> glob como `wallets.*`, sigue coincidiendo: eso es el Paso 6. + +## Paso 6 — Cerrar el bucle con un bean de proyección + +Aquí es donde Lumen cierra el bucle de CQRS. La **proyección** es un bean de DI: +el análogo en Rust de un `@Component` de Spring con un método `@EventListener`. +`WalletProjection` es un `#[derive(Service)]` cuyos colaboradores se obtienen con +`#[autowired]` del contenedor: el `Ledger` (por el store de eventos que reproduce) +y el `ReadModel` al que alimenta —el *mismo* `ReadModel` que lee la consulta +`GetWallet`—. Un impl `#[handlers]` marca su método con +`#[event_listener(topic = "wallets.events")]`, de modo que por cada evento +entregado el framework lo llama; alcanza sus colaboradores a través de `self`, +recarga el flujo de la cartera afectada, lo repliega en una `WalletView` y lo hace +upsert. + +> **Note** **Término clave — `#[derive(Service)]` / `#[handlers]` / `#[event_listener]`.** +> `#[derive(Service)]` marca una estructura como un bean de DI singleton cuyos +> campos `#[autowired]` rellena el contenedor (el `@Service`/`@Component` de +> Spring). `#[handlers]` sobre el impl le indica al framework que escanee sus +> métodos en busca de atributos de manejador. `#[event_listener(topic = …)]` +> suscribe un método a un topic del broker —el análogo de `@KafkaListener`—. +> Escribes la reacción; el framework hace la suscripción. + +Añade esto a `samples/lumen/src/ledger.rs`: + +```rust +use std::sync::Arc; + +use firefly::eda::Event; +use firefly::prelude::*; + +use crate::domain::Wallet; +// `Ledger` and `ReadModel` are defined earlier in this same module. + +/// The read-model **projection bean** — Spring's `@Component @EventListener`. It +/// `#[autowired]`s the `Ledger` (for the event store it replays) and the +/// `ReadModel` it feeds; `#[handlers]` subscribes its `project` method to +/// `EVENTS_TOPIC`. The idempotent rebuild-from-stream projection that closes the +/// CQRS loop, wired entirely through the DI container with no process-global. +#[derive(Service)] +struct WalletProjection { + /// The application service whose event store the projection replays + /// (autowired). + #[autowired] + ledger: Arc, + /// The read model the projection upserts (autowired) — the same instance the + /// `GetWallet` query reads. + #[autowired] + read_model: Arc, +} + +#[handlers] +impl WalletProjection { + /// Projects one delivered wallet event into the read model. + #[event_listener(topic = "wallets.events")] + async fn project(&self, ev: Event) -> FireflyResult<()> { + let Some(wallet_id) = ev.headers.get("aggregateId") else { + return Ok(()); + }; + // A transient store miss is swallowed so one poison message never stalls + // the projection — the EDA at-least-once contract. + if let Ok(events) = self.ledger.store().load(wallet_id).await { + let view = Wallet::rehydrate(wallet_id, &events).view(); + self.read_model.upsert(view); + } + Ok(()) + } +} +``` + +Qué acaba de pasar, línea a línea. La estructura tiene dos campos `#[autowired]`, +así que el contenedor construye `WalletProjection` entregándole los singletons +`Ledger` y `ReadModel` existentes: sin `new`, sin código de cableado. El método +`project` lee `aggregateId` de una cabecera (los metadatos de enrutamiento que +estampó el Paso 3); si falta, el evento no es para nosotros y devolvemos `Ok(())`. +En caso contrario hacemos `load` del flujo de eventos completo de la cartera, +`rehydrate` del agregado, tomamos su `.view()` y la hacemos `upsert` en el modelo +de lectura. El método devuelve `FireflyResult<()>`: el `Result<(), FireflyError>` +del framework. + +Dos propiedades hacen de esta una *buena* proyección, no meramente una que +funciona. + +Es **idempotente.** En lugar de mutar la fila del modelo de lectura a partir del +único evento entregado (`balance += amount`), recarga el flujo completo de la +cartera y reconstruye la vista desde cero. Bajo la entrega al-menos-una-vez de la +EDA, un `MoneyDeposited` reentregado contaría dos veces si aplicaras el delta, +pero volver a replegar el mismo flujo converge en la misma `WalletView` sin +importar cuántas veces llegue el evento. La cabecera lleva el `aggregateId`; eso +es todo lo que la proyección necesita para encontrar el flujo. + +Está **desacoplada.** `WalletProjection` no importa ningún comando, no llama a +ningún manejador y no tiene ni idea de que se procesó un ingreso. Reacciona +puramente al hecho publicado. Puedes añadir un suscriptor `FraudDetector` o +`WelcomeNotifier` a su lado sin tocar una línea de la ruta de comandos, que es +exactamente el Ejercicio 1. + +> **Note** `#[event_listener(topic = "wallets.events")]` sobre un método de un bean +> `#[handlers]` envía un `BeanListenerRegistration` al registro `inventory` que el +> framework drena. En el arranque, `FireflyApplication` resuelve `WalletProjection` +> desde el contenedor —autowireando su `Ledger` + `ReadModel`— y suscribe su método +> `project` al topic mediante +> `subscribe_discovered_listener_beans(broker, container)`. La suscripción se +> cablea por ti; tú solo escribes la reacción. + +> **Tip** **Punto de control.** `cargo test -p lumen` pasa el bucle HTTP completo: +> un `POST /api/v1/wallets/:id/deposit` fluye comando → ledger → store → broker → +> proyección → modelo de lectura, y el siguiente `GET /api/v1/wallets/:id` se sirve +> desde la vista proyectada, sin reparación manual. + +## Paso 7 — Entender cómo se cablea la proyección (sin raíz de composición) + +Como la proyección es un bean ordinario del contenedor, sus colaboradores llegan +por **inyección por constructor** a través de campos `#[autowired]`: sin +proceso-global que sembrar, sin paso `bind`. El contenedor entrega a +`WalletProjection` el mismo `Ledger` (y por tanto el mismo store de eventos) y el +mismo `ReadModel` que entrega a los manejadores de CQRS, así que los eventos que +publican los manejadores son exactamente los eventos que la proyección consume y +proyecta en la lectura que sirve la consulta `GetWallet`. + +Por eso la fábrica `#[bean]` de `ledger` en `samples/lumen/src/web.rs` es ahora +una **fábrica pura**: construye el `Ledger` y lo devuelve, sin efecto secundario +de siembra de proyección: + +```rust,ignore +// samples/lumen/src/web.rs — the `ledger` #[bean] factory. +#[bean] +fn ledger(&self, store: Arc, broker: Arc) -> Ledger { + let store: Arc = store; + Ledger::new(store, broker) +} +``` + +Qué acaba de pasar. Los parámetros de la fábrica se autowirean ellos mismos: el +contenedor proporciona el bean `MemoryEventStore` y el port `Arc` (que +el stack web registra, con `InMemoryBroker` por defecto). La fábrica hace upcast +del store concreto al port `dyn EventStore` y construye el `Ledger`. Aquí no hay +ninguna llamada a subscribe, ni una raíz de composición en ninguna parte. + +La suscripción en sí la cablea `FireflyApplication` en el arranque: +`subscribe_discovered_listener_beans(broker, container)` resuelve el bean de +proyección y drena su método `#[event_listener]` sobre el broker (junto a la +versión de `fn` libre `subscribe_discovered_listeners` para listeners que no +necesitan colaboradores inyectados). Así que ni la fábrica `ledger` ni ninguna +raíz de composición llaman a un helper de suscripción a mano. + +> **Design note.** Todo el bucle se *declara*, no se *ensambla*. Declaras el bean +> del store, el bean del modelo de lectura, la fábrica del ledger y el bean de +> proyección; el framework descubre cada uno, autowirea sus dependencias y suscribe +> el listener: la historia de Spring de `@Configuration` + `@Bean` + escaneo de +> componentes, con el registro de endpoints de listener sustituido por el drenaje +> de inventory. Cuando un listener *no* necesita colaboradores inyectados, la forma +> más sencilla de `fn` libre —un escueto +> `#[event_listener(topic = "…")] async fn(ev: Event) -> FireflyResult<()>`— es la +> alternativa, descubierta de la misma manera. + +> **Tip** **Punto de control.** Lee el informe de arranque de Lumen. La línea +> `:: cqrs handlers: … | event listeners: … | scheduled tasks: …` ahora cuenta al +> menos un event listener: la proyección que el framework acaba de suscribir. + +## Paso 8 — Llegar más lejos: topics con globs y grupos de consumidores + +Un topic de suscripción es un *patrón* glob (`*`, `?`, `[..]`, `{a,b}`); un evento +publicado se entrega a cada suscripción cuyo patrón coincida con su topic. Lumen se +suscribe al exacto `wallets.events`, pero un servicio multi-evento podría desplegar +un solo listener sobre toda una familia: + +```rust,ignore +broker.subscribe("wallets.*", handler(|ev| async move { Ok(()) })).unwrap(); +// matches wallets.events, wallets.audit, ... +``` + +> **Note** **Término clave — grupo de consumidores.** Un *grupo de consumidores* es +> un conjunto de suscriptores que *compiten* por los eventos de un topic: cada +> evento coincidente va a exactamente **un** miembro del grupo (round-robin), +> mientras que grupos distintos reciben cada uno su propia copia. Es el modelo de +> grupos de consumidores de Kafka y el `group` / `@KafkaListener(groupId=…)` de +> Spring: la forma de escalar una carga de trabajo horizontalmente sin +> procesamiento doble. + +```rust,ignore +broker.subscribe_group("wallets.events", "projections", handler1).unwrap(); +broker.subscribe_group("wallets.events", "projections", handler2).unwrap(); +// each event reaches exactly one of handler1/handler2 +``` + +Así es como escalarías la proyección de Lumen horizontalmente: ejecuta varias +instancias de proyector en un grupo y el broker reparte los eventos entre ellas +(round-robin por `(topic, group)`), cada instancia dueña de una porción del espacio +de carteras. Una suscripción sin grupo —la que crea `#[event_listener]` por +defecto— siempre recibe su propia copia. + +> **Tip** **Punto de control.** En un test de `InMemoryBroker`, suscribe dos +> manejadores al mismo grupo y publica dos eventos; cada manejador se ejecuta una +> vez. Suscribe dos manejadores *sin grupo* y publica un evento; ambos se ejecutan. +> Eso es fan-out frente a consumidores en competencia, en cuatro líneas. + +## Paso 9 — Hacer los fallos sobrevivibles: reintento y dead-letter + +`wrap_listener(handler, publisher, policy)` es el envoltorio de reintento/DLQ +agnóstico al adaptador. Una entrega fallida se reintenta hasta `retries` veces con +backoff lineal (`retry_delay * attempt`); al agotarse, el evento se republica al +topic de dead-letter (cuando está definido), llevando la carga útil, la clave y las +cabeceras originales más las cabeceras de diagnóstico `x-original-topic` y +`x-exception`: + +> **Note** **Término clave — topic de dead-letter (DLT/DLQ).** Cuando un mensaje +> sigue fallando, no quieres que bloquee el flujo para siempre. Un *topic de +> dead-letter* es donde se aparcan los mensajes agotados para una inspección o +> reproducción posterior. Es el enrutamiento a dead-letter del +> `DefaultErrorHandler` de Spring Kafka y `@RetryableTopic`. + +```rust +use std::sync::Arc; +use std::time::Duration; +use firefly_eda::{handler, wrap_listener, InMemoryBroker, ListenerPolicy}; + +# tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +let broker = Arc::new(InMemoryBroker::new()); +let inner = handler(|_ev| async { Err(firefly_kernel::FireflyError::internal("boom")) }); +let wrapped = wrap_listener( + inner, + broker.clone(), + ListenerPolicy::with_retries(3) + .retry_delay(Duration::from_millis(50)) + .dead_letter_topic("wallets.events.DLT"), +); +broker.subscribe("wallets.events", wrapped).unwrap(); +# }); +``` + +Qué acaba de pasar. `ListenerPolicy::with_retries(3)` establece tres reintentos +tras el primer intento (cuatro intentos en total); `.retry_delay(...)` añade +backoff lineal; `.dead_letter_topic(...)` nombra el topic donde aparcar los eventos +agotados. `wrap_listener` devuelve un nuevo `Handler` que suscribes en lugar del +interno. Una política sin reintentos, sin topic y sin store es un pass-through: +devuelve el manejador original sin cambios, de modo que envolver tiene coste cero +cuando no está configurado. + +La proyección de Lumen toma un camino más suave: *traga* un fallo transitorio del +store y devuelve `Ok(())` en vez de hacer fallar la entrega, así que un único +mensaje envenenado nunca atasca el flujo. Esa es la decisión correcta para una +proyección de reconstrucción desde el flujo: la siguiente reentrega, o el siguiente +evento de la cartera, converge de todos modos. Un listener *con efectos +secundarios* —uno que envía un correo o llama a una API externa— es donde +`wrap_listener` y un topic de dead-letter se ganan su sustento, porque ahí el +trabajo no se puede simplemente volver a derivar. + +Para un registro inspeccionable de fallos (en lugar de un topic de enrutamiento), +cablea un `EdaDeadLetterStore` mediante `ListenerPolicy::dead_letter_store`: un +evento agotado se captura en el store (consultable con `list` / `get` / `remove`). +Puedes establecer ambos —capturar *y* enrutar— en una misma política. + +> **Tip** **Punto de control.** Envuelve un manejador que siempre falla con +> `ListenerPolicy::with_retries(2).dead_letter_topic("orders.DLT")` sobre un broker +> que registra las publicaciones; tras una entrega, exactamente un evento aterriza +> en `orders.DLT` con una cabecera `x-original-topic`, y el manejador envuelto +> devuelve `Ok(())` en vez de dar error. + +## Paso 10 — Filtrar la entrega con filtros de eventos + +`EventFilter` es una compuerta de entrega por sobre superpuesta a la coincidencia +de topics. Donde el broker decide *qué* suscripciones alcanza un topic, un filtro +decide si una suscripción alcanzada realmente se *ejecuta*. Vienen dos —un filtro +regex de cabecera y un filtro de predicado arbitrario—. Los sobres de Lumen llevan +una cabecera `aggregateType`, así que un filtro de cabecera podría restringir un +suscriptor a los eventos de `Wallet`: + +```rust +use firefly_eda::{handler, with_filters, Event, HeaderEventFilter, InMemoryBroker}; + +# tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +let broker = InMemoryBroker::new(); +let inner = handler(|_ev: Event| async { Ok(()) }); +let gated = with_filters(inner, [HeaderEventFilter::new("aggregateType", r"^Wallet$").unwrap()]); +broker.subscribe("wallets.events", gated).unwrap(); +# }); +``` + +Qué acaba de pasar. `HeaderEventFilter::new(name, pattern)` compila una regex +anclada contra la cabecera nombrada (una cabecera ausente se trata como la cadena +vacía). `with_filters(handler, [filters])` envuelve el manejador para que se +ejecute solo en los eventos que pasan *todos* los filtros; un evento que no +coincide se descarta antes de que se ejecute el cuerpo del manejador: el manejador +envuelto simplemente devuelve `Ok(())`. Una lista de filtros vacía devuelve el +manejador sin cambios (coste cero). `PredicateEventFilter::new(closure)` es la vía +de escape cuando una regex sobre una cabecera no basta: filtra por cualquier +propiedad del sobre. + +> **Tip** **Punto de control.** Construye +> `HeaderEventFilter::new("aggregateType", r"^Wallet$")`, envuelve un manejador +> contador con `with_filters` y luego entrega un sobre cuyo `aggregateType` sea +> `"Account"` y otro que sea `"Wallet"`. Solo el segundo incrementa el contador. Un +> filtro de cabecera es más barato que un `if` dentro del manejador porque el +> descarte ocurre antes de que se ejecute tu código: Ejercicio 3. + +## Paso 11 — Consumir reactivamente como un `Flux` + +`InMemoryBroker::subscribe_reactive(topic)` es el gemelo reactivo de `subscribe`: +un `Flux` que emite cada evento entregado al topic, componiendo con el +conjunto completo de operadores de reactive-streams de Firefly. `publish_mono(event)` +es la publicación reactiva fría: no ocurre nada hasta que se suscribe el `Mono` +devuelto. + +> **Note** **Término clave — `Flux` / `Mono`.** `Flux` es un flujo reactivo de +> *muchos* `T`; `Mono` es un flujo reactivo de *como mucho un* `T`. Son el port +> de Firefly del `Flux` / `Mono` de Project Reactor (Spring WebFlux). Ambos son +> *fríos* y *perezosos*: construir uno no hace trabajo; el trabajo se ejecuta +> cuando te suscribes (aquí, `.block().await`). + +```rust +use std::sync::Arc; +use firefly_eda::{Event, InMemoryBroker}; + +# tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +let broker = Arc::new(InMemoryBroker::new()); +let flux = broker.subscribe_reactive("wallets.*").unwrap(); + +broker + .publish_mono(Event::new("wallets.events", "WalletOpened", "lumen", None)) + .block() + .await + .unwrap(); +broker.close().unwrap(); // terminates the Flux + +let events = flux.take(1).collect_list().block().await.unwrap().unwrap(); +assert_eq!(events[0].topic, "wallets.events"); +# }); +``` + +Qué acaba de pasar. `subscribe_reactive("wallets.*")` devuelve un `Flux` +respaldado por un canal acotado. `publish_mono(...)` construye un `Mono<()>` frío; +`.block().await` lo conduce, ejecutando la publicación. Cerrar el broker descarta +el emisor, lo que termina el `Flux`. Luego `flux.take(1).collect_list()` compone +dos operadores en un `Mono>`; `.block().await` produce un +`Result>, _>`, así que los dos `.unwrap()` desenvuelven el +`Result` y luego el `Option`. + +> **Note** Las entregas se bufferean a través de un canal acotado; cuando el +> consumidor aguas abajo se queda atrás, los eventos más nuevos se descartan +> (`onBackpressureDrop`) en lugar de bloquear o hacer fallar al publicador, +> extendiendo a la superficie reactiva la invariante del broker «un consumidor +> lento nunca hace fallar a los publicadores». Este es el mismo `Flux` sobre el que +> compone el endpoint de streaming opcional de Lumen (véase +> [Producción y despliegue](./20-production.md)). + +> **Tip** **Punto de control.** La aserción de arriba se cumple: un evento +> publicado llega al `Flux`, y su `topic` es `wallets.events` aunque te +> suscribieras al glob `wallets.*`. + +## Paso 12 — Eventos en proceso y externalización tras commit + +El broker lleva eventos *entre* servicios. Dentro de un mismo servicio a menudo +quieres el mismo desacoplamiento sin un salto de red: un componente emite un hecho, +otros reaccionan, y ninguno de ellos sabe que los otros existen. Eso es el +`ApplicationEventPublisher` / `@EventListener` de Spring, y Firefly lo incluye como +un bus en proceso, asíncrono y seguro entre hilos, junto al broker. + +> **Note** **Término clave — bus de eventos en proceso.** Un bus de +> publicación/suscripción *local al proceso*: haces `publish_event(value)` y +> cualquier `#[application_event_listener]` para ese tipo reacciona —sin broker, sin +> red, sin serialización—. El `ApplicationEventPublisher.publishEvent(...)` + +> `@EventListener` de Spring. + +Publica con `publish_event`, y escucha con `#[application_event_listener]` sobre una +función asíncrona libre que toma el evento por referencia compartida. Los listeners +se descubren a lo largo del grafo de crates (el mismo escaneo de `inventory` que +encuentra tus componentes), así que no hay registro manual: + +```rust,ignore +use firefly::prelude::*; + +struct WalletOpened { id: String } + +#[firefly::application_event_listener] +async fn audit_opening(event: &WalletOpened) { + tracing::info!(wallet = %event.id, "wallet opened"); +} + +// somewhere in a command handler: +publish_event(WalletOpened { id: wallet_id }).await; +``` + +### Escuchar en relación con una transacción + +Un listener simple se ejecuta en el instante en que publicas. A menudo eso es +demasiado pronto: no quieres enviar una notificación de «cartera abierta» hasta que +la transacción de base de datos que la abrió haya confirmado realmente. +`#[transactional_event_listener]` ata el listener a una fase del límite +`#[transactional]` circundante —`after_commit` (el predeterminado), `before_commit`, +`after_rollback` o `after_completion`—: + +```rust,ignore +#[firefly::transactional_event_listener] // after_commit +async fn notify_owner(event: &WalletOpened) { + // Runs only once the opening transaction commits; never on a rollback. + mailer.send_welcome(&event.id).await; +} +``` + +Los eventos publicados dentro de una transacción se bufferean y se despachan en la +fase elegida; una transacción revertida dispara los listeners `after_rollback` y +nunca los `after_commit`, de modo que una escritura fallida nunca puede filtrar un +efecto secundario de «éxito». Sin ninguna transacción activa, el listener recurre a +ejecutarse de inmediato (tratando el trabajo como ya confirmado), así que el mismo +manejador es útil en un test unitario o en una ruta sin datasource. Si quieres +semántica de eventos transaccionales sin ningún datasource SQL en absoluto, registra +el `LocalTransactionManager` (el equivalente en Rust del +`ResourcelessTransactionManager` de Spring). + +### Tender un puente de los eventos en proceso al broker + +Las dos capas se componen en el patrón que casi siempre quieres: haz el trabajo en +proceso y, una vez que confirma, publica un evento de integración al broker —nunca +un mensaje «fantasma» para una transacción que se revirtió—. Eso es la +externalización de eventos de Spring Modulith, y `externalize_after_commit` la +cablea en una línea: + +```rust,ignore +// at startup, once per externalized event type: +firefly::eda::externalize_after_commit::("wallet.events", "wallet.opened"); + +// thereafter, an ordinary in-process publish inside a transaction... +publish_event(WalletOpened { id: wallet_id }).await; +// ...is serialized to JSON and published to the "wallet.events" topic on the +// registered broker the moment the transaction commits. +``` + +`externalize_after_commit` simplemente registra un listener `after_commit` que +reenvía a través de `publish_to_broker` (que serializa la carga útil y publica +mediante el `Broker` registrado con `register_broker`). Una transacción confirmada +llega a Kafka, RabbitMQ o cualquier transporte que hayas cableado; una revertida no +publica nada. El reenvío tras commit es de mejor esfuerzo: un broker ausente o un +fallo de publicación no deshace la transacción ya confirmada; echa mano de un outbox +de verdad (próximo capítulo) cuando necesites al-menos-una-vez. + +Tres roles distintos, fáciles de mantener claros: + +- `#[event_listener("topic")]` *consume* de un topic del broker —el análogo de + `@KafkaListener` (la proyección de Lumen en el Paso 6)—. +- `#[application_event_listener]` / `#[transactional_event_listener]` manejan eventos + *en proceso*. +- `externalize_after_commit` es el *puente* del segundo a un productor del broker. + +> **Tip** **Punto de control.** Puedes nombrar, para cada uno de esos tres, si +> cruza un límite de proceso (solo el primero y el puente lo hacen) y si es +> consciente de transacciones (el listener transaccional y el puente lo son). + +## Paso 13 — Cambiar a un transporte de producción + +Cada crate de transporte implementa el mismo port `Broker`; cambia el constructor y +conserva cada manejador. Programa contra `firefly_eda::Broker` y selecciona el +adaptador en el momento del cableado —para un servicio `FireflyApplication` eso es +una palanca de configuración `firefly.*` (o un `#[bean]` que proporciona el port +`dyn Broker`)—. Sustituye el broker en memoria por uno de Kafka y la proyección, el +ledger y cada comando siguen compilando sin cambios. + +| Crate | Backend | Constructor | +|------------------------|-----------------|---------------------------------------------------| +| `firefly-eda-kafka` | Apache Kafka | `new_kafka_broker(KafkaConfig)?` | +| `firefly-eda-rabbitmq` | RabbitMQ | `RabbitMqBroker::new(RabbitMqBrokerConfig)` | +| `firefly-eda-postgres` | Postgres outbox | `PostgresBroker::new(PostgresConfig::new(dsn))` | +| `firefly-eda-redis` | Redis Streams | `RedisStreamsBroker::connect(RedisConfig::new(url))?` | + +Kafka, por ejemplo —fíjate en que el cuerpo del manejador es idéntico al de Lumen, +y como aquí tienes un `Box` los métodos del trait son `async`—: + +```rust,no_run +use firefly_eda::{handler, Event}; +use firefly_eda_kafka::{new_kafka_broker, KafkaConfig}; + +# async fn ex() -> firefly_eda::EdaResult<()> { +let broker = new_kafka_broker(KafkaConfig { + brokers: vec!["kafka:9092".into()], + client_id: "lumen".into(), + consumer_group: "lumen-projections".into(), + ..Default::default() +})?; + +broker + .subscribe("wallets.events", handler(|ev: Event| async move { + println!("observed {}", ev.event_type); + Ok(()) + })) + .await?; + +let ev = Event::new("wallets.events", "WalletOpened", "lumen", None); +broker.publish(ev).await?; +# Ok(()) +# } +``` + +Qué acaba de pasar. `new_kafka_broker(KafkaConfig { … })?` devuelve un +`Box` (de ahí el `?`). En el *port*, `subscribe` y `publish` son métodos +de trait `async`, así que aplicas `.await` a ambos —la única diferencia con el +`InMemoryBroker` concreto del Paso 5, cuyos métodos inherentes son síncronos—. La +clausura dentro de `handler(...)` es byte a byte lo que escribirías para el broker +en memoria. + +Redis Streams usa un ciclo de vida de conectar-y-luego-arrancar: + +```rust,no_run +use firefly_eda::{handler, Event}; +use firefly_eda_redis::{RedisConfig, RedisStreamsBroker}; + +# async fn ex() -> firefly_eda::EdaResult<()> { +let broker = RedisStreamsBroker::connect( + RedisConfig::new("redis://localhost:6379/0") + .with_streams(["wallets.events"]) + .with_group("lumen-projections"), +)?; +broker.subscribe("wallets.*", handler(|ev: Event| async move { + println!("got {}", ev.event_type); + Ok(()) +})).await?; +broker.start().await?; +# Ok(()) +# } +``` + +Qué acaba de pasar. `RedisStreamsBroker::connect(config)?` marca a Redis y devuelve +el broker; `RedisConfig::new(url).with_streams([...]).with_group(...)` es el builder. +Haces `subscribe` antes de `start()` —`start()` comienza a consumir de los streams +declarados—. (RabbitMQ tiene la misma forma de connect/start; +`RabbitMqBroker::new(config)` devuelve el broker y `start()` declara su topología.) + +> **Note** El broker de Postgres es un **outbox transaccional**: los eventos se +> escriben en la misma transacción que tu cambio de estado y se drenan a los +> consumidores mediante `LISTEN`/`NOTIFY`, dando entrega al-menos-una-vez sin un +> broker aparte. Eso cierra el hueco entre append y publish del Paso 4; el +> [próximo capítulo](./11-event-sourcing.md) cubre la primitiva del outbox +> directamente. + +> **Tip** **Punto de control.** Añade la feature `eda-kafka` y proporciona el port +> `dyn Broker` como un `#[bean]` que construye `new_kafka_broker(...)`. No necesitas +> un Kafka en marcha: `cargo build` confirma que la proyección, el ledger y los +> manejadores de comandos compilan sin cambios contra el port. Eso es el Ejercicio 4. + +## Salud del broker + +`EventPublisherHealthIndicator` adapta cualquier broker que implemente la sonda de +ping `BrokerHealth` a un `firefly_observability::Indicator`, exponiendo la vivacidad +del broker en `/actuator/health` bajo el id `eventPublisher`, de modo que cuando +Lumen se gradúe a un broker real, su readiness aparezca junto al resto de la salud +del servicio (véase [Observabilidad](./15-observability.md)). El broker en memoria +reporta `UP` hasta que se cierra. + +## Resumen — qué cambió en Lumen + +El bucle de CQRS está cerrado. Donde los manejadores de comandos del Capítulo 9 +persistían eventos y dejaban que el lado de lectura se reparase solo, Lumen ahora +publica cada evento persistido y lo proyecta de vuelta automáticamente. + +| Pieza | Rol | +|-------|------| +| `EVENTS_TOPIC` / `EVENT_SOURCE` | Constantes compartidas en las que coinciden el publicador y el listener | +| `to_envelope(&DomainEvent)` | Tiende un puente de un evento de dominio persistido al `Event` de transporte (key = id de cartera, las cabeceras llevan el enrutamiento) | +| `Ledger::commit` | Añade y **luego** publica cada evento: guarda antes de publicar | +| `WalletProjection` (`#[derive(Service)]` + `#[handlers]`) | El **bean** de proyección: hace `#[autowired]` del `Ledger` + `ReadModel`, su método `#[event_listener]` reconstruye el modelo de lectura desde el flujo | +| `#[event_listener(topic = "wallets.events")]` | Marca el método del bean; envía un `BeanListenerRegistration` que el framework drena (`subscribe_discovered_listener_beans`), resolviendo el bean y suscribiendo el método | +| Inyección por constructor | La proyección alcanza sus colaboradores a través de campos `#[autowired]`: sin `OnceLock`, sin `bind`; el `#[bean]` `ledger` es una fábrica pura | +| `Broker` del framework (`InMemoryBroker`) | El transporte predeterminado: cambia el adaptador por Kafka/RabbitMQ/Redis/Postgres, conserva el listener | + +También sabes ahora: + +- La diferencia entre un **evento de dominio** (duradero, en el store) y un + **evento de mensajería** (el sobre de transporte, en el broker), y qué capítulo + es dueño de cada uno. +- El alcance del broker: topics con globs, **grupos** de fan-out frente a + consumidores en competencia, reintento/dead-letter de `wrap_listener`, **filtros** + por sobre y la superficie reactiva `Flux`. +- Los tres roles de evento —`#[event_listener]` (consumo del broker), + `#[application_event_listener]` / `#[transactional_event_listener]` (en proceso) y + `externalize_after_commit` (el puente)—. + +Tres principios se llevan adelante: **guarda antes de publicar** para que un +suscriptor nunca vea un hecho sin confirmar; **haz las proyecciones idempotentes** +para que la reentrega al-menos-una-vez sea inofensiva (Lumen vuelve a replegar el +flujo en lugar de aplicar un delta); y **depende del port `Broker`, no del +adaptador** para que el broker en memoria se convierta en Kafka con un cambio de una +línea. + +Los eventos que Lumen publica aquí siguen estando respaldados por un store +transitorio en memoria. El [próximo capítulo](./11-event-sourcing.md) convierte esos +eventos en la *fuente de la verdad*: duraderos, reproducibles, el registro canónico +a partir del cual se recalcula cada saldo. + +## Ejercicios + +1. **Añade un listener `WelcomeNotifier`.** Como el notificador no necesita + colaboradores inyectados, echa mano de la forma más sencilla de `fn` libre: + escribe un `#[event_listener(topic = "wallets.events")] async fn` que reaccione + solo a `WalletOpened` (comprueba `ev.event_type`) y registre una línea de + bienvenida que lleve la cabecera `aggregateId`. El framework drena el nuevo + listener automáticamente —no añades ninguna llamada a subscribe—. Confirma —a + través de un test unitario de `InMemoryBroker` que publica un sobre + `WalletOpened`— que se dispara, mientras los manejadores de comandos existentes + quedan intactos. + +2. **Demuestra la idempotencia.** En un test, construye un `Ledger` sobre un + `MemoryEventStore` y un `InMemoryBroker`, suscribe la proyección, abre una + cartera e ingresa dos veces. Luego publica el *mismo* sobre `MoneyDeposited` una + segunda vez con `broker.publish(to_envelope(&event)).await` y afirma que el saldo + de la `WalletView` del modelo de lectura no cambia: el repliegue de + reconstrucción desde el flujo absorbe la reentrega. + +3. **Filtra por tipo de agregado.** Envuelve el manejador de la proyección con + `with_filters` y un `HeaderEventFilter::new("aggregateType", r"^Wallet$")`, luego + publica un sobre cuya cabecera `aggregateType` sea `"Account"` y confirma que la + proyección no se ejecuta para él. Explica por qué un filtro de cabecera es una + guarda más barata que comprobar dentro del cuerpo del manejador. (Pista: el + descarte ocurre antes de que se invoque el manejador.) + +4. **Cambia a un broker real (boceto).** Añade la feature `eda-kafka` al crate y + proporciona el port `dyn Broker` como un `#[bean]` que construye + `new_kafka_broker(...)` en lugar de apoyarte en el broker en memoria por defecto. + No necesitas un Kafka en marcha —el objetivo es confirmar que la proyección, el + ledger y los manejadores de comandos compilan sin cambios contra el port + `Broker`—. + +5. **Enruta un fallo.** Envuelve un manejador que siempre falla con + `wrap_listener(inner, broker.clone(), ListenerPolicy::with_retries(2) + .dead_letter_topic("wallets.events.DLT"))`, suscríbelo y suscribe un segundo + manejador a `wallets.events.DLT`. Publica un evento y afirma que el manejador de + dead-letter lo observa llevando una cabecera `x-original-topic` de + `wallets.events`. + +## Adónde ir después + +- Haz estos eventos duraderos y reproducibles en **[Event + Sourcing](./11-event-sourcing.md)**, donde el store en memoria se convierte en la + fuente de la verdad y el outbox transaccional cierra el hueco entre append y + publish. +- Expón la vivacidad del broker y las métricas de peticiones en + **[Observabilidad](./15-observability.md)**: el indicador de salud `eventPublisher` + se une al resto de la superficie del actuator. +- Cablea un transporte real de Kafka o RabbitMQ y el endpoint de streaming reactivo + en **[Producción y despliegue](./20-production.md)**. diff --git a/docs/book/src-es/11-event-sourcing.md b/docs/book/src-es/11-event-sourcing.md new file mode 100644 index 00000000..2a49a39c --- /dev/null +++ b/docs/book/src-es/11-event-sourcing.md @@ -0,0 +1,939 @@ +# Event Sourcing + +El [capítulo anterior](./10-eda-messaging.md) dejó una pregunta cortésmente sin +formular. El `Ledger` de Lumen persiste eventos de monedero y una proyección +reconstruye el modelo de lectura volviendo a plegar el stream — pero *¿qué +stream?* Hasta ahora el estado canónico del monedero ha estado implícito. Al +terminar este capítulo es explícito y fundamental: el agregado `Wallet` **no +almacena ningún saldo en absoluto**. Su saldo es una función pura de un stream de +solo anexado (append-only) de eventos `WalletOpened`, `MoneyDeposited` y +`MoneyWithdrawn`, recalculado cada vez que se carga el agregado. + +Eso es **event sourcing**: en lugar de almacenar el estado actual y descartar +cada cambio, almacenas la *secuencia de cambios* y derivas el estado +reproduciéndola. Un libro mayor financiero es el dominio ideal para ello — los +contables saben desde hace siglos que la autoridad de un libro mayor proviene de +sus asientos, no del total acumulado al pie de la columna. El total es un *hecho +derivado*; los asientos son la *fuente de verdad*. Al final, un auditor que +pregunte «¿cuál era el saldo del monedero `wlt_…` tras el tercer movimiento?» +obtiene una respuesta que Lumen puede *demostrar* a partir del stream, no +simplemente reportar a partir de una columna. + +Este capítulo es una construcción guiada. Introducimos cada pieza desde primeros +principios, la escribimos bloque a bloque contra la API real de +`firefly-eventsourcing`, y nos detenemos en puntos de control para que puedas +confirmar lo que tienes antes de continuar. Nada aquí se da por sentado: cada +tipo, método y derive coincide con el crate que se distribuye en +[`samples/lumen`](https://github.com/fireflyframework/fireflyframework-rust/tree/main/samples/lumen). + +Al terminar este capítulo, serás capaz de: + +- Explicar la diferencia entre **almacenamiento de estado** y **almacenamiento de + eventos**, y por qué el saldo del monedero se convierte en un cálculo en lugar + de una columna. +- Definir eventos de dominio con `#[derive(DomainEvent)]` y un agregado basado en + eventos con `#[derive(AggregateRoot)]`, y saber exactamente qué genera cada + derive. +- Implementar la forma canónica de comando — validar, `raise`, luego `apply` — y + comprender por qué **el mismo fold se ejecuta tanto en la ruta de escritura como + en el replay**. +- Persistir y recargar eventos a través del puerto `EventStore` con **concurrencia + optimista**, y manejar correctamente un conflicto de concurrencia. +- Reconocer las costuras de nivel de producción que ofrece el crate — snapshots, + proyecciones, el stream global, el transactional outbox, upcasters y + multi-tenancy — y saber cuándo cada una merece su sitio. + +## Conceptos que conocerás + +Cada idea de abajo se reintroduce en contexto donde se usa por primera vez; esta +es la versión corta para que el vocabulario no resulte nuevo cuando llegues a él. + +> **Note** **Término clave — event sourcing.** Un estilo de persistencia en el que +> almacenas la *secuencia ordenada de cambios* (eventos) de una entidad en lugar +> de su estado actual, y recalculas el estado reproduciendo esa secuencia. El +> análogo en Java/Spring es el `firefly-event-sourcing-spring-boot-starter` (o los +> agregados basados en eventos de Axon Framework). + +> **Note** **Término clave — domain event.** Un registro inmutable de que *algo +> ocurrió* en el dominio, nombrado en pasado (`MoneyDeposited`). En event sourcing +> los eventos son el sistema de registro. Esto es distinto del envoltorio `Event` +> de EDA del [capítulo anterior](./10-eda-messaging.md), que es el *transporte* de +> un hecho; el `DomainEvent` de aquí es el *registro* duradero del mismo. + +> **Note** **Término clave — agregado.** Un grupo de objetos de dominio tratados +> como una única frontera de consistencia, con una **raíz de agregado** (aggregate +> root) como punto de entrada. Cada comando pasa por la raíz, que impone las +> invariantes del agregado. El agregado de Lumen es el `Wallet`; su raíz es el +> `AggregateRoot` del framework, embebido. Este es el «agregado» del Domain-Driven +> Design que los desarrolladores de Spring conocen por las raíces `@Entity` — pero +> aquí se reconstruye a partir de eventos, no se carga desde una fila. + +> **Note** **Término clave — concurrencia optimista.** Una forma de detectar +> escrituras concurrentes sin bloquear: cada escritura declara la versión que +> esperaba encontrar, y el store la rechaza si otro escritor llegó antes. El +> análogo en Spring/JPA es el bloqueo optimista con `@Version`. + +## Paso 1 — Siente el cambio: almacenamiento de estado vs almacenamiento de eventos + +Antes de escribir una línea, observa qué *contiene* el almacenamiento de Lumen en +cada modelo. El contraste es toda la motivación de este capítulo. + +En el **modelo de almacenamiento de estado** — el que está por defecto en todas +partes — el store guarda solo el estado actual del monedero: + +| id | owner | balance | version | +|----|-------|---------|---------| +| wlt_a1 | alice | 120 | 3 | + +Cada ingreso y cada retirada sobrescribe `balance`. La historia ha desaparecido: +sabes que el monedero contiene 120 céntimos ahora; no puedes saber cómo llegó +hasta ahí. + +En el **modelo de almacenamiento de eventos**, el store guarda el stream: + +| aggregate_id | version | event_type | payload | +|--------------|---------|------------|---------| +| wlt_a1 | 1 | WalletOpened | `{"wallet_id":"wlt_a1","owner":"alice","opening_balance":100}` | +| wlt_a1 | 2 | MoneyDeposited | `{"wallet_id":"wlt_a1","amount":50}` | +| wlt_a1 | 3 | MoneyWithdrawn | `{"wallet_id":"wlt_a1","amount":30}` | + +El saldo actual sigue siendo 120 céntimos — pero ahora puedes leer cada decisión +que condujo a él, reproducir hasta cualquier versión, y auditarlo todo. + +Lo que acaba de ocurrir: el mismo saldo final tiene ahora una *derivación*. El +compromiso es real y merece nombrarse de antemano — las lecturas cuestan un replay +(mitigado mediante **snapshots**, Paso 8) y los eventos son inmutables (el cambio +de esquema se maneja mediante **upcasters**, Paso 11). Ambos tienen soporte de +primera clase, y los conocerás a su debido tiempo. + +> **Note** Event sourcing *no* es lo mismo que la EDA del +> [capítulo anterior](./10-eda-messaging.md). Allí, el agregado almacenaba su +> estado y *publicaba* eventos como efecto secundario. Aquí los eventos *son* el +> estado: no hay una columna `balance` que mantener sincronizada — el saldo se +> calcula plegando el stream cada vez que se carga el agregado. + +> **Tip** **Punto de control.** Puedes enunciar, en una frase, qué pierde o +> conserva cada tabla: el almacenamiento de estado conserva la respuesta y descarta +> el trabajo; el almacenamiento de eventos conserva el trabajo y recalcula la +> respuesta. El resto del capítulo hace concreto ese recálculo. + +## Paso 2 — El modelo mental: raise, append, fold + +Todo lo de abajo son tres movimientos repetidos. Un comando **levanta** (`raise`) +un evento sobre el agregado; el store **anexa** (`append`) los eventos levantados +de forma duradera bajo concurrencia optimista; una carga posterior **pliega** +(`fold`) el stream de vuelta al estado actual. Ten presente este ciclo — cada API +del capítulo es uno de estos tres movimientos. + +
+ +write path +CommandDeposit { amount }raise(event)→ uncommitted []append(events)optimistic concurrency +event stream (append-only) ++100WalletOpened + ++50MoneyDeposited + +−30MoneyWithdrawn +append +fold / replay +current statebalance = 120 + +
Tres movimientos. Un comando hace raise de un evento sobre el agregado; EventStore::append persiste los eventos no confirmados bajo concurrencia optimista; una carga posterior hace fold de todo el stream de solo anexado de vuelta al estado actual — los eventos son la fuente de verdad, el estado es derivado.
+
+ +La pieza del framework que impulsa los tres movimientos es `firefly-eventsourcing`, +reexportada a través de la fachada como `firefly::eventsourcing`. + +> **Note** **Término clave — `firefly-eventsourcing`.** El crate de event sourcing +> del framework. Proporciona el `AggregateRoot` (búfer de eventos no confirmados + +> versión), el puerto `EventStore` (append/load con concurrencia optimista), +> snapshots, proyecciones, un stream global entre agregados, un transactional +> outbox, upcasters y multi-tenancy. No dependes de él directamente — llega a +> través de la única fachada `firefly`, y los dos derives (`DomainEvent`, +> `AggregateRoot`) entran a través de `firefly::prelude`. + +## Paso 3 — Define los eventos de dominio del Wallet + +La acción: declarar los tres eventos que el monedero puede producir. En Lumen cada +uno es una struct de payload simple que lleva `#[derive(DomainEvent)]`. Viven en +`src/domain.rs`. + +```rust +use firefly::eventsourcing::{AggregateRoot, DomainEvent}; +use firefly::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Payload of the event raised when a wallet is opened. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DomainEvent)] +pub struct WalletOpened { + pub wallet_id: String, + pub owner: String, + /// The opening balance, in minor units (cents). + pub opening_balance: i64, +} + +/// Payload of the event raised when money is credited to a wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DomainEvent)] +pub struct MoneyDeposited { + pub wallet_id: String, + pub amount: i64, +} + +/// Payload of the event raised when money is debited from a wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DomainEvent)] +pub struct MoneyWithdrawn { + pub wallet_id: String, + pub amount: i64, +} +``` + +Lo que acaba de ocurrir, bloque a bloque: + +- Los dos **tipos** `AggregateRoot` y `DomainEvent` provienen de + `firefly::eventsourcing`. Las dos **macros derive** del mismo nombre provienen de + `firefly::prelude::*` — el glob que reexporta todas las macros del framework, de + modo que un servicio depende de un solo crate y aun así escribe + `#[derive(DomainEvent)]`. +- `Serialize`/`Deserialize` hacen que cada payload sea codificable en JSON; el + derive necesita `Serialize` porque codifica en JSON el payload dentro del evento + almacenado. +- Cada evento se nombra en **pasado** y lleva solo los datos que el hecho necesita. + `opening_balance` y `amount` están en unidades menores (céntimos) — Lumen nunca + almacena dinero en coma flotante. + +Ahora la parte importante — qué genera `#[derive(DomainEvent)]`. Para cada struct +produce: + +- una `pub const EVENT_TYPE: &'static str` igual al nombre de la struct + (`"WalletOpened"`, `"MoneyDeposited"`, `"MoneyWithdrawn"`) — el discriminador de + enrutamiento; +- un accesor `event_type()` que devuelve esa const; +- un método `to_domain_event(aggregate_id, aggregate_type, version)` que codifica + en JSON el payload dentro de un `DomainEvent` del framework. + +Esa const `EVENT_TYPE` generada es lo *único* que referencian el agregado y su +fold, de modo que el tipo de evento nunca es un literal de cadena suelto en los +puntos de llamada — y un renombrado de la struct se propaga automáticamente. + +> **Note** **Término clave — `DomainEvent` (el tipo de cable).** Junto al derive +> hay una struct concreta `firefly::eventsourcing::DomainEvent`: la forma de cable +> de cada evento persistido, con un `aggregate_id`, `aggregate_type`, `version` +> en base 1, `event_type`, `time`, un `payload` en base64, un `metadata` opcional y +> un `tenant_id` opcional. Su JSON es un contrato estable, versionado y neutral +> respecto al lenguaje — compatible byte a byte con los ports de Java, .NET, Go y +> Python, de modo que cualquier servicio que lo respete interopera con +> independencia del lenguaje. + +> **Tip** **Punto de control.** `cargo build` compila las tres structs. En un test +> rápido puedes afirmar `WalletOpened::EVENT_TYPE == "WalletOpened"` y hacer un +> round-trip de un payload con `serde_json::to_vec` / `from_slice`. Los eventos +> existen; todavía nada los levanta. + +## Paso 4 — Define el agregado Wallet + +La acción: declarar el agregado que produce esos eventos. El `Wallet` lleva +`#[derive(AggregateRoot)]`, que encuentra el campo `AggregateRoot` del framework +embebido y conecta el discriminador de tipo y los accesores. De forma crucial, el +estado proyectado (`owner`, `balance`, `opened`) **no se almacena** — se pliega a +partir del stream. + +```rust +use firefly::eventsourcing::{AggregateRoot, DomainEvent}; + +use crate::money::Money; + +/// The aggregate-type discriminator stamped onto every event a wallet raises. +pub const AGGREGATE_TYPE: &str = "Wallet"; + +#[derive(Debug, Clone, AggregateRoot)] +#[firefly(aggregate_type = "Wallet")] +pub struct Wallet { + /// The framework aggregate root — uncommitted-event buffer + version. + pub root: AggregateRoot, + pub owner: String, + /// Folded from the stream; never stored. + pub balance: Money, + /// Whether the wallet has been opened (an empty stream is "absent"). + pub opened: bool, +} +``` + +Lo que acaba de ocurrir: + +- El campo embebido `root: AggregateRoot` es la contabilidad interna del framework + — contiene el id del agregado, la versión actual y el búfer de eventos *no + confirmados* que los comandos levantan pero que el store aún no ha persistido. + Rust compone este campo en lugar de heredar de una clase base. +- `#[derive(AggregateRoot)]` localiza ese campo `root` (el nombre de campo por + defecto; se anula con `#[firefly(field = "...")]`) y genera una const + `Wallet::AGGREGATE_TYPE` más los accesores `aggregate()` / `aggregate_mut()` + sobre la raíz embebida. `#[firefly(aggregate_type = "Wallet")]` fija el + discriminador explícitamente (de todos modos tomaría por defecto el nombre de la + struct). +- `owner`, `balance` y `opened` son **campos proyectados**: existen solo en memoria + y se reconstruyen plegando el stream. `Money` es el value object de Lumen basado + en céntimos de `src/money.rs`. + +> **Note** **Término clave — eventos no confirmados (uncommitted events).** Eventos +> que un comando ha levantado (`raise`) sobre la raíz del agregado pero que el +> store aún no ha persistido. Viven en el búfer de la raíz hasta que los +> `take_uncommitted()` y se los entregas a `EventStore::append`. Piensa en ellos +> como la escritura pendiente del agregado. + +> **Tip** **Punto de control.** `cargo build` tiene éxito y `Wallet::AGGREGATE_TYPE` +> evalúa a `"Wallet"`. El agregado está declarado pero todavía no tiene +> comportamiento — el Paso 5 añade los comandos. + +## Paso 5 — Escribe un comando: validar, raise, apply + +La acción: dar comportamiento al monedero. Cada comando sigue la forma canónica de +event sourcing — validar la invariante, hacer `raise` del evento correspondiente +sobre la raíz embebida, y luego aplicarlo al estado en memoria. Aquí está +`deposit`, más el pequeño helper privado que serializa un payload y lo levanta. + +```rust,ignore +/// Credits `amount` to the wallet, raising a `MoneyDeposited` event. +pub fn deposit(&mut self, amount: Money) -> Result<(), DomainError> { + self.require_opened()?; + let amount = amount.require_positive()?; + self.raise( + MoneyDeposited::EVENT_TYPE, + &MoneyDeposited { + wallet_id: self.root.id.clone(), + amount: amount.cents_value(), + }, + ); + self.balance = self.balance.add(amount); + Ok(()) +} + +/// Serialises a `#[derive(DomainEvent)]` payload and raises it onto the embedded +/// root under `event_type` — the discriminator from the generated `EVENT_TYPE`. +fn raise(&mut self, event_type: &str, payload: &P) { + let bytes = serde_json::to_vec(payload).expect("domain event payload serialises"); + self.root.raise(event_type, bytes); +} +``` + +Lo que acaba de ocurrir, en orden: + +1. `require_opened()?` impone la invariante: no puedes ingresar en un monedero que + nunca se abrió. Una comprobación fallida devuelve `DomainError::NotFound` y **no** + levanta evento alguno. +2. `amount.require_positive()?` rechaza un ingreso no positivo antes de que se + registre ningún evento. +3. `self.raise(MoneyDeposited::EVENT_TYPE, …)` registra el hecho. Observa que el + tipo de evento es la const generada, nunca un literal de cadena. El helper + privado `raise` codifica el payload en JSON y llama a + `self.root.raise(event_type, bytes)`. +4. `self.balance = self.balance.add(amount)` actualiza la proyección en memoria. + +El `AggregateRoot::raise` del framework hace dos cosas: empuja el evento al búfer +de no confirmados (para que el ledger pueda persistirlo más tarde) e incrementa la +versión en uno. Ese incremento de versión es lo que después impulsa la concurrencia +optimista. + +`withdraw` tiene la misma forma con una guarda extra que merece verse, porque la +saga de transferencia de [Sagas, Workflows & TCC](./12-sagas.md) depende de ella: + +```rust,ignore +/// Debits `amount` from the wallet, raising a `MoneyWithdrawn` event. +pub fn withdraw(&mut self, amount: Money) -> Result<(), DomainError> { + self.require_opened()?; + let amount = amount.require_positive()?; + let remaining = self.balance.subtract(amount)?; // Overdraw → InsufficientFunds + self.raise( + MoneyWithdrawn::EVENT_TYPE, + &MoneyWithdrawn { + wallet_id: self.root.id.clone(), + amount: amount.cents_value(), + }, + ); + self.balance = remaining; + Ok(()) +} +``` + +Por qué importa: `Money::subtract` se calcula *primero* y rechaza un descubierto +con `MoneyError::Overdraw` (mapeado a `DomainError::InsufficientFunds`) **antes** de +que se alcance siquiera `raise`. Una retirada fallida, por tanto, no levanta evento +alguno, dejando el stream limpio. Esa guarda de descubierto es el disparador de +fallo en el que se apoya la saga de transferencia. + +> **Tip** **Punto de control.** Con `open`, `deposit` y `withdraw` escritos, un test +> unitario puede abrir un monedero, ingresar 50, retirar 30, luego llamar a +> `wallet.take_uncommitted()` y afirmar que contiene exactamente tres eventos en +> orden. Los propios tests de `domain.rs` de Lumen hacen precisamente esto. + +## Paso 6 — Rehidrata: pliega el stream de vuelta al estado + +La acción: reconstruir un monedero a partir de sus eventos. La **rehidratación** es +la ruta de carga — reproduce el stream ordenado completo a través del mismo `apply` +que usan los comandos. Un stream vacío produce un monedero *sin abrir*, que es como +el ledger distingue «ausente» de «existe». + +> **Note** **Término clave — rehidratación.** Reconstruir el estado actual de un +> agregado plegando su stream de eventos desde el principio. El análogo en +> Spring/Axon es el `load` de un repositorio basado en eventos, que reproduce los +> eventos del agregado dentro de una instancia nueva. + +```rust,ignore +/// Rebuilds a wallet by folding `events` (its full ordered stream). +pub fn rehydrate(id: &str, events: &[DomainEvent]) -> Self { + let mut wallet = Wallet { + root: AggregateRoot::new(id, AGGREGATE_TYPE), + owner: String::new(), + balance: Money::ZERO, + opened: false, + }; + for event in events { + wallet.apply(event); + // Keep the root version in lock-step with the stream head so a + // subsequent command appends at the right expected version. + wallet.root.version = event.version; + } + wallet +} + +/// Folds one persisted event into the projected state. +fn apply(&mut self, event: &DomainEvent) { + match event.event_type.as_str() { + WalletOpened::EVENT_TYPE => { + if let Ok(p) = serde_json::from_slice::(&event.payload) { + self.owner = p.owner; + self.balance = Money::cents(p.opening_balance); + self.opened = true; + } + } + MoneyDeposited::EVENT_TYPE => { + if let Ok(p) = serde_json::from_slice::(&event.payload) { + self.balance = self.balance.add(Money::cents(p.amount)); + } + } + MoneyWithdrawn::EVENT_TYPE => { + if let Ok(p) = serde_json::from_slice::(&event.payload) { + self.balance = Money::cents(self.balance.cents_value() - p.amount); + } + } + _ => {} + } +} +``` + +Lo que acaba de ocurrir: + +- `rehydrate` parte de un monedero en blanco (`opened: false`, saldo cero) y pliega + cada evento a través de `apply`, manteniendo `root.version` al paso de la cabeza + del stream. Tras el fold, `root.version` es igual a la versión del último evento + — que es exactamente el token contra el que el siguiente comando hará append. +- `apply` hace match sobre `event.event_type` contra las constantes `EVENT_TYPE` + generadas — las mismas constantes bajo las que los comandos hacen `raise` — de + modo que el fold de escritura y el fold de replay nunca pueden discrepar sobre el + nombre de un evento. + +Una sutileza que merece una pausa. `apply` pliega `MoneyWithdrawn` con una resta +*cruda* (`self.balance.cents_value() - p.amount`) en lugar del `Money::subtract` +guardado contra descubierto que usa el *comando* `withdraw`. Esa asimetría es +deliberada: **el replay nunca revalida**. La guarda ya se ejecutó en tiempo de +escritura, y una retirada fallida no levantó evento alguno, de modo que cada evento +del stream es un hecho que ya pasó su invariante. El replay simplemente lo aplica. + +> **Design note.** Esta es la garantía de corrección de event sourcing hecha +> concreta. Un comando hace `raise` de un evento y `apply` muta los campos +> proyectados; una carga reproduce el *mismo* `apply` para reconstruir el estado. +> Lumen no registra ninguna tabla de handlers — hace `match` sobre la const +> `EVENT_TYPE` generada, la forma idiomática de Rust de impedir que el fold de +> escritura y el fold de replay discrepen jamás sobre el nombre de un evento. + +> **Tip** **Punto de control.** Esta es la ley que hay que demostrar: open + +> deposit + withdraw sobre un monedero *escritor*, toma su stream no confirmado, +> luego `Wallet::rehydrate` un monedero nuevo a partir de ese stream y afirma que +> el saldo, el propietario y la versión reconstruidos coinciden — estado +> recalculado a partir de eventos, nunca almacenado. El test +> `rehydrate_folds_the_full_stream` de Lumen hace exactamente esto. + +## Paso 7 — Persiste y recarga a través del `EventStore` + +La acción: hacer que los eventos sean duraderos. El `AggregateRoot` del framework +acumula `DomainEvent`s a medida que los haces `raise`; los `take_uncommitted` y +los `append` a un `EventStore`. El store impone concurrencia optimista — le pasas +la versión que cargaste, y el append de un escritor concurrente falla. + +Aquí está el movimiento aislado, contra el store en proceso: + +```rust +use firefly::eventsourcing::{AggregateRoot, EventStore, MemoryEventStore}; + +#[tokio::main] +async fn main() { + let store = MemoryEventStore::new(); + + let mut user = AggregateRoot::new("u1", "User"); + user.raise("UserCreated", br#"{"name":"alice"}"#); + user.raise("UserRenamed", br#"{"name":"bob"}"#); + + let events = user.take_uncommitted(); + // expected_version 0 -> this is a brand-new aggregate. + if let Err(err) = store.append(&user.id, 0, events).await { + eprintln!("append failed (raced): {err}"); + } + + assert_eq!(store.load("u1").await.unwrap().len(), 2); +} +``` + +Lo que acaba de ocurrir: dos llamadas a `raise` almacenan en búfer dos eventos e +incrementan la raíz a la versión 2. `take_uncommitted()` vacía el búfer (una fusión +idiomática de Rust de «devolver los eventos» + «borrarlos»). `append(&id, 0, events)` +los persiste, donde `0` es la **versión esperada** — la cabeza que esperábamos +encontrar antes de escribir. Como el agregado es completamente nuevo, esa cabeza es +`0`; el append tiene éxito. Releer el stream devuelve ambos eventos en orden. + +El puerto `EventStore` — el contrato que implementa cada store: + +```rust,ignore +#[async_trait] +pub trait EventStore: Send + Sync { + async fn append(&self, aggregate_id: &str, expected_version: i64, + events: Vec) -> Result<(), EventSourcingError>; + async fn load(&self, aggregate_id: &str) -> Result, EventSourcingError>; + async fn load_after(&self, aggregate_id: &str, since_version: i64) + -> Result, EventSourcingError>; + async fn stream_all(&self, after_event_id: Option<&str>, limit: usize, tenant: Option<&str>) + -> Result, EventSourcingError>; +} +``` + +> **Note** **Término clave — puerto `EventStore` / adaptador `MemoryEventStore`.** +> El trait `EventStore` es la frontera de persistencia — un *puerto* en el sentido +> hexagonal. `MemoryEventStore` es el *adaptador* en proceso sobre el que Lumen se +> ejecuta por defecto, ideal para desarrollo y tests. `SqlEventStore::new(db)` es el +> adaptador de producción sobre el puerto `Database` de `firefly-transactional`. +> Intercambiarlos es un cambio de una línea en el `#[bean]` `event_store` de +> `LumenBeans` — exactamente como intercambiar el broker en el +> [capítulo anterior](./10-eda-messaging.md). + +Ese bean es el único sitio donde reside la elección: + +```rust,ignore +#[bean] +impl LumenBeans { + /// The in-memory event store (`@Bean`). + #[bean] + fn event_store(&self) -> MemoryEventStore { + MemoryEventStore::new() + } + // ... +} +``` + +> **Tip** **Punto de control.** Ejecuta el ejemplo anterior (o el `#[tokio::test]` +> equivalente). `store.load("u1")` devuelve un `Vec` de longitud 2. Si en su lugar +> llamas a `store.append(&user.id, 5, events)` para un agregado nuevo, obtienes +> `Err(EventSourcingError::Concurrency)` — prueba de que la comprobación de versión +> esperada está activa. + +## Paso 8 — Conéctalo al Ledger y maneja la concurrencia + +La acción: ligar la persistencia al dominio en un único servicio de aplicación. El +`Ledger` de Lumen (introducido en el [capítulo anterior](./10-eda-messaging.md)) +posee el store y el broker. Cada comando rehidrata, ejecuta el método de dominio, y +confirma con concurrencia optimista. Aquí están `deposit` y la ruta de carga: + +```rust,ignore +/// Credits `amount` to `wallet_id`, persisting + publishing `MoneyDeposited`. +pub async fn deposit(&self, wallet_id: &str, amount: Money) -> Result { + let mut wallet = self.load(wallet_id).await?; + let expected = wallet.root.version; + wallet.deposit(amount)?; + self.commit(&mut wallet, expected).await?; + Ok(wallet.view()) +} + +/// Rehydrates the aggregate from its persisted stream. +async fn load(&self, wallet_id: &str) -> Result { + let events = self.load_events(wallet_id).await?; + Ok(Wallet::rehydrate(wallet_id, &events)) +} + +/// Loads the full event stream, mapping an absent aggregate to a domain 404. +pub async fn load_events(&self, wallet_id: &str) -> Result, DomainError> { + match self.store.load(wallet_id).await { + Ok(events) => Ok(events), + Err(EventSourcingError::AggregateNotFound) => { + Err(DomainError::NotFound(wallet_id.to_string())) + } + Err(e) => Err(DomainError::NotFound(format!("{wallet_id}: {e}"))), + } +} +``` + +Lo que acaba de ocurrir: `deposit` carga el monedero (rehidratándolo a partir de su +stream), captura `wallet.root.version` como `expected`, ejecuta el comando de +dominio, y luego confirma en `expected`. La versión a la que rehidrató el monedero +**es** el token que el append debe igualar. `commit` (mostrado por completo en el +[capítulo anterior](./10-eda-messaging.md)) hace append en `expected`, y luego +publica cada evento anexado al broker para que la proyección pueda reaccionar. Los +dos capítulos se encuentran aquí: este aporta el store duradero y reproducible; el +otro lleva cada evento anexado al cable. + +Ahora el caso de concurrencia, porque en un sistema real dos escritores compiten. +Supón que un ingreso desde la aplicación y una retirada de comisión desde un job +cargan ambos el monedero `wlt_a1` en la versión 3, cada uno aplica un cambio, y cada +uno intenta hacer append en `expected_version = 3`. El primer append gana y el +stream avanza a 4; el segundo ahora no coincide, y el store devuelve +`EventSourcingError::Concurrency`. Lumen mapea eso a un `DomainError::NotFound` que +lleva un detalle de «modificación concurrente» para que el llamante reintente desde +una carga fresca. Nunca gestionas números de versión a mano — la versión a la que +rehidrató el monedero es el token, y el store lo impone. + +> **Note** `append(id, expected_version, events)` impone concurrencia optimista: la +> versión rehidratada es el token, y un append obsoleto falla con +> `EventSourcingError::Concurrency`. Atrápalo y reintenta el ciclo +> cargar-mutar-guardar (o expón un 409) — nunca lo tragues, o te arriesgas a perder +> una escritura. + +> **Tip** **Punto de control.** Haz append del evento de apertura de un monedero en +> `expected_version = 0`. Luego, *sin recargar*, levanta un segundo evento y hazle +> append *también* en `expected_version = 0`. El segundo append devuelve +> `EventSourcingError::Concurrency`. Una carga fresca (que avanza `expected` a 1) +> habría tenido éxito — ese es todo el mecanismo en cuatro líneas. + +## Paso 9 — La ruta más fina: agregados tipados y el repositorio + +Lumen pliega el stream a mano en `Wallet::apply` porque enseña la mecánica con +claridad. Para agregados más grandes, el framework ofrece una ruta más fina: +implementar `EventSourcedAggregate` — un `apply_event` tipado más serialización de +snapshot opcional — y dejar que `EventSourcedRepository` ate `load` (snapshot + +replay) y `save` (append + política de snapshot) juntos. + +```rust,ignore +use firefly_eventsourcing::{ + AggregateRoot, DomainEvent, EventSourcedAggregate, EventSourcedRepository, + EventSourcingError, MemoryEventStore, +}; +use std::sync::Arc; + +#[derive(Default)] +struct Wallet { root: AggregateRoot, balance: i64 } + +impl EventSourcedAggregate for Wallet { + const AGGREGATE_TYPE: &'static str = "Wallet"; + fn root(&self) -> &AggregateRoot { &self.root } + fn root_mut(&mut self) -> &mut AggregateRoot { &mut self.root } + fn apply_event(&mut self, event: &DomainEvent) -> Result<(), EventSourcingError> { + if event.event_type == "Credited" { + let amount: i64 = serde_json::from_slice(&event.payload) + .map_err(|e| EventSourcingError::Projection(e.to_string()))?; + self.balance += amount; + } + Ok(()) + } +} + +# async fn ex() -> Result<(), EventSourcingError> { +let repo = EventSourcedRepository::::new(Arc::new(MemoryEventStore::new())); + +let mut w = Wallet::default(); +w.root_mut().raise("Credited", b"500"); +repo.save(&mut w).await?; // append uncommitted + +let reloaded = repo.load(&w.root.id).await?; // snapshot + replay +assert!(reloaded.is_some()); +# Ok(()) +# } +``` + +Lo que acaba de ocurrir: `EventSourcedAggregate` es el contrato del trait — expone +la raíz embebida vía `root()` / `root_mut()` y el fold del lado de lectura vía +`apply_event`. El repositorio entonces orquesta el pegamento que de otro modo cada +servicio basado en eventos escribe a mano: `save` calcula la versión esperada a +partir del lote no confirmado y hace append con concurrencia optimista; `load` +devuelve `Ok(Some(_))` cuando el agregado tiene eventos y `Ok(None)` cuando nunca se +persistió. Un evento sin handler debería devolver `EventSourcingError::Projection` +para que la reconstrucción falle ruidosamente en lugar de corromper el estado en +silencio. + +`EventSourcedRepository::with_snapshots(store, snapshots, interval)` habilita +capturas de estado periódicas para que la rehidratación no reproduzca toda la +historia — que es el siguiente paso. + +> **Tip** **Punto de control.** Puedes articular cuándo es correcta cada ruta: +> plegar a mano (`Wallet::apply`) cuando el agregado es pequeño y quieres la +> mecánica a la vista; `EventSourcedRepository` cuando quieres que se maneje por ti +> la orquestación de load/save/snapshot. Ambas terminan en el mismo `EventStore`. + +## Paso 10 — Snapshots: acotar el coste del replay + +Event sourcing cambia simplicidad de escritura por coste de lectura: un monedero +con 10.000 movimientos reproduce 10.000 eventos en cada carga. Los **snapshots** +recortan eso. + +> **Note** **Término clave — snapshot.** Un punto de control serializado del estado +> de un agregado en una versión concreta. En la carga, el repositorio +> deserializa el snapshot más reciente y reproduce solo los eventos *posteriores* a +> él — convirtiendo un replay de 10.000 eventos en uno de 1.000 si el snapshot está +> en la versión 9.000. El análogo en Axon es su disparador de snapshots. + +Los monederos de Lumen son lo bastante efímeros como para que el replay completo +del store en memoria esté bien, así que el sample no conecta snapshots — pero la +costura es una sola llamada al constructor: + +```rust,ignore +use firefly_eventsourcing::{EventSourcedRepository, MemorySnapshotStore}; + +// Checkpoint each time a wallet's stream crosses a 100-event boundary. +let repo = EventSourcedRepository::::with_snapshots( + store, + Arc::new(MemorySnapshotStore::new()), + 100, +); +``` + +Lo que acaba de ocurrir: `with_snapshots(store, snapshots, interval)` hace un punto +de control del estado del agregado cada vez que un stream *cruza* una frontera de +intervalo. El disparador es un cruce, no una divisibilidad exacta, de modo que un +lote que se sitúa a horcajadas del umbral (versión 95 → 105) aun así hace snapshot. +En la carga, el repositorio restaura el snapshot más reciente y reproduce solo los +eventos posteriores a él. + +> **Design note.** Los snapshots son una optimización, nunca un requisito de +> corrección. Elimínalos y el sistema es más lento pero sigue siendo correcto — los +> eventos siguen siendo la fuente de verdad, y el snapshot es solo un fold cacheado +> del prefijo. + +## Paso 11 — Proyecciones, el stream global y el outbox + +Estas tres costuras son la forma en que event sourcing alimenta al resto de un +sistema. No conectarás todas ellas en la base de enseñanza, pero conocer la forma +de cada una es parte de comprender el modelo. + +### Proyecciones — construir modelos de lectura a partir de la historia + +> **Note** **Término clave — proyección.** Un handler del lado de lectura que +> consume eventos para construir un modelo de lectura optimizado para consultas. +> Debe ser **idempotente**, porque los eventos pueden reproducirse durante la +> recuperación. El análogo en Spring es un `@EventListener` del lado de consulta +> que actualiza una tabla de lectura. + +Una `Projection` se registra en un `ProjectionRunner`, que puede reproducir los +eventos de un agregado a través de ella. Este es el hermano del *event store* del +oyente del *event bus* del [capítulo anterior](./10-eda-messaging.md): la +`WalletProjection` viva de Lumen reacciona a los eventos a medida que se publican, +mientras que un `ProjectionRunner` puede reproducir la historia desde el principio +para reconstruir un modelo de lectura desde cero. + +```rust,ignore +use std::sync::Arc; +use firefly_eventsourcing::{FunctionProjection, ProjectionRunner}; + +let runner = ProjectionRunner::new(); +runner.register(Arc::new(FunctionProjection::new("balances", |event| async move { + // update a read-model row from the event ... + Ok(()) +}))); + +runner.replay(&store, "wlt_a1").await?; // replay one aggregate's stream +``` + +Esta reconstruibilidad es exclusiva de event sourcing. Si el modelo de lectura de +Lumen alguna vez se pierde o cambia su esquema, detienes el proyector, limpias el +modelo de lectura, y reproduces cada stream — la historia está ahí mismo en el +store. Un modelo de almacenamiento de estado no puede hacer esto; descartó la +historia en tiempo de escritura. + +### El stream global — modelos de lectura entre agregados + +`EventStore::stream_all` expone el stream global, entre agregados y ordenado de +eventos con un cursor reanudable — el motor de los modelos de lectura que abarcan +muchos agregados (piensa en «todos los movimientos de todos los monederos, en +orden»). El runner lo consume por lotes, al menos una vez (at-least-once) y en +orden: + +```rust,ignore +// Drive one batch; returns the next cursor + any per-event error. +let (next_cursor, err) = runner + .drive_once(&store, None, 100, None) + .await?; + +// Or replay the whole global stream from a start cursor. +let cursor = runner.replay_all(&store, None, 100, None).await?; +``` + +Lo que acaba de ocurrir: `drive_once` aplica una página y devuelve el cursor desde +el que reanudar, avanzándolo solo más allá de los eventos aplicados con *éxito* — de +modo que un evento fallido se reintenta en la siguiente llamada en lugar de +saltarse. `replay_all` vacía el stream global completo desde un cursor de inicio, +paginando `batch_size` cada vez. + +### El transactional outbox — cerrar la brecha entre append y publish + +El [capítulo anterior](./10-eda-messaging.md) señaló una brecha en `Ledger::commit`: +hace append, luego publica, y un fallo *entre* ambos persiste el hecho pero pierde +la difusión. `TransactionalOutbox` cierra esa brecha. + +> **Note** **Término clave — transactional outbox.** Un patrón en el que un escritor +> *encola* (`enqueue`) un evento de forma duradera (idealmente en la misma +> transacción de store que el append) en lugar de publicarlo directamente, y un +> relay en segundo plano reenvía cada registro pendiente a un broker, reintentando +> ante fallo. Registrar el evento de forma duradera *antes* de despacharlo es lo que +> garantiza la entrega al menos una vez frente a fallos. Este es el mismo patrón de +> outbox que los equipos de Spring implementan en torno a su message broker. + +```rust,ignore +use std::sync::Arc; +use firefly_eventsourcing::{EdaSink, TransactionalOutbox}; + +let outbox = TransactionalOutbox::new(Arc::new(EdaSink::new( + broker, // the Arc + "wallet.events", // destination topic + "lumen", // logical source stamped onto every Event::source +))) +.with_max_attempts(5); + +outbox.enqueue(some_event).await; // a writer enqueues +outbox.start().await; // background relay forwards + retries +// ... later +let dead = outbox.dead_letters().await; // exhausted records, for inspection +outbox.stop().await; +``` + +Lo que acaba de ocurrir: un escritor hace `enqueue` de un `DomainEvent`; el relay +(arrancado con `start()`) sondea y reenvía cada registro pendiente a un +`OutboxSink`, reintentando hasta `max_attempts`. El `EdaSink` por defecto tiende un +puente de cada `DomainEvent` a un `firefly_eda::Event` y lo publica — duradero esta +vez. Los registros que agotan `max_attempts` se convierten en **dead letters**: +excluidos del bucle de publicación y expuestos vía `dead_letters()` para inspección +o reintento manual. Esta es la ruta de actualización a producción — y exactamente +por qué la proyección se construyó para ser **idempotente** en el capítulo anterior: +la entrega al menos una vez significa que un evento puede llegar dos veces. + +## Paso 12 — Evolución de esquema y multi-tenancy + +Dos costuras más completan el modelo. Ambas operan en la ruta de lectura, de modo +que la historia almacenada permanece inmutable. + +### Upcasters — migrar eventos antiguos en la lectura + +> **Note** **Término clave — upcaster.** Una transformación aplicada a un evento +> almacenado cuando se *lee*, migrándolo de un esquema antiguo al actual. Los +> consumidores siempre observan eventos del esquema actual; la historia almacenada +> nunca se reescribe. Esta es la respuesta de event sourcing a la migración de +> esquema. + +Supón que Lumen necesita más adelante un campo `reference` en cada ingreso para +conciliación: los eventos nuevos lo llevan, los eventos `MoneyDeposited` antiguos no, +y un upcaster cubre el hueco en la carga: + +```rust,ignore +use std::sync::Arc; +use firefly_eventsourcing::{EventUpcaster, MemoryEventStore}; + +let store = MemoryEventStore::with_upcasters(vec![Arc::new(MyUpcaster)]); +// every event returned by load / load_after passes through applicable upcasters +``` + +Un `EventUpcaster` implementa `applies_to(&event) -> bool` y +`upcast(event) -> DomainEvent`. Los datos antiguos se vuelven legibles sin una +migración; los datos nuevos se escriben en el esquema actual; los eventos en sí +permanecen inmutables. Nunca reescribes la historia. + +### Multi-tenancy — un store, muchos tenants + +Un `DomainEvent::tenant_id` opcional (estampado desde `AggregateRoot::with_tenant`, +persistido y filtrable, omitido del JSON cuando es `None`) se enhebra a través de +`append` / `load` / `stream_all`. Un único store sirve a muchos tenants con +aislamiento por tenant en el stream global — la ruta que tomaría un despliegue de +Lumen multibanco para mantener separados los streams de monedero de cada tenant. +Como el campo se omite del JSON cuando es `None`, un Lumen de un solo tenant +serializa byte a byte de forma idéntica al formato de cable entre lenguajes. + +> **Tip** **Punto de control.** Puedes nombrar, para cada costura, qué te cuesta si +> *no* la usas: sin snapshots → cargas más lentas; sin outbox → un fallo puede +> perder una publicación; sin upcaster → los eventos antiguos se vuelven ilegibles +> tras un cambio de esquema; sin tenant id → necesitas un store por tenant. Ninguna +> de ellas cambia la fuente de verdad — todas son preocupaciones de ruta de lectura +> o de entrega superpuestas sobre el mismo stream inmutable. + +## Resumen — qué cambió en Lumen + +El saldo del monedero ya no es un valor almacenado — es un *cálculo* sobre un stream +inmutable, y el stream es el sistema de registro. + +| Pieza | Rol | +|-------|------| +| `#[derive(DomainEvent)]` | Genera `EVENT_TYPE` + `event_type()` + `to_domain_event(...)` para cada struct de payload | +| `#[derive(AggregateRoot)]` | Genera `AGGREGATE_TYPE` + `aggregate()` / `aggregate_mut()` sobre el `root` embebido | +| Comando de `Wallet` (`deposit` / `withdraw`) | Valida la invariante, hace `raise` del evento, aplica al estado | +| `Wallet::apply` / `rehydrate` | El mismo fold se ejecuta en escritura y en replay — un stream vacío es «sin abrir» | +| `EventStore` / `MemoryEventStore` | El log de solo anexado; `SqlEventStore` para producción | +| `append(id, expected_version, …)` | Concurrencia optimista — la versión rehidratada es el token | +| `EventSourcedRepository` | Ata load (snapshot + replay) y save (append + política de snapshot) juntos | +| `ProjectionRunner` | Reconstruye modelos de lectura a partir de la historia (el hermano del lado del store del oyente de EDA) | +| `TransactionalOutbox` | Cierra la brecha entre append y publish con relay al menos una vez | +| `EventUpcaster` / `tenant_id` | Evolución de esquema en la lectura; aislamiento por tenant sobre un único store | + +Tres ideas se llevan adelante: + +- **Los eventos son la verdad.** No hay columna de saldo que pueda desviarse; el + saldo se pliega a partir del stream en cada carga. +- **Escritura y replay comparten un fold.** `apply` se ejecuta de la misma manera + ya sea que un comando acabe de levantar el evento o que una carga esté + reconstruyendo a partir de la historia — y el replay nunca revalida, porque cada + evento almacenado ya pasó su invariante. Esa simetría es la garantía de + corrección. +- **Depende del puerto `EventStore`.** El store en memoria se convierte en SQL con + un intercambio de bean de una línea, igual que el broker se convirtió en Kafka — + el dominio nunca cambia. + +Cuando un proceso de negocio abarca múltiples agregados y necesita compensación — +mover dinero de un monedero a otro, atómicamente — plegar un único stream ya no +basta. Ese es el siguiente capítulo. + +## Ejercicios + +1. **Reproduce hasta un punto en el tiempo.** Abre un monedero y haz tres ingresos. + Carga el stream crudo con `ledger.load_events(&id)`, toma solo los eventos con + `version <= 2`, y `Wallet::rehydrate` un monedero nuevo a partir de esa porción. + Afirma que el saldo es igual a apertura + primer ingreso solamente — la «consulta + de viaje en el tiempo» que un modelo de almacenamiento de estado no puede + responder. + +2. **Demuestra que la guarda de descubierto no levanta evento.** Abre un monedero + con 100 céntimos, intenta `withdraw` de 101, y afirma que da error con + `DomainError::InsufficientFunds`. Luego llama a `wallet.root.uncommitted()` y + afirma que el búfer sigue conteniendo exactamente un evento (el `WalletOpened`) — + el comando fallido dejó el stream limpio. + +3. **Fuerza un conflicto de concurrencia optimista.** Haz append del evento de + apertura de un monedero en `expected_version = 0`. Luego, sin recargar, levanta + un segundo evento y hazle append *también* en `expected_version = 0`. Afirma que + el segundo append devuelve `EventSourcingError::Concurrency`, y explica por qué + una carga fresca (que avanza `expected` a 1) habría tenido éxito. + +4. **Añade una reconstrucción con `ProjectionRunner`.** Registra una + `FunctionProjection` que cuente el número de eventos `MoneyDeposited` por + monedero en un mapa en memoria, `replay` el stream de un monedero a través de + ella, y afirma el conteo. Luego limpia el mapa y reproduce de nuevo — + confirmando que el modelo de lectura es reconstruible solo a partir del store, + sin tráfico de eventos en vivo. + +5. **Intercambia el store (sobre el papel).** Lee el `#[bean]` `event_store` en + `LumenBeans`, luego escribe el cambio de una línea que devolvería un + `SqlEventStore::new(db)` en lugar de un `MemoryEventStore::new()`. Observa que + ningún comando, ningún `apply` y ningún `rehydrate` cambiaría — solo el bean. Esa + es la recompensa de depender del puerto `EventStore`. + +## Adónde ir después + +- Coordina un proceso entre **dos** monederos — debita uno, acredita el otro, y + compensa cuando el crédito falla — en + **[Sagas, Workflows & TCC](./12-sagas.md)**. La saga de transferencia se construye + directamente sobre la guarda de descubierto y el token de concurrencia optimista + de este capítulo. +- Revisa cómo cada evento anexado alcanza la proyección por el cable en + **[Event-Driven Architecture & Messaging](./10-eda-messaging.md)** — la mitad de + transporte de la historia que este capítulo completó. diff --git a/docs/book/src-es/12-sagas.md b/docs/book/src-es/12-sagas.md new file mode 100644 index 00000000..145f2e13 --- /dev/null +++ b/docs/book/src-es/12-sagas.md @@ -0,0 +1,1153 @@ +# Sagas, workflows y TCC + +Al terminar este capítulo, Lumen sabrá **mover dinero entre dos monederos** — y +hacerlo *de forma segura*. Una transferencia no es un único comando: adeuda un +monedero y luego abona otro, y eso son dos escrituras independientes sobre dos +flujos de eventos independientes. Si la rama del abono falla después de que el +adeudo ya se haya confirmado, el titular de origen pierde el dinero sin tener +nada al otro lado. No existe ningún `BEGIN … COMMIT` que abarque dos agregados, +así que Lumen recurre a los patrones que hacen este trabajo a través de una +frontera distribuida: una **saga** que compensa el adeudo cuando el abono falla, +un **workflow** que ejecuta comprobaciones previas en paralelo, y un coordinador +**TCC** que reserva en ambos lados antes de confirmar cualquiera de ellos. + +Construyes los tres sobre el `Ledger` con event sourcing que desarrollaste en +[Event Sourcing](./11-event-sourcing.md), de modo que una transferencia genera +eventos *reales* `MoneyWithdrawn` / `MoneyDeposited` en ambos flujos — y un +reembolso genera un `MoneyDeposited` real en el flujo de origen. Aquí nada es un +juguete; cada rama acciona el mismo servicio de aplicación que usan los +handlers de CQRS, y cada resultado es observable en el ledger. + +Al terminar este capítulo, serás capaz de: + +- Explicar *por qué* una transferencia de dinero entre dos agregados necesita una + saga, y no una transacción de base de datos, y qué es una *compensación*. +- Declarar una `Saga` con `#[firefly::saga]` y `#[saga_step]` — incluyendo el + orden con `depends_on`, un método `compensate` con nombre y reintentos por paso + — luego ejecutarla y leer su `Outcome`. +- Declarar un `Workflow` con `#[firefly::workflow]` que ejecuta comprobaciones + independientes en una capa paralela y une sus veredictos tipados en un nodo de + decisión. +- Declarar un coordinador TCC con `#[firefly::tcc]` y `#[participant]` para + reservar y luego confirmar a través de dos recursos. +- Montar los tres en la superficie web de Lumen, renderizando un rollback limpio + como un problema RFC 9457 `422` en lugar de un `500`. +- Elegir el motor adecuado para un proceso dado, y reconocer el compromiso de + consistencia eventual que asume cada uno. + +## Conceptos que conocerás + +Antes de la primera línea de código, aquí están las ideas en las que se apoya +este capítulo. Cada una se reintroduce en contexto allí donde se usa por primera +vez; esta es la versión breve. + +> **Note** **Término clave — saga.** Una *saga* es una secuencia de transacciones +> locales donde cada paso tiene una acción *compensatoria* que lo deshace +> semánticamente. Si un paso posterior falla, el motor ejecuta las compensaciones +> de los pasos completados en orden inverso. Así es como se consigue un "todo o +> nada" entre servicios que no pueden compartir una única transacción de base de +> datos. El equivalente en Java es el patrón `@Saga` / `@SagaStep`; pyfly lo +> expresa con decoradores de saga. + +> **Note** **Término clave — compensación.** Una *compensación* no es un rollback +> de base de datos — es un *deshacer semántico*. "Reabonar al origen" es un +> `deposit` completamente nuevo que restaura el saldo y deja tras de sí un evento +> de reembolso auditable; no borra la historia, añade un hecho correctivo. + +> **Note** **Término clave — workflow (DAG).** Un *workflow* es un grafo dirigido +> acíclico de pasos. Los pasos sin dependencia entre ellos se ejecutan +> concurrentemente en la misma *capa topológica*; un paso que declara +> `depends_on` espera a sus predecesores. Úsalo cuando un proceso tenga ramas +> independientes que deban ejecutarse en paralelo y luego unirse. + +> **Note** **Término clave — TCC (Try-Confirm-Cancel).** El *TCC* es un protocolo +> en dos fases: **Try** en cada participante (reservar recursos) y luego +> **Confirm** en todos si hay éxito; ante cualquier fallo en el Try, **Cancel** en +> los participantes ya intentados. Mientras que una saga aplica cada rama de +> inmediato y la deshace más tarde, el TCC reserva primero y solo confirma una vez +> que cada reserva ha tenido éxito. + +> **Note** **Término clave — consistencia eventual.** Operar entre agregados +> independientes sin un bloqueo distribuido implica que hay una ventana en la que +> una rama se ha confirmado y otra no. Estos motores garantizan la consistencia +> *al final* — todas las ramas confirmadas, o todas compensadas — no en cada +> instante. + +`firefly-orchestration` incluye los tres motores clásicos de transacciones +distribuidas en los que coincide toda plataforma Firefly. Cada uno compone pasos +async, se ejecuta como un simple future sobre la task del llamador, aplica una +política de reintentos por paso, hila un blackboard de contexto tipado y respeta +la cancelación cooperativa. Y — esta es la propiedad clave — no los construyes a +mano como valores. Lumen declara cada motor con una macro de atributo sobre un +bloque `impl`, exactamente como declara los handlers de CQRS y los controladores. + +| Motor | Topología | Compensación | Se declara con | +|------------|----------------------------|------------------------------------|------------------------------------| +| `Saga` | Pasos ordenados por dependencias | Orden inverso, política configurable | `#[saga]` + `#[saga_step]` | +| `Workflow` | DAG con capas paralelas | Orden inverso, política configurable | `#[workflow]` + `#[workflow_step]` | +| `Tcc` | Try a todos, luego Confirm a todos | Cancel a los intentados ante un fallo de Try | `#[tcc]` + `#[participant]` | + +> **Design note.** El modelo de orquestación de Firefly es *declarativo*. Escribes +> un bloque `impl` corriente de métodos `async fn(&self, …) -> Result` y los +> anotas: `#[saga_step]` para una rama de saga, `#[workflow_step]` para un nodo de +> DAG, `#[participant]` para un actor de TCC. La macro baja esos métodos a los +> mismos motores de `firefly-orchestration` — `depends_on` los ordena, +> `compensate` nombra el deshacer, el `Ok(T)` de un paso se publica para los pasos +> posteriores, y un `Err(E)` dispara la compensación en orden inverso. Si has +> usado `@Saga` de Java o los decoradores de saga de pyfly, esta es la forma de +> escribirlo en Rust: el flujo de control vive en métodos que puedes leer de +> arriba abajo, y el cableado se genera por ti. + +## Paso 1 — Entender el problema de las escrituras distribuidas + +Concreta los modos de fallo antes de escribir una línea de código. Una +transferencia de Lumen tiene dos ramas: + +1. **Adeudar el origen** — `withdraw(amount)`, que impone `balance >= 0`. +2. **Abonar el destino** — `deposit(amount)`. + +Cada rama es una llamada independiente al `Ledger` que añade al propio flujo de +eventos de ese monedero. Un monedero de destino inexistente, o un descubierto en +el origen, hace fallar una rama después de que la otra ya pueda haberse +confirmado. Reintentar la operación *completa* es inseguro — podrías adeudar dos +veces. Saltarte en silencio la rama fallida deja los saldos inconsistentes. + +La respuesta de principios es la **consistencia eventual con compensación +explícita**. Cada rama se confirma en su propio flujo de forma independiente, y +diseñas una ruta de recuperación — una transacción compensatoria — para cada paso +que pueda tener éxito antes de que falle uno posterior. "Reabonar al origen" es un +`deposit` completamente nuevo que restaura el saldo, y deja tras de sí un evento +de reembolso auditable. + +
+ +forward: dependency-ordered steps +debitwithdraw(amount)creditdeposit(amount)notifypublish event +may fail + + +compensate — reverse order +a compensation is a forward undo, not a database rollback + +
Una saga ejecuta sus pasos en orden de dependencias. Si un paso falla, el motor ejecuta las compensaciones de los pasos ya completados en orden inverso — aquí un credit fallido reembolsa el debit. Una compensación es una acción hacia delante que deshace, no un rollback de base de datos.
+
+ +Lo que acaba de ocurrir: nombraste las dos escrituras, viste por qué ni el +reintento ni saltarse el fallo son seguros, y te decantaste por la forma de +saga — adeudar y luego abonar, con un reembolso esperando por si el abono llega a +fallar. El resto del capítulo convierte esa forma en código. + +> **Tip** **Punto de control.** Sabes enunciar, en una sola frase cada uno, por +> qué una transferencia de dinero no puede ser una única transacción de base de +> datos y qué hace una compensación que un rollback no hace. Si ambos están +> claros, estás listo para declarar la saga. + +## Paso 2 — Declarar los tipos de la interfaz + +La transferencia de Lumen vive en `src/transfer.rs`. Empieza con los tipos que +cruzan la frontera HTTP: el cuerpo de la petición y el resultado que devuelve +`POST /api/v1/transfers`. + +```rust +use serde::{Deserialize, Serialize}; + +/// `POST /api/v1/transfers` command — move `amount` (cents) from `from` to `to`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, firefly::Schema)] +#[serde(default)] +pub struct TransferRequest { + /// The source wallet id (debited). + pub from: String, + /// The destination wallet id (credited). + pub to: String, + /// The amount to move, in minor units (cents); must be `> 0`. + pub amount: i64, +} + +/// The result of a completed (or compensated) transfer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, firefly::Schema)] +pub struct TransferResult { + /// `"completed"` when both legs succeeded — the lowercase `SagaStatus`. + pub status: String, + pub from: String, + pub to: String, + pub amount: i64, + #[serde(rename = "stepsExecuted")] + pub steps_executed: Vec, + #[serde(rename = "stepsRolledBack")] + pub steps_rolled_back: Vec, +} +``` + +Lo que acaba de ocurrir: `TransferRequest` lleva los dos ids de monedero y un +importe en unidades menores (céntimos). `TransferResult` refleja el estado de la +saga como una cadena en minúsculas más las dos listas de pasos, de modo que la API +le dice al llamador *exactamente* qué hizo el motor — qué pasos se ejecutaron y +cuáles se revirtieron. + +> **Note** **Término clave — `firefly::Schema`.** El derive `Schema` enseña a la +> documentación OpenAPI autogenerada (servida en el puerto de gestión) qué aspecto +> tiene este DTO. Es el equivalente en Rust de la reflexión de modelos de +> springdoc, calculado en tiempo de compilación. Conociste la documentación del +> puerto de gestión en [Quickstart](./02-quickstart.md); todo DTO que cruza la +> interfaz lo deriva. + +Una transferencia también necesita un error tipado que distinga una petición +malformada de un fallo de negocio limpio y compensado: + +```rust +/// The typed error a transfer surfaces to its caller. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransferError { + /// The request was malformed (same wallet, non-positive amount). + Invalid(String), + /// The transfer failed and was rolled back; the inner string is the + /// failing leg's domain error (e.g. `insufficient funds`). + Compensated(String), +} + +impl std::fmt::Display for TransferError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TransferError::Invalid(detail) => f.write_str(detail), + TransferError::Compensated(detail) => write!(f, "transfer rolled back: {detail}"), + } + } +} + +impl std::error::Error for TransferError {} +``` + +Lo que acaba de ocurrir: `Invalid` es una petición incorrecta (un `422` que nunca +tocó el ledger); `Compensated` es un fallo de negocio que se ejecutó, revirtió +limpiamente y arrastra la causa de la rama fallida. Mantenerlos como variantes +distintas permite al endpoint mapear cada uno al estado HTTP correcto. + +> **Note** Lumen escribe a mano `Display` + `std::error::Error` en lugar de +> incorporar `thiserror`. Esa es la misma disciplina de una sola dependencia que +> mantiene el resto del libro: todo el framework y cada error tipado siguen +> llegando a través de la única fachada `firefly`, sin ninguna crate adicional que +> alinear. + +## Paso 3 — Declarar la saga + +La saga es un bloque `impl` sobre una pequeña struct que contiene el `Ledger`. +Cada rama es un método anotado que llama directamente al ledger y devuelve un +`Result<(), DomainError>` tipado. No hay captura de closures, ni `Mutex` para +sacar a escondidas la causa de un canal de error borrado, ni llamada a un +builder — la macro lee los atributos y genera todo eso. + +```rust +use std::sync::Arc; + +use firefly::orchestration::SagaError; + +use crate::domain::DomainError; +use crate::ledger::Ledger; +use crate::money::Money; + +/// The money-transfer saga, declared with `#[firefly::saga]`: each leg is an +/// annotated method driving the `Ledger`. The macro generates +/// `TransferSaga::run` (used by `run_transfer`) and `TransferSaga::saga`. +struct TransferSaga { + ledger: Ledger, +} + +#[firefly::saga(name = "money-transfer")] +impl TransferSaga { + /// Debit the source wallet (a real `MoneyWithdrawn` event). Rolled back by + /// `refund_debit` when a later leg fails. + #[saga_step(id = "debit", compensate = "refund_debit")] + async fn debit(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { + self.ledger.withdraw(&req.from, Money::cents(req.amount)).await?; + Ok(()) + } + + /// Compensation for `debit`: a refund is a normal deposit, so it raises a + /// real `MoneyDeposited` event on the source stream. + async fn refund_debit(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { + self.ledger.deposit(&req.from, Money::cents(req.amount)).await?; + Ok(()) + } + + /// Credit the destination (a real `MoneyDeposited` event). The last leg, so + /// a failure here rolls back only the debit. + #[saga_step(id = "credit", depends_on = ["debit"])] + async fn credit(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { + self.ledger.deposit(&req.to, Money::cents(req.amount)).await?; + Ok(()) + } +} +``` + +Cómo se lee, bloque a bloque: + +- `debit` es el primer paso (`id = "debit"`), y nombra su deshacer con + `compensate = "refund_debit"`. +- `refund_debit` *no* lleva el marcador `#[saga_step]` — es un método corriente + referenciado por nombre, y la macro lo incluye en la saga generada únicamente + porque `debit` apunta a él. +- `credit` declara `depends_on = ["debit"]`, así que el motor lo ejecuta + estrictamente después del adeudo. No tiene compensación porque es la última + rama: lo único que deshacer ante su fallo es el adeudo, lo cual el motor maneja + automáticamente. + +Cada rama toma `#[input] req: TransferRequest`. Ese marcador es el corazón del +modelo. + +> **Note** **Término clave — inyección de parámetros.** Los parámetros de cada +> paso se *inyectan* desde el contexto de la saga mediante marcadores que la macro +> lee y elimina: `#[input]` es la entrada completa (o `#[input("field")]` para un +> único campo); `#[from_step("id")]` es el valor `Ok` que publicó un paso +> anterior; `#[variable("key")]` es una variable de contexto con alcance de saga; +> y `#[ctx]` es el propio blackboard `StepContext`. Como aquí cada paso necesita +> la petición completa, cada parámetro es `#[input] req: TransferRequest`. + +El `Ok(T)` de un paso se serializa y se pone a disposición de los pasos +posteriores vía `#[from_step]`; un `Err(E)` (donde `E: std::error::Error + Send + +Sync`) dispara la compensación en orden inverso. Como los métodos devuelven +`DomainError` directamente, la causa tipada del fallo se preserva durante todo el +recorrido por el motor — sin necesidad de ningún `Mutex` compartido. + +El atributo `#[saga_step]` acepta `id` (obligatorio), `depends_on = ["…"]`, +`compensate = "method"`, y las palancas de recuperación por paso `retry`, +`backoff_ms`, `timeout_ms` y `jitter`. El atributo `#[saga(...)]` acepta un +`name`, una anulación de fachada `crate` y una `policy` de compensación: + +- **`best_effort`** (el valor por defecto del motor) — registra y continúa + compensando los pasos restantes aunque una compensación falle. +- **`stop_on_error`** — aborta el rollback en el primer fallo de compensación y + expone un `SagaError::Compensation` que envuelve el original. +- además de `retry_with_backoff`, `circuit_breaker`, `best_effort_parallel` y + `grouped_parallel` para fan-outs mayores. + +> **Note** Esta es la misma forma que `@Saga` / `@SagaStep` de Java y los +> decoradores de saga de pyfly — un método de paso, una compensación nombrada por +> cadena, un orden `depends_on` — pero bajada al sistema de tipos de Rust. Un paso +> que devuelve el tipo equivocado o que nombra una compensación inexistente es un +> *error de compilación*, no una sorpresa en tiempo de ejecución. + +> **Design note.** Aquí no hay reflexión ni escaneo en tiempo de ejecución. +> `#[saga]` se expande en tiempo de compilación a las llamadas exactas +> `Saga::new(...).step(...)` que de otro modo escribirías a mano, hiladas a través +> del contrato `__rt` de la fachada `firefly` de modo que un servicio de una sola +> dependencia lo compila sin nombrar nunca `firefly-orchestration`. Si alguna vez +> necesitas construir una saga dinámicamente (pasos conocidos solo en tiempo de +> ejecución), el mismo motor expone la costura programática a la que la macro baja: +> `Saga::new(name).step(Step::with_context(id, action).with_context_compensation(undo))`. + +> **Tip** **Punto de control.** Tienes una struct `TransferSaga`, un `impl` +> `#[firefly::saga]` con dos ramas `#[saga_step]` y una compensación nombrada. +> `cargo build` debería compilarlo — y si renombras `refund_debit` sin actualizar +> la cadena `compensate = "…"`, la compilación debería fallar con un mensaje que +> apunta a la línea infractora. Pruébalo y luego déjalo como estaba. + +## Paso 4 — Ejecutar la saga + +La macro genera dos métodos sobre el tipo: + +- `TransferSaga::saga(self: Arc) -> Saga` — construye el motor a partir de + tus pasos, su orden `depends_on`, las compensaciones y las políticas de + reintento. +- `TransferSaga::run(self: Arc, input) -> Result` — + serializa `input` en un contexto de paso nuevo y ejecuta todo el DAG, + compensando ante un fallo. + +`run_transfer` valida la petición, construye el valor de saga detrás de un `Arc` y +llama al `run` generado. Si tiene éxito lee el `Outcome`; si falla, extrae el +`DomainError` tipado de la rama fallida del `SagaError::Step` del motor para que la +API pueda responder `insufficient funds` literalmente: + +```rust +/// Validates and runs a money transfer as a declarative saga, returning the +/// terminal `TransferResult`. +pub async fn run_transfer( + ledger: &Ledger, + req: &TransferRequest, +) -> Result { + if req.amount <= 0 { + return Err(TransferError::Invalid("amount must be > 0".into())); + } + if req.from == req.to { + return Err(TransferError::Invalid("from and to must differ".into())); + } + + let saga = Arc::new(TransferSaga { + ledger: ledger.clone(), + }); + match saga.run(req.clone()).await { + Ok(outcome) => Ok(TransferResult { + status: outcome.status.to_string(), + from: req.from.clone(), + to: req.to.clone(), + amount: req.amount, + steps_executed: outcome.steps_executed, + steps_rolled_back: outcome.steps_rolled, + }), + Err(failure) => { + // Surface the failing leg's typed domain error (e.g. "insufficient + // funds"), unwrapped from the saga's generic step error. + let detail = match failure.error() { + SagaError::Step { source, .. } => source.to_string(), + other => other.to_string(), + }; + Err(TransferError::Compensated(detail)) + } + } +} +``` + +Lo que el `run` generado hace por ti, línea a línea: + +- `saga.run(req.clone())` serializa `req` en un `StepContext` nuevo, construye la + saga (`debit` → `credit`, con la compensación del adeudo adjunta) y ejecuta el + DAG. +- En el camino feliz devuelve un `Outcome` cuyo `status` es `Completed`, + `steps_executed` lista las ramas que se ejecutaron, y `steps_rolled` está vacío. + Fíjate en que el campo es `outcome.steps_rolled` del lado del motor; + `run_transfer` lo copia en el campo de la interfaz `steps_rolled_back`. +- Ante un fallo devuelve un `SagaFailure`: su `outcome()` está totalmente poblado + (estado `Compensated`, con `steps_rolled` nombrando las compensaciones que se + ejecutaron), y su `error()` es un `SagaError`. Hacemos match contra + `SagaError::Step { source, .. }` para recuperar el mensaje del `DomainError` de + la rama — así es como `POST /api/v1/transfers` responde `insufficient funds` en + lugar de un opaco `step "credit" failed`. + +> **Note** **Término clave — `Outcome` / `SagaFailure`.** `Outcome` es el registro +> terminal de la saga: `status` (un `SagaStatus` que se muestra en minúsculas — +> `completed` / `compensated` / `failed`), `steps_executed` y `steps_rolled`. +> `SagaFailure` es el par de fallo — `outcome()` da el mismo registro, y `error()` +> da el `SagaError` tipado que finalizó la ejecución. No hay un flag separado de +> "¿revirtió?" que consultar; el outcome te lo dice todo. + +> **Tip** **Punto de control.** `run_transfer` compila y sus tres ramas están +> claras: un fallo de validación `Invalid`, un camino feliz `Ok(Outcome)`, y un +> fallo `SagaError::Step` extraído a `TransferError::Compensated`. Estás listo para +> montarlo. + +## Paso 5 — Montar el endpoint de la saga + +El método del controlador en `src/web.rs` es delgado: acciona la saga y luego +traduce el resultado tipado al contrato HTTP. Un rollback limpio es un fallo de +*negocio*, así que se expone como un problema `422` que lleva la causa — no un +`500`: + +```rust +/// `POST /api/v1/transfers` — run a money transfer as a saga. +#[post( + "/transfers", + summary = "Transfer funds (saga)", + description = "Moves funds between two wallets as a compensating saga (debit then credit).", + tags = ["Transfers"], + status = 200 +)] +async fn transfer( + State(api): State, + Json(body): Json, +) -> WebResult> { + let result = run_transfer(&api.ledger, &body) + .await + .map_err(|e| match e { + TransferError::Invalid(detail) => WebError::from(FireflyError::validation(detail)), + TransferError::Compensated(detail) => { + WebError::from(FireflyError::validation(detail)) + } + })?; + // A transfer touches both wallets' views; invalidate the family. + api.query_cache.invalidate_type::(); + Ok(Json(result)) +} +``` + +Lo que acaba de ocurrir: + +- `run_transfer` devuelve el `TransferError` tipado; el `map_err` traduce ambas + variantes a un problema de validación. `FireflyError::validation(...)` se + renderiza como un documento RFC 9457 `422 application/problem+json` que lleva la + cadena de detalle, de modo que el llamador ve `insufficient funds`, no una traza + de pila. +- `invalidate_type::()` descarta las vistas `GetWallet` cacheadas, + porque una transferencia cambió dos saldos y una lectura tras la escritura debe + ser honesta. Esa caché y su invalidación son el tema de + [Caching](./17-caching.md); la transferencia es simplemente una mutación más que + juega con sus reglas. + +> **Note** Este handler vive dentro del `impl WalletApi` con +> `#[rest_controller(path = "...")]` de Lumen, montado automáticamente al arrancar +> — nunca editas `main` para añadir una ruta. `WebResult` es `Result WebError>`, y cualquier `WebError` se renderiza como un problema RFC 9457. +> Conociste ambos en [Tu primera API HTTP](./06-first-http-api.md). + +## Paso 6 — Leer los tres caminos de la saga + +Los tests en `src/transfer.rs` ejercitan los tres caminos, y son la mejor +documentación del comportamiento. El **camino feliz** mueve los fondos y no +revierte nada: + +```rust +let result = run_transfer( + &ledger, + &TransferRequest { from: src.id.clone(), to: dst.id.clone(), amount: 300 }, +) +.await +.unwrap(); + +assert_eq!(result.status, "completed"); +assert_eq!(result.steps_executed, ["debit", "credit"]); +assert!(result.steps_rolled_back.is_empty()); +assert_eq!(balance(&ledger, &src.id).await, 700); +assert_eq!(balance(&ledger, &dst.id).await, 300); +``` + +El camino de **descubierto** cortocircuita en el adeudo — el origen nunca tiene +los fondos, así que el withdraw falla *antes* de que se aplique nada. No hay nada +que compensar, y ambos saldos quedan intactos: + +```rust +let err = run_transfer( + &ledger, + &TransferRequest { from: src.id.clone(), to: dst.id.clone(), amount: 500 }, +) +.await +.unwrap_err(); + +assert_eq!(err, TransferError::Compensated("insufficient funds".into())); +assert_eq!(balance(&ledger, &src.id).await, 100); // untouched +assert_eq!(balance(&ledger, &dst.id).await, 0); // untouched +``` + +El camino de **fallo en el abono** es donde la compensación se gana su sueldo. El +adeudo se aplicó, luego el abono falló (el destino no existe), así que el motor +ejecuta la compensación del adeudo — un depósito de reembolso. El saldo neto del +origen queda restaurado, y el flujo registra *tanto* el adeudo como su reembolso, +un rastro de auditoría de exactamente lo que ocurrió: + +```rust +let err = run_transfer( + &ledger, + &TransferRequest { from: src.id.clone(), to: "wlt_missing".into(), amount: 400 }, +) +.await +.unwrap_err(); +assert!(matches!(err, TransferError::Compensated(_))); + +// open(1000) − withdraw(400) + refund(400) = 1000, with 3 events on the stream. +let src_events = ledger.load_events(&src.id).await.unwrap(); +assert_eq!(Wallet::rehydrate(&src.id, &src_events).view().balance, 1_000); +assert_eq!(src_events.len(), 3); // open + withdraw + refund-deposit +``` + +Lo que acaba de ocurrir: la tercera aserción es el quid de la compensación como +*deshacer semántico*. El saldo se restaura a `1_000`, pero el flujo **no** mide +dos eventos como si nada hubiera pasado — mide *tres* eventos: el open, el +withdraw y el depósito de reembolso. La historia de lo que realmente ocurrió se +preserva y es auditable. + +> **Note** Una saga no te da serializabilidad. Entre el momento en que se adeuda +> el origen y el momento en que el abono se confirma (o se ejecuta el reembolso), +> otra petición podría leer el origen y ver un saldo más bajo del que tendrá en +> última instancia. Ese es el compromiso de operar entre agregados independientes +> sin un bloqueo distribuido: consistencia *al final* — todas las ramas +> confirmadas, o todas compensadas — no en cada instante. + +> **Tip** **Punto de control.** Ejecuta `cargo test -p lumen transfer`. Los tests +> del camino feliz, del descubierto y del fallo en el abono pasan, y el test del +> fallo en el abono confirma tres eventos en el flujo de origen. Ese rastro de tres +> eventos es tu prueba de que la compensación añadió en lugar de borrar. + +## Paso 7 — Añadir un workflow de cumplimiento en paralelo + +Una transferencia grande debería pasar por un filtro de comprobaciones de +cumplimiento *antes* de que el dinero se mueva. Esas comprobaciones son +independientes entre sí — una comprobación de saldo y un tope por transferencia no +tienen nada que ver entre ellas — así que deberían ejecutarse en paralelo. Eso es +un `Workflow`: un DAG de nodos con declaraciones `depends_on`, donde los nodos +independientes se ejecutan concurrentemente dentro de una capa topológica y un +nodo que declara dependencias se ejecuta solo después de que estas se completen. + +Lo declaras con `#[firefly::workflow]` y marcas cada nodo con `#[workflow_step]` — +la misma inyección de parámetros que una saga. `#[workflow_step]` acepta `id` +(obligatorio), `depends_on = ["…"]`, `compensate = "method"`, `when = "expr"` (una +condición de salto — el nodo se omite cuando el predicado es falso) y +`fire_and_forget` (planifica el nodo sin bloquear la capa). La macro genera +`Workflow::workflow(self: Arc)` y `run(self, input) -> Result<(), +WorkflowError>`. + +
+ +parallel layer +balance-checkfunds_ok: bool +limit-checkwithin_limit: bool +approvedepends_on both + + + +
Un workflow es un DAG de pasos. balance-check y limit-check no tienen dependencia entre sí, así que se ejecutan en la misma capa paralela; approve espera a ambos y consume sus veredictos.
+
+ +El `src/compliance.rs` de Lumen ejecuta dos comprobaciones independientes en +paralelo y luego un filtro de aprobación que consume ambas. Primero, el tipo de +error y la entrada de política: + +```rust +use std::sync::Arc; + +use firefly::orchestration::WorkflowError; + +use crate::domain::Wallet; +use crate::ledger::Ledger; +use crate::transfer::TransferRequest; + +/// The per-transfer ceiling, in minor units (cents). +pub const MAX_TRANSFER_CENTS: i64 = 1_000_000; // 10,000.00 + +/// Why a transfer failed compliance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ComplianceError { + /// The source wallet does not exist, so its balance cannot be checked. + NotFound(String), + /// A check failed — the transfer is not allowed (the string says why). + Rejected(String), +} + +impl std::fmt::Display for ComplianceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ComplianceError::NotFound(id) => write!(f, "source wallet {id} not found"), + ComplianceError::Rejected(why) => write!(f, "transfer rejected: {why}"), + } + } +} + +impl std::error::Error for ComplianceError {} +``` + +Ahora el workflow en sí. `balance-check` y `limit-check` no tienen dependencia +entre sí, así que el motor los ejecuta en la misma capa topológica; `approve` +declara `depends_on` sobre ambos y lee sus veredictos booleanos a través de +`#[from_step(...)]`: + +```rust +/// The compliance workflow: each node drives the `Ledger` or a policy input. +struct ComplianceCheck { + ledger: Ledger, + max_cents: i64, +} + +#[firefly::workflow(name = "transfer-compliance")] +impl ComplianceCheck { + /// Does the source wallet hold enough to cover the transfer? Reads the real + /// source aggregate. Errors if the source does not exist. + #[workflow_step(id = "balance-check")] + async fn balance_check(&self, #[input] req: TransferRequest) -> Result { + let events = self + .ledger + .load_events(&req.from) + .await + .map_err(|e| ComplianceError::NotFound(e.to_string()))?; + if events.is_empty() { + return Err(ComplianceError::NotFound(req.from.clone())); + } + let balance = Wallet::rehydrate(&req.from, &events).view().balance; + Ok(balance >= req.amount) + } + + /// Is the amount within the per-transfer ceiling? Independent of the + /// balance check, so it runs in the same parallel layer. + #[workflow_step(id = "limit-check")] + async fn limit_check(&self, #[input] req: TransferRequest) -> Result { + Ok(req.amount <= self.max_cents) + } + + /// The decision node: runs only after both checks (`depends_on`) and + /// consumes their boolean verdicts via `#[from_step]`. + #[workflow_step(id = "approve", depends_on = ["balance-check", "limit-check"])] + async fn approve( + &self, + #[from_step("balance-check")] funds_ok: bool, + #[from_step("limit-check")] within_limit: bool, + ) -> Result<(), ComplianceError> { + if !funds_ok { + return Err(ComplianceError::Rejected("insufficient funds".into())); + } + if !within_limit { + return Err(ComplianceError::Rejected(format!( + "amount exceeds the {} cent per-transfer ceiling", + self.max_cents + ))); + } + Ok(()) + } +} +``` + +Lo que acaba de ocurrir — y por qué importa: esta es la recompensa del modelo de +inyección. `balance_check` devuelve `Ok(true)` u `Ok(false)`, y el motor serializa +ese `bool` bajo el id de nodo `balance-check`. `approve` declara +`#[from_step("balance-check")] funds_ok: bool`, y la macro deserializa el valor +almacenado de vuelta a ese parámetro — tipado en ambos extremos, sin fontanería de +contexto manual. `balance-check` lee el agregado de origen *real* desde el +`Ledger`; solo el tope por transferencia es una entrada de política nueva. + +> **Tip** **Punto de control.** Fíjate en la diferencia de topología respecto a la +> saga: el `credit` de la saga declara `depends_on = ["debit"]` para que los dos se +> ejecuten *en serie*; el `balance-check` y el `limit-check` del workflow *no* +> declaran dependencia entre sí, así que se ejecutan *en la misma capa*. Solo +> `approve` espera. Esa única diferencia de `depends_on` es la diferencia entre una +> cadena y un DAG. + +## Paso 8 — Ejecutar el workflow y recuperar la causa + +`run_compliance` construye el workflow detrás de un `Arc` y llama al `run` +generado. `Ok(())` significa aprobado; un `Err` se recupera en un +`ComplianceError` tipado. El motor de workflow expone un fallo de nodo como +`WorkflowError::Node { source, .. }`, donde el `source` empaquetado se puede hacer +downcast de vuelta al error original: + +```rust +/// Runs the compliance workflow for `req`. `Ok(())` means the transfer is +/// approved (both checks passed); `Err` carries the typed reason it was rejected. +pub async fn run_compliance( + ledger: &Ledger, + req: &TransferRequest, +) -> Result<(), ComplianceError> { + let check = Arc::new(ComplianceCheck { + ledger: ledger.clone(), + max_cents: MAX_TRANSFER_CENTS, + }); + match check.run(req.clone()).await { + Ok(()) => Ok(()), + Err(failure) => Err(compliance_cause(failure)), + } +} + +/// Recovers a typed `ComplianceError` from the failing node's error. +fn compliance_cause(failure: WorkflowError) -> ComplianceError { + let detail = match &failure { + WorkflowError::Node { source, .. } => { + if let Some(err) = source.downcast_ref::() { + return err.clone(); + } + source.to_string() + } + other => other.to_string(), + }; + if detail.contains("not found") { + ComplianceError::NotFound(detail) + } else { + ComplianceError::Rejected(detail) + } +} +``` + +Lo que acaba de ocurrir: `WorkflowError::Node` empaqueta el error del nodo fallido +como un `source`. `compliance_cause` primero intenta +`downcast_ref::()` para recuperar la variante tipada exacta; si +lo consigue, devuelve el error original literalmente. La comparación de cadenas de +respaldo es una ruta de cinturón y tirantes para cuando el tipo empaquetado no se +puede hacer downcast. + +El endpoint en `src/web.rs` es una comprobación previa de solo lectura que nunca +mueve fondos — `200 OK` con la decisión cuando se aprueba, `404` cuando el +monedero de origen es desconocido, y `422` que lleva el motivo cuando una +comprobación de cumplimiento rechaza: + +```rust +/// `POST /api/v1/transfers/compliance` — gate a transfer through the parallel +/// compliance workflow (balance + limit checks → approve). +#[post( + "/transfers/compliance", + summary = "Compliance-gated transfer (workflow)", + description = "Runs the parallel compliance workflow (balance + limit checks) before approving a transfer.", + tags = ["Transfers"], + status = 200 +)] +async fn transfer_compliance( + State(api): State, + Json(body): Json, +) -> WebResult> { + run_compliance(&api.ledger, &body).await.map_err(|e| match e { + // An unknown source wallet is a 404 (like GET /wallets/:id); a + // failed check is a 422. + ComplianceError::NotFound(detail) => WebError::from(FireflyError::not_found(detail)), + ComplianceError::Rejected(detail) => WebError::from(FireflyError::validation(detail)), + })?; + Ok(Json(serde_json::json!({ + "decision": "approved", + "from": body.from, + "to": body.to, + "amount": body.amount, + }))) +} +``` + +Lo que acaba de ocurrir: un origen ausente se mapea a `FireflyError::not_found` +(un problema `404`, coherente con `GET /wallets/:id`), y una comprobación +rechazada se mapea a `FireflyError::validation` (un problema `422`). Como la +comprobación nunca mueve fondos, no hay caché que invalidar. + +> **Note** El starter de la capa de experiencia, `firefly-starter-experience`, +> construye sobre exactamente este motor de workflow con pasos *dirigidos por +> señales* que se aparcan hasta que un llamador externo entrega una señal nombrada, +> y luego se reanudan desde donde lo dejaron. Volvemos a esa capa en +> [HTTP Clients](./13-http-clients.md). + +> **Tip** **Punto de control.** Ejecuta `cargo test -p lumen compliance`. Una +> transferencia financiada y dentro del límite se aprueba; una con descubierto es +> `Rejected`; una que excede el tope es `Rejected` con un mensaje de "ceiling"; un +> origen desconocido es `NotFound`. + +## Paso 9 — Replantear la transferencia como TCC + +La misma transferencia se puede modelar de una segunda forma — +reservar-y-luego-capturar — y Lumen incluye ambas para que las puedas comparar. +`Tcc` ejecuta un protocolo en dos fases: **Try** en cada participante (reservar +recursos) y luego **Confirm** en todos si hay éxito; ante cualquier fallo de Try, +**Cancel** en los participantes ya intentados, en orden inverso. Mientras que una +saga aplica cada rama de inmediato y deshace una rama confirmada ante un fallo, el +TCC reserva primero y solo confirma una vez que cada reserva ha tenido éxito — de +modo que una reserva fallida se cancela, nunca se compensa a posteriori. + +Lo declaras con `#[firefly::tcc]` y marcas cada método *try* con +`#[participant(name, confirm, cancel)]`. Los métodos confirm y cancel son simples +`async fn` referenciados por nombre. El resultado del try de un participante se +publica bajo su nombre, de modo que confirm y cancel pueden leerlo vía +`#[from_step("")]`. `#[participant]` acepta `name` y `confirm` +(obligatorios), además de `cancel`, `retry`, `backoff_ms` y `timeout_ms`. La macro +genera `Tcc::tcc(self: Arc)` y `run(self, input) -> Result<(), TccError>`. + +
+ +Try +reserve +Confirm +on all-tried +Cancel +on a try failure +source +withdraw (hold) +(none — held) +deposit (release) +dest +verify exists +deposit (capture) +(none — nothing held) + +all tried → confirm +any try fails → cancel tried in reverse + +
Try / Confirm / Cancel. El Try de cada participante reserva; una vez que todos han intentado, Confirm captura; si algún Try falla, el motor Cancela los participantes ya intentados en orden inverso. El origen retiene fondos en el Try y los libera en el Cancel; el destino captura en el Confirm.
+
+ +El `src/tcc_transfer.rs` de Lumen modela la transferencia como +reservar-y-luego-capturar. El try del origen *retiene* los fondos adeudando ahora; +su confirm es un no-op (el adeudo ya capturó), y su cancel libera la retención con +un reembolso. El try del destino *verifica* que existe (todavía no hay nada +confirmado, así que no hay cancel); su confirm captura abonando: + +```rust +use std::sync::Arc; + +use firefly::orchestration::TccError; +use serde::{Deserialize, Serialize}; + +use crate::domain::DomainError; +use crate::ledger::Ledger; +use crate::money::Money; +use crate::transfer::{TransferError, TransferRequest}; + +/// The wire result of a confirmed two-phase transfer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, firefly::Schema)] +pub struct TccTransferResult { + /// `"confirmed"` when both participants captured. + pub status: String, + pub from: String, + pub to: String, + pub amount: i64, +} + +/// The two-phase transfer coordinator: each participant drives the `Ledger`. +struct TwoPhaseTransfer { + ledger: Ledger, +} + +#[firefly::tcc(name = "transfer-2pc")] +impl TwoPhaseTransfer { + /// Source **try**: hold the funds by debiting now (a real `MoneyWithdrawn`). + #[participant(name = "source", confirm = "capture_source", cancel = "release_source")] + async fn hold_source(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { + self.ledger + .withdraw(&req.from, Money::cents(req.amount)) + .await?; + Ok(()) + } + /// Source **confirm**: the debit on try already captured the funds. + async fn capture_source(&self) -> Result<(), DomainError> { + Ok(()) + } + /// Source **cancel**: release the hold by refunding it (a real `MoneyDeposited`). + async fn release_source(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { + self.ledger + .deposit(&req.from, Money::cents(req.amount)) + .await?; + Ok(()) + } + + /// Destination **try**: pre-authorize by verifying the destination exists; + /// nothing is committed yet, so there is no cancel. + #[participant(name = "dest", confirm = "capture_dest")] + async fn hold_dest(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { + let events = self.ledger.load_events(&req.to).await?; + if events.is_empty() { + return Err(DomainError::NotFound(req.to.clone())); + } + Ok(()) + } + /// Destination **confirm**: capture by crediting the destination. + async fn capture_dest(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { + self.ledger.deposit(&req.to, Money::cents(req.amount)).await?; + Ok(()) + } +} +``` + +Cómo se lee: el participante `source` nombra las tres fases — +`confirm = "capture_source"`, `cancel = "release_source"` — mientras que `dest` +omite `cancel` porque su try no retiene nada. El confirm `capture_source` toma solo +`&self`: un método de participante sin parámetros inyectados es válido, y un +confirm no-op es exactamente la forma correcta cuando el try ya capturó. + +> **Tip** **Punto de control.** Compara los participantes de origen y destino. El +> try del origen *confirma un efecto secundario* (el withdraw), así que necesita un +> cancel real que reembolse. El try del destino solo *lee* (verifica la +> existencia), así que no retiene nada y no necesita cancel. La asimetría es +> intencionada y es exactamente por lo que el TCC te permite omitir un cancel +> cuando no hay nada que liberar. + +## Paso 10 — Ejecutar el TCC y montarlo + +`run_tcc_transfer` construye el coordinador detrás de un `Arc` y lo ejecuta. Si +tiene éxito, ambos lados capturaron (`status: "confirmed"`); ante cualquier fallo +de reserva los participantes intentados se cancelan y la causa de la fase fallida +se renderiza a partir de `TccError`: + +```rust +/// Validates and runs a two-phase transfer. On success both sides captured +/// (`status: "confirmed"`); on any reservation failure the tried participants +/// are cancelled (the source hold released) and this returns +/// `TransferError::Compensated` with the cause. +pub async fn run_tcc_transfer( + ledger: &Ledger, + req: &TransferRequest, +) -> Result { + if req.amount <= 0 { + return Err(TransferError::Invalid("amount must be > 0".into())); + } + if req.from == req.to { + return Err(TransferError::Invalid("from and to must differ".into())); + } + let tcc = Arc::new(TwoPhaseTransfer { + ledger: ledger.clone(), + }); + match tcc.run(req.clone()).await { + Ok(()) => Ok(TccTransferResult { + status: "confirmed".into(), + from: req.from.clone(), + to: req.to.clone(), + amount: req.amount, + }), + Err(err) => Err(TransferError::Compensated(tcc_cause(err))), + } +} + +/// Renders the failing phase's cause for the caller. +fn tcc_cause(err: TccError) -> String { + match err { + TccError::Try { source, .. } => source.to_string(), + TccError::Confirm(errors) => errors + .into_iter() + .map(|e| e.to_string()) + .collect::>() + .join("; "), + } +} +``` + +Lo que acaba de ocurrir: `TccError::Try { source, .. }` lleva la reserva que falló +(p. ej. un origen con descubierto o un destino inexistente); `tcc_cause` renderiza +su mensaje. `TccError::Confirm(errors)` recopila los fallos de la fase de confirm +si una captura falla de algún modo después de que todas las reservas tuvieran +éxito — sus mensajes se unen con `; `. El endpoint refleja el de la saga: `200 OK` +con el resultado confirmado, o `422` cuando una reserva falló y la retención del +origen se liberó. + +```rust +/// `POST /api/v1/transfers/2pc` — run a two-phase (Try/Confirm/Cancel) transfer +/// via the TCC coordinator. +#[post( + "/transfers/2pc", + summary = "Two-phase transfer (TCC)", + description = "Runs a Try/Confirm/Cancel two-phase transfer via the TCC coordinator.", + tags = ["Transfers"], + status = 200 +)] +async fn transfer_2pc( + State(api): State, + Json(body): Json, +) -> WebResult> { + let result = run_tcc_transfer(&api.ledger, &body) + .await + .map_err(|e| match e { + TransferError::Invalid(detail) => WebError::from(FireflyError::validation(detail)), + TransferError::Compensated(detail) => { + WebError::from(FireflyError::validation(detail)) + } + })?; + api.query_cache.invalidate_type::(); + Ok(Json(result)) +} +``` + +Los tests fijan la semántica de dos fases. Una transferencia a un destino +inexistente *retiene y luego libera* el origen, dejándolo intacto: + +```rust +let err = run_tcc_transfer( + &ledger, + &TransferRequest { from: src.id.clone(), to: "wlt_missing".into(), amount: 400 }, +) +.await +.unwrap_err(); +assert!(matches!(err, TransferError::Compensated(_))); +// Source try held the funds, then the dest try failed → source cancel +// released them: the hold + its release net to the original balance. +assert_eq!(balance(&ledger, &src.id).await, 1_000); +``` + +> **Note** Lumen incluye *ambos* planteamientos de la misma transferencia para que +> los puedas comparar. La saga aplica cada rama localmente y reembolsa el adeudo si +> el abono falla — lo más sencillo cuando un deshacer es en sí mismo una acción +> local limpia. El TCC reserva en ambos lados, y luego confirma o libera de forma +> conjunta — mejor cuando un participante puede *retener* una reserva de forma +> barata y quieres semántica de todo o nada sin ninguna ventana en la que un lado +> se haya confirmado y el otro no. + +> **Tip** **Punto de control.** Ejecuta `cargo test -p lumen tcc_transfer`. El test +> de éxito mueve los fondos y reporta `confirmed`; el test del destino inexistente +> retiene y luego libera el origen, de modo que su saldo vuelve a `1_000`; el test +> del origen insuficiente aborta antes de retener nada. + +## Paso 11 — Cancelación + +Los tres motores respetan un `CancellationToken` para la cancelación cooperativa. +Los motores dependen únicamente de `futures`, así que cualquier executor (Tokio +incluido) los acciona. El `run` declarativo siempre honra un token hilado a través +del contexto; cuando necesitas accionarlo de forma explícita, la API del builder de +más bajo nivel expone `run_cancellable(&token)`. Cancela el token desde un timeout +o una señal de apagado para drenar la ejecución. + +```rust,ignore +// Sketch — the builder seam `#[saga]` lowers onto, for a run you cancel yourself. +let token = firefly::orchestration::CancellationToken::new(); +let outcome = saga.run_cancellable(&token).await?; +// elsewhere: token.cancel(); // drains the run cooperatively +``` + +Lo que acaba de ocurrir: la cancelación es *cooperativa* — el motor comprueba el +token antes de ejecutar el siguiente paso, así que un paso en curso termina pero +ningún paso posterior arranca. Una ejecución cancelada se expone como +`SagaError::Cancelled` (y los equivalentes en `WorkflowError` / `TccError`), no +como un fallo de paso. + +## Resumen — qué cambió en Lumen + +- Lumen ahora declara sus orquestaciones con **macros**, no con valores + construidos a mano. La transferencia es un `impl` + `#[firefly::saga(name = "money-transfer")]` cuyo paso `debit` nombra + `compensate = "refund_debit"` y cuyo paso `credit` declara + `depends_on = ["debit"]`. La macro genera `TransferSaga::saga` y + `TransferSaga::run`, y `run_transfer` simplemente llama a `saga.run(req.clone())`. +- Un nuevo **workflow de cumplimiento** en `src/compliance.rs`: + `#[firefly::workflow(name = "transfer-compliance")]` ejecuta `balance-check` y + `limit-check` en una capa paralela, y luego `approve` (que hace `depends_on` de + ambos) consume sus veredictos `bool` a través de `#[from_step(...)]`. +- Una nueva **transferencia TCC en dos fases** en `src/tcc_transfer.rs`: + `#[firefly::tcc(name = "transfer-2pc")]` con un participante `source` + (`confirm = "capture_source"`, `cancel = "release_source"`) y un participante + `dest` (`confirm = "capture_dest"`, sin cancel) — reservar todos, y luego + confirmar todos o cancelar los intentados. +- Los tres están montados en la superficie web en `src/web.rs`: + `POST /api/v1/transfers` (saga), `POST /api/v1/transfers/compliance` (workflow) y + `POST /api/v1/transfers/2pc` (TCC) — cada uno renderizando un rollback limpio + como un problema RFC 9457 `422` e invalidando la caché `GetWallet` cuando mueve + fondos. +- Como cada rama devuelve su `DomainError` / `ComplianceError` tipado, la causa del + fallo se preserva a través del motor y se recupera de `SagaError::Step`, + `WorkflowError::Node` y `TccError::Try` — sin contrabando de `Mutex`, sin cadenas + opacas empaquetadas. +- Los comportamientos — camino feliz, cortocircuito por descubierto, reembolso por + fallo en el abono (tres eventos en el flujo de origen), rechazo en paralelo y una + retención de TCC liberada — están todos fijados por tests, de modo que la prosa + nunca puede desviarse del código. + +También ahora sabes cómo **elegir un motor**: + +| Necesidad | Motor | +|-------------------------------------------------|------------| +| Proceso ordenado por dependencias, deshacer ante fallo | `Saga` | +| Ramas paralelas que se unen | `Workflow` | +| Reservar-y-luego-confirmar entre recursos | `Tcc` | + +La transferencia de dinero de Lumen es una `Saga` (debit → credit, con un +reembolso del adeudo). Su filtro de cumplimiento previo es un `Workflow` +(comprobaciones de saldo y de límite en paralelo, luego approve). Y la misma +transferencia replanteada como reservar-y-luego-capturar es un `Tcc`. Los tres se +declaran de la misma manera — un bloque `impl` anotado — y los tres están montados +en la superficie web. + +## Ejercicios + +1. **Añade un paso `notify` a la saga.** Añade un tercer método + `#[saga_step(id = "notify", depends_on = ["credit"])]` a `TransferSaga` que + "envíe un recibo" (devuelve `Ok(())` por ahora). Afirma que en el camino feliz + `steps_executed == ["debit", "credit", "notify"]`, y que cuando el abono falla + el paso notify nunca se ejecuta y solo se revierte el adeudo. +2. **Haz que el adeudo reintente.** Dale al paso `debit` + `#[saga_step(id = "debit", compensate = "refund_debit", retry = 2, backoff_ms = 50)]`. + Acciona un `Ledger` inestable que falle el primer withdraw y tenga éxito en el + segundo, y afirma que la transferencia aún se completa — demostrando que el + reintento por paso recupera un fallo transitorio antes de que la compensación se + llegue siquiera a considerar. +3. **Añade un nodo de KYC al workflow.** Añade un tercer + `#[workflow_step(id = "kyc-check")]` independiente a `ComplianceCheck` que + devuelva un `bool`, y haz que `approve` haga `depends_on` de los tres, leyendo el + nuevo veredicto vía `#[from_step("kyc-check")]`. Afirma que `kyc-check` se + ejecuta en la misma capa paralela que las comprobaciones existentes y que un KYC + fallido rechaza la transferencia. +4. **Confirma que el TCC es de todo o nada.** Escribe un test que ejecute + `run_tcc_transfer` con un origen con descubierto y afirme que ningún saldo se + movió — el try del origen aborta antes de retener nada, así que no hay nada que + cancelar. Contrástalo con el test del destino inexistente, donde el origen *sí* + se retiene y luego se libera. +5. **Cambia la política de compensación de la saga.** Cambia el atributo de la saga + a `#[firefly::saga(name = "money-transfer", policy = "stop_on_error")]` y lee la + documentación de `SagaError::Compensation`. Razona (o haz un test) sobre qué + expondría la rama de error de `run_transfer` si una *compensación* en sí misma + fallara — y por qué `best_effort` es el valor por defecto del motor. + +## Adónde ir después + +- Para llamar a los servicios externos que estos motores coordinan — un procesador + de pagos, un proveedor de FX — necesitas un cliente HTTP. Continúa a + **[HTTP Clients](./13-http-clients.md)**. +- Los endpoints de transferencia invalidan la caché `GetWallet` en cada movimiento; + aprende cómo funcionan esa caché del lado de lectura y su invalidación en + **[Caching](./17-caching.md)**. +- Revisita el `Ledger` con event sourcing que cada rama acciona en + **[Event Sourcing](./11-event-sourcing.md)** para ver de dónde provienen los + eventos `MoneyWithdrawn` / `MoneyDeposited` que estas sagas generan. diff --git a/docs/book/src-es/13-http-clients.md b/docs/book/src-es/13-http-clients.md new file mode 100644 index 00000000..7dc09dca --- /dev/null +++ b/docs/book/src-es/13-http-clients.md @@ -0,0 +1,744 @@ +# Clientes HTTP + +Hasta ahora, cada tramo de una transferencia de Lumen ha sido una llamada a un +método *local*: el paso de abono es `ledger.deposit(&req.to, ...)`, en proceso e +infalible salvo por las reglas de dominio que aplica. Es algo deliberado: Lumen +es autocontenido, y mantenerlo así permitió que los capítulos anteriores +enseñaran modelado de dominio, CQRS, event sourcing y sagas sin tener la red de +por medio. Este capítulo es el momento en que la red entra en escena. Es el +*siguiente adaptador que añadirías*: cuando una transferencia tiene que liquidarse +contra una pasarela de pagos real, el tramo de abono deja de ser un +`Ledger::deposit` local y se convierte en una llamada a un servicio externo de +**Payments**, una llamada que puede expirar por timeout, fallar a medias o +aterrizar en un host sobrecargado, modos de fallo que un método local nunca tuvo. + +`firefly-client` te da un cliente *tipado* para esa llamada en lugar de una sesión +`reqwest` artesanal hilvanada con lógica de reintentos y timeouts. Conocerás tres +estilos de cliente —eager, reactivo y declarativo— que comparten un mismo conjunto +de automatismos, y luego verás cómo una **capa de experiencia** se sitúa delante +de Lumen y la compone con sus vecinos en una única API con forma de recorrido +(journey). Todo es accesible a través de la única fachada `firefly` de la que has +dependido desde [Quickstart](./02-quickstart.md). + +Al terminar este capítulo, serás capaz de: + +- Construir un `RestClient` eager con `RestBuilder`, llamarlo y decodificar un + documento de problema upstream en un error tipado. +- Construir el `WebClient` reactivo y elegir entre sus terminales `body_to_mono` / + `body_to_flux` / `exchange`, y saber por qué *no* lleva reintentos integrados. +- Escribir un trait declarativo `#[http_client]` y dejar que la macro genere la + implementación que emite las peticiones, la imagen especular de un + `#[rest_controller]`. +- Envolver una llamada saliente en un `CircuitBreaker` para que un upstream enfermo + no pueda arrastrar a Lumen con él. +- Comprender el patrón de capa de experiencia (BFF) y la estricta dirección de + dependencia `channel → experience → domain → core`. + +## Conceptos que conocerás + +Antes del primer cliente, aquí están las ideas en las que se apoya este capítulo. +Cada una se reintroduce en contexto allí donde se usa por primera vez; esta es la +versión breve. + +> **Note** **Término clave — cliente HTTP.** Un *cliente* aquí es un objeto que tu +> servicio usa para hacer llamadas HTTP *salientes* a otro servicio. Es el inverso +> de un controlador, que *recibe* llamadas entrantes. Firefly incluye un cliente +> tipado para que la forma de la petición, la decodificación de la respuesta y el +> manejo de errores los compruebe el compilador, en lugar de dejarlos a una llamada +> `reqwest` cruda. + +> **Note** **Término clave — documento de problema RFC 9457.** Un cuerpo de error +> JSON estándar (tipo de medio `application/problem+json`) que transporta los campos +> `type`, `title`, `status` y `detail`. RFC 9457 es el estándar actual (deja +> obsoleto al RFC 7807). Firefly los *produce* desde handlers que fallan y los +> *consume* en el lado del cliente, decodificando un problema upstream en un +> `FireflyError` tipado, de modo que un fallo externo arrastra el estado y el +> detalle del upstream directamente a través de la propia pila de errores de Lumen. + +> **Note** **Término clave — id de correlación / contexto de traza.** Un *id de +> correlación* es un identificador por petición que viaja con ella para que sus +> líneas de log y las de cada servicio al que llama puedan unirse entre sí. El +> *contexto de traza* del W3C (cabeceras `traceparent` / `tracestate`) hace lo +> mismo para el trazado distribuido. Todos los clientes de Firefly reenvían ambos +> de forma automática, así que una petición que se ramifica hacia tres upstreams +> sigue siendo una única traza coherente. + +> **Note** **Término clave — publicador reactivo (`Mono` / `Flux`).** Un `Mono` +> es un valor asíncrono diferido que se resuelve en, como mucho, un `T`; un +> `Flux` es un *flujo* asíncrono diferido de `T`. Los conociste en +> [El modelo reactivo](./05-reactive-model.md). El cliente reactivo los devuelve +> para que una llamada saliente caiga directamente en una tubería reactiva. El +> análogo en Spring es el `Mono` / `Flux` de Project Reactor. + +> **Note** **Término clave — Backend-for-Frontend (BFF).** Una aplicación ligera +> del lado del servidor que agrega varios servicios de dominio en una única API con +> *forma de recorrido* adaptada a un frontend concreto, en lugar de hacer que el +> frontend llame a cada servicio y combine los resultados por su cuenta. Se trata +> en profundidad en [La capa de experiencia](./20a-experience-tier.md); aquí se +> introduce. + +El crate publica sus clientes tras una única puerta de entrada, `firefly::client`, +y las piezas declarativas también se reexportan a través de `firefly::prelude`. +Los dos clientes HTTP comparten los mismos automatismos —`Accept` / `Content-Type` +por defecto, propagación del id de correlación y del contexto de traza W3C, y +decodificación de problemas RFC 9457 en un `FireflyError` tipado—: + +- el **`RestClient` eager** (construido con `RestBuilder`): una `async fn` que hace + `await` de un `Result`, con un presupuesto de reintentos integrado; +- el **`WebClient` reactivo** (construido con `WebClientBuilder`): cuyos operadores + terminales devuelven `Mono` / `Flux`, de modo que una llamada saliente compone de + extremo a extremo con una tubería reactiva. + +Por encima del `WebClient` se sitúa el trait **declarativo `#[http_client]`** —el +análogo del `@HttpExchange` de Spring 6—, que escribes como un trait y dejas que la +macro implemente. El crate también incluye builders y andamiajes para clientes +GraphQL, SOAP, gRPC y WebSocket, seleccionados por feature para que las +dependencias pesadas no entren en servicios que no las usan. + +> **Design note.** Ambos clientes HTTP son *valores construidos con un builder +> fluido*: no hay una interfaz anotada de la que generar para las superficies eager +> y reactiva, ni reflexión. Los decoradores de resiliencia (tratados cerca del +> final) envuelven la llamada desde fuera en lugar de venir integrados. Eso mantiene +> cada cliente pequeño y convierte la política de reintentos/corte de circuito en +> una propiedad del punto de llamada, no en un valor por defecto oculto. + +## Paso 1 — Construir el `RestClient` eager + +El cliente eager es el que conviene usar cuando solo quieres hacer `await` de un +resultado. Lo construyes con `RestBuilder`, configurando la URL base, las cabeceras +por defecto, un timeout por petición y un presupuesto de intentos, y luego llamas a +`request` con un método, una ruta y un cuerpo opcional. + +Aquí Lumen construye el cliente de Payments al que llamaría desde el tramo de abono +de una transferencia: + +```rust,no_run +use std::time::Duration; +use firefly::client::RestBuilder; +use http::Method; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +struct SettleTransfer { wallet_id: String, amount: i64, reference: String } +#[derive(Deserialize)] +struct Payment { id: String, status: String } + +#[tokio::main] +async fn main() { + let payments = RestBuilder::new("https://payments.internal") + .with_header("X-Tenant", "lumen") + .with_timeout(Duration::from_secs(5)) + .with_retries(3) + .build(); + + let req = SettleTransfer { + wallet_id: "wlt_alice".into(), + amount: 300, + reference: "transfer-42".into(), + }; + match payments.request::<_, Payment>(Method::POST, "/payments", Some(&req)).await { + Ok(payment) => println!("settled {} ({})", payment.id, payment.status), + Err(err) => { + // Upstream RFC 9457 problems are decoded into a typed FireflyError. + if let Some(fe) = err.as_firefly() { + eprintln!("payments upstream {}: {}", fe.status, fe.detail); + } + } + } +} +``` + +Qué acaba de ocurrir, bloque a bloque: + +- `RestBuilder::new("https://payments.internal")` prepara un builder en una URL base + (las barras finales se recortan para que la concatenación `base + path` quede + limpia). +- `.with_header("X-Tenant", "lumen")` fija una cabecera por defecto enviada en + *cada* petición que hace este cliente. +- `.with_timeout(Duration::from_secs(5))` limita cada intento a cinco segundos. +- `.with_retries(3)` fija el *presupuesto total de intentos* a tres. Fíjate en que + es el número de intentos, no de reintentos adicionales: `1` significa un intento + sin reintento, y el cliente solo reintenta ante errores de red y estados + `429` / `5xx`, con backoff exponencial (100 ms duplicándose por intento, con tope + en 2 s). +- `.build()` finaliza el `RestClient`. +- `payments.request::<_, Payment>(Method::POST, "/payments", Some(&req))` envía la + petición: el turbofish nombra el tipo del cuerpo (aquí inferido) y el tipo de + respuesta `Payment`. Codifica el cuerpo en JSON, fija `Content-Type` / + `Accept: application/json`, reenvía el id de correlación y el contexto de traza, y + decodifica un cuerpo 2xx en `Payment`. + +Por qué importa: una respuesta `application/problem+json` distinta de 2xx se +decodifica en un `FireflyError`, de modo que un fallo upstream arrastra el estado y +el detalle del upstream directamente a través de la propia pila de errores de Lumen. +`err.as_firefly()` es el accesor tipado que recupera el problema decodificado del +upstream. + +> **Tip** **Punto de control.** Puedes llamar a `request::<_, T>(method, path, body)` +> y recibir de vuelta un `Result`. En la rama de error, +> `err.as_firefly()` devuelve `Some(&FireflyError)` siempre que el fallo fuera un +> error HTTP upstream (no un fallo de transporte / codificación / decodificación), y +> `fe.status` / `fe.detail` reflejan el problema del upstream. + +### Ramificar según la clase de fallo + +Rara vez quieres hacer match contra códigos de estado crudos. `ClientError` ofrece +helpers de predicado para que quien llama pueda ramificar según la *clase* de fallo: + +```rust,ignore +match payments.request::<_, Payment>(Method::POST, "/payments", Some(&req)).await { + Ok(payment) => { /* settled */ } + Err(err) if err.is_unprocessable_entity() => { /* 422 — map onto Lumen's own 422 */ } + Err(err) if err.is_retryable() => { /* 429 / 5xx / transport — worth a retry */ } + Err(err) => { /* everything else */ } +} +``` + +Los predicados reflejan la forma en que el framework renderiza problemas en otros +sitios: `is_validation()` (400), `is_unauthorized()` (401/403), `is_not_found()` +(404), `is_conflict()` (409), `is_unprocessable_entity()` (422), +`is_rate_limited()` (429), `is_server_error()` (5xx) e `is_retryable()` (la misma +regla que el cliente aplica internamente: fallos de transporte, `429` y cualquier +`5xx`). + +> **Note** **Dónde encaja esto en la saga.** En [Sagas](./12-sagas.md) el tramo de +> abono era `ledger.deposit(&req.to, amount)`. En un despliegue partido eso pasa a +> ser `payments.request::<_, Payment>(Method::POST, "/payments", …)`. La *forma* de +> la saga no cambia —sigue siendo un `#[saga_step]` con el `compensate = +> "refund_debit"` del débito—, solo que el *cuerpo* del paso de abono ahora hace +> E/S a través de la red, que es justamente por lo que la compensación (devolver el +> débito) importa más que nunca. + +## Paso 2 — Mantener la llamada de liquidación idempotente + +La llamada de liquidación de una transferencia debe ser idempotente: si +`POST /payments` expira por timeout y el reintento de la saga la dispara de nuevo, +Payments no debe crear *dos* pagos. Lleva una `Idempotency-Key` estable —típicamente +el id de la transferencia— para que el upstream deduplique una petición reentregada. +Fíjala como cabecera por defecto en un builder por llamada: + +```rust,ignore +let payments = RestBuilder::new("https://payments.internal") + .with_header("Idempotency-Key", &transfer_id) // stable per business op + .with_timeout(Duration::from_secs(2)) + .build(); +``` + +Qué acaba de ocurrir: como la clave es una cabecera por defecto, *cada* intento que +hace este cliente —incluidos los reintentos que dispara el presupuesto— lleva la +misma clave. La deduplicación en sí es tarea del upstream; la tarea del cliente es +reenviar la clave de forma *consistente* a lo largo de los reintentos. + +Por qué importa: este es el espejo saliente de la idempotencia entrante que +obtuviste gratis en [Quickstart](./02-quickstart.md); allí Lumen *registra* una +`Idempotency-Key` y reproduce la respuesta almacenada; aquí Lumen *envía* una para +que el servicio al que llama pueda hacer lo mismo. + +> **Tip** **Punto de control.** Un `POST` reintentado que lleva una +> `Idempotency-Key` estable llega al upstream con la *misma* clave cada vez. Si fijas +> la clave por intento en lugar de por operación de negocio, la deduplicación se +> rompe: hazla una cabecera por defecto basada en el id de negocio (el id de la +> transferencia), no en el intento. + +## Paso 3 — Construir el `WebClient` reactivo + +El cliente reactivo devuelve `Mono` / `Flux`, de modo que una llamada saliente cae +directamente en una tubería reactiva y compone de extremo a extremo con los +responders `NdJson` / `Sse` ([El modelo reactivo](./05-reactive-model.md)) que usa +el endpoint de streaming de Lumen. Lo construyes con `WebClientBuilder`; la cadena +fluida de la petición se lee de arriba abajo: construir, direccionar, enviar, +decodificar: + +```rust,no_run +use firefly::client::WebClientBuilder; +use http::Method; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +struct SettleTransfer { wallet_id: String, amount: i64 } +#[derive(Deserialize)] +struct Payment { id: String } +#[derive(Deserialize)] +struct LedgerTick { seq: u64 } + +#[tokio::main] +async fn main() { + let client = WebClientBuilder::new("https://payments.internal") + .with_header("X-Tenant", "lumen") + .build(); + + // body_to_mono — a single value -> Mono. + let _payment = client + .method(Method::POST) + .uri("/payments") + .body(&SettleTransfer { wallet_id: "wlt_alice".into(), amount: 300 }) + .retrieve() + .body_to_mono::() + .block() + .await; + + // body_to_flux — a streamed NDJSON OR SSE body, decoded lazily + // element-by-element with backpressure. + let _ticks = client + .get() + .uri("/ledger/ticks") + .header("Accept", "application/x-ndjson") + .retrieve() + .body_to_flux::() + .collect_list() + .block() + .await; + + // exchange — raw status + headers without raising on a non-2xx. + let _resp = client.get().uri("/health").retrieve().exchange().block().await; +} +``` + +Qué acaba de ocurrir, leyendo una cadena a la vez: + +- `client.method(Method::POST)` (o los atajos `.get()` / `.post()` / `.put()` / + `.delete()` / `.patch()`) inicia una petición; `.uri(...)` fija la ruta; + `.body(&...)` codifica un cuerpo en JSON; `.retrieve()` finaliza la petición en una + *especificación de respuesta*. Aún no ha ocurrido ninguna E/S: la petición se + envía de forma perezosa cuando se suscribe el publicador devuelto. +- `.body_to_mono::()` dice «decodifica todo el cuerpo como un único + `Payment`» y produce un `Mono`. `.block().await` se suscribe y espera, + devolviendo `Result, FireflyError>`: el `Result` transporta + cualquier error terminal y el `Option` modela un cuerpo vacío (`204`). +- `.body_to_flux::()` dice «decodifica este cuerpo en streaming elemento + a elemento» y produce un `Flux`; `.collect_list()` lo reúne en un + `Mono>`. +- `.exchange()` devuelve la respuesta cruda (estado + cabeceras + cuerpo) *sin* lanzar + ante un no-2xx, como un `Mono`. + +> **Note** **Término clave — operador terminal.** Un *operador terminal* es el método +> que cierra la cadena fluida y decide la forma del resultado. En la especificación +> de respuesta de un `WebClient`, los tres terminales son: +> +> | Operador | Devuelve | Comportamiento | +> |-------------------------|---------------------------|------------------------------------------------------| +> | `body_to_mono::()` | `Mono` | todo el cuerpo decodificado como un único `T` | +> | `body_to_flux::()` | `Flux` | un cuerpo NDJSON/SSE en streaming, elemento a elemento | +> | `exchange()` | `Mono` | el estado + cabeceras + cuerpo crudos, sin lanzar | + +> **Tip** **Punto de control.** Una cadena de `WebClient` que termina en +> `.body_to_mono::()` te da un `Mono` del que puedes hacer `.block().await` +> (devolviendo `Result, FireflyError>`) o componer más. Nada se dispara +> hasta que te suscribes: si construyes la cadena y nunca le haces block/await, no se +> envía ninguna petición. + +## Paso 4 — Hacer streaming de una respuesta con `body_to_flux` + +`body_to_flux` consume el flujo de bytes trozo a trozo y decodifica un elemento por +frame, de forma perezosa y con contrapresión (backpressure): un consumidor lento +estrangula al productor, y `.take(n)` deja de tirar antes de tiempo. El decodificador +se elige a partir del `Content-Type` de la respuesta: + +- `application/x-ndjson` (y cualquier tipo no-SSE) → un documento JSON por línea + terminada en salto de línea; +- `text/event-stream` → frames SSE separados por una línea en blanco; las líneas + `data:` se concatenan y las líneas de comentario / `event:` / `id:` se ignoran. + +Un elemento mal formado termina el flujo con un `FireflyError` de decodificación: el +primer error es terminal, el contrato de reactive-streams que respeta el `Flux` de +Firefly. + +Por qué importa: este es el lado *consumidor* del mismo formato de cable que +*produce* el propio endpoint `GET /api/v1/wallets/:id/events` de Lumen (el endpoint +de `streaming` con feature gate). Un servicio hace streaming del log de eventos de la +wallet; otro lo lee de vuelta elemento a elemento, la simetría exacta que te compra +el modelo reactivo. + +> **Tip** **Punto de control.** Apunta un `body_to_flux::()` a un endpoint +> `application/x-ndjson` y hazle `.take(5)`; solo se tiran cinco elementos y el +> upstream deja de producir. Apúntalo a un endpoint `text/event-stream` y los frames +> SSE `data:` se decodifican igual: el tipo de contenido, no la llamada, elige el +> decodificador. + +## Paso 5 — Inspeccionar la respuesta cruda con `exchange` + +`exchange()` devuelve un `WebClientResponse` *sin* lanzar ante un no-2xx, de modo que +puedes inspeccionar el estado y decidir qué hacer: el terminal adecuado cuando un +no-2xx es *esperado* y no debería cortocircuitar la tubería: + +```rust,ignore +let resp = client.get().uri("/health").retrieve().exchange().block().await?.unwrap(); +if resp.is_success() { + let body: serde_json::Value = resp.body_json()?; +} else if let Some(problem) = resp.problem() { + // a decoded RFC 9457 FireflyError, if the body was a problem document +} +``` + +Qué acaba de ocurrir: `.exchange().block().await` devuelve +`Result, FireflyError>`; el `?` desempaqueta el `Result` +(aquí solo da error un fallo a nivel de transporte) y `.unwrap()` el `Option`. +`resp.is_success()` comprueba el rango 2xx, `resp.body_json::()` decodifica el +cuerpo en búfer y `resp.problem()` decodifica un cuerpo `application/problem+json` +distinto de 2xx en un `FireflyError` (devolviendo `None` para un 2xx). La diferencia +con `body_to_mono` es el comportamiento de *lanzar*: `body_to_mono` convierte un +no-2xx en el `Err` terminal del `Mono`, mientras que `exchange` te entrega la +respuesta cruda para que ramifiques sobre ella. + +## Paso 6 — Componer reintentos (el `WebClient` no integra ninguno) + +A diferencia de `RestBuilder::with_retries`, el `WebClient` **no** tiene presupuesto +de reintentos. Es intencionado: la política de reintentos sigue siendo una propiedad +del *punto de llamada*, no del cliente. Compón los reintentos sobre el publicador +devuelto con `Mono::retry` / `Mono::retry_backoff`: + +```rust,ignore +use firefly::reactive::{Backoff, Mono}; +use std::time::Duration; + +let payment = Mono::retry_backoff( + || client.get().uri("/payments/p1").retrieve().body_to_mono::(), + Backoff::new(3, Duration::from_millis(100)), +); +``` + +Qué acaba de ocurrir: `Mono::retry_backoff` toma un *closure de fábrica* (debe +reconstruir la petición en cada intento, ya que un `Mono` suscrito se consume) y una +planificación `Backoff::new(max_retries, base)`. Cada fallo vuelve a ejecutar la +fábrica tras un retardo que crece exponencialmente. `Mono::retry(factory, n)` es el +hermano de recuento fijo sin backoff. + +Por qué importa: el mismo `WebClient` puede ser cauto en un endpoint y agresivo en +otro, porque la política vive en la llamada y no en el cliente. Esto refleja cómo el +modelo reactivo compone `retry` sobre un publicador en lugar de configurarlo una sola +vez de forma global. + +> **Tip** **Punto de control.** Una llamada de `WebClient` envuelta en +> `Mono::retry_backoff` reintenta según su propia planificación; el `WebClient` +> desnudo nunca reintenta. Si te descubres deseando que `WebClientBuilder` tuviera un +> `.with_retries`, esa es la señal para recurrir a `Mono::retry_backoff` en su lugar. + +## Paso 7 — Escribir un trait declarativo `#[http_client]` + +Escribir la cadena de llamada a mano está bien para peticiones puntuales, pero un +*servicio al que llamas repetidamente* merece una interfaz tipada. `#[http_client]` +es el análogo del `@HttpExchange` de Spring 6 (el sustituto moderno de OpenFeign): +escribes un **trait** de métodos que llevan los mismos atributos de verbo que usa un +`#[rest_controller]`, y la macro genera un `Impl` que emite las peticiones a +través de un `WebClient`. Es la imagen especular de un controlador: el mismo +vocabulario, con la petición *emitida* en vez de *recibida*. + +> **Note** **Término clave — cliente declarativo.** Un *cliente declarativo* es una +> interfaz que tú *describes* (verbos, rutas, argumentos) y dejas que el framework +> *implemente*, en lugar de escribir tú mismo el código que emite las peticiones. La +> macro lee el trait y genera el cuerpo. El análogo en Spring es `@HttpExchange` +> sobre una interfaz Java (antiguamente el `@FeignClient` de Spring Cloud OpenFeign). + +```rust,ignore +use firefly::prelude::*; // #[http_client], ClientError, Mono, Flux +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct CreateOrder { pub sku: String, pub qty: u32 } +#[derive(Deserialize)] +pub struct Order { pub id: String, pub sku: String } + +#[http_client(path = "/api/v1/orders", name = "orders", bean)] +pub trait OrdersClient { + // `:id` name-matches the `id` arg → path variable (percent-encoded). + #[get("/:id")] + async fn get_order(&self, id: String) -> Result; + + // `status` / `page` are neither path vars nor a body → inferred query + // params; `Option` omits itself when `None`. + #[get("/")] + async fn list(&self, status: String, page: Option) -> Result, ClientError>; + + // the lone non-scalar arg is the JSON body; one explicit header. + #[post("/")] + async fn create(&self, #[header("X-Tenant")] tenant: String, order: CreateOrder) + -> Result; + + #[delete("/:id")] + async fn cancel(&self, id: String) -> Result<(), ClientError>; // 204 → () + + // reactive-first: a non-async fn returning Mono/Flux, no bridging. + #[get("/stream")] + fn stream(&self) -> Flux; +} +``` + +Qué acaba de ocurrir: la macro emitió el trait (menos los atributos marcadores de +verbo / por argumento) más una struct concreta `OrdersClientImpl` que envuelve un +`WebClient` e implementa el trait traduciendo el verbo, la plantilla de ruta y los +argumentos ligados de cada método en una petición fluida de `WebClient`. El +`path = "/api/v1/orders"` a nivel de trait se une a la ruta de cada método; +`name = "orders"` nombra el bean de DI; `bean` opta por el registro (Paso 8). + +Constrúyelo a partir de una URL base, o inyecta un `WebClient` afinado: + +```rust,ignore +let api = OrdersClientImpl::new("https://orders.svc"); // builds a WebClient +let order = api.get_order("42".into()).await?; +// or: OrdersClientImpl::with_client(my_web_client) // shared pool / timeouts +``` + +`OrdersClientImpl::new(base_url)` construye un `WebClient` nuevo enraizado en la URL +(y aplica los valores por defecto `accept` / `content_type` del trait, si los hay). +`OrdersClientImpl::with_client(web_client)` es la costura de DI: pasa un `WebClient` +ya configurado (timeouts, cabeceras por defecto, un pool de conexiones compartido), +el análogo del `HttpServiceProxyFactory` de Spring. + +### Cómo se ligan los argumentos + +La sintaxis de ruta es la `:id` del framework (la misma que `#[rest_controller]`), no +la `{id}` de Spring, así que un controlador y su cliente especular se leen idénticos, +y escribir `{id}` es un error de compilación que te apunta hacia `:id`. La ligadura de +argumentos no necesita atributos en el caso común: + +- un argumento sin anotar cuyo nombre coincide con un segmento `:var` es la + **variable de ruta** (codificada como percent-encoding); +- el único argumento no anotado y no escalar en un `POST` / `PUT` / `PATCH` es el + **cuerpo JSON**; +- todo lo demás es un **parámetro de consulta** (`Option` se omite cuando es `None`; + `Vec` / `&[_]` repite la clave). + +Anula cualquiera de estos con `#[path]` / `#[query("k")]` / `#[header("X")]` / +`#[body]`. Cada `:var` debe ligarse a exactamente un argumento o la macro se niega a +compilar, así que un renombrado sale a la luz de forma ruidosa en vez de descartar el +valor en silencio. + +### Formas de retorno + +Una `async fn` que devuelve `Result` es el valor por defecto +ergonómico; `Result` funciona para cualquier `E: From`; una `fn` +*no asíncrona* que devuelve `Mono` / `Flux` entrega el valor reactivo diferido +directamente (un `Flux` usa por defecto `Accept: application/x-ndjson`); y +`WebClientResponse` es la vía de escape cruda de `.exchange()`. + +> **Note** **Fidelidad de errores.** En un método con `await` de +> `Result`, cada fallo llega como `ClientError::Problem` con un +> `FireflyError` que lleva el estado y el código originales —así que `is_not_found()` +> / `is_server_error()` / `is_retryable()` siguen clasificando correctamente— en lugar +> de las variantes estructuradas `Transport` / `Decode` / `Encode`. Esas variantes +> estructuradas sobreviven solo en las formas de retorno `Mono` / `Flux` (donde el +> terminal `FireflyError` *es* el canal de error reactivo). Haz match sobre la forma +> reactiva cuando necesites variantes exactas byte a byte. + +> **Tip** **Punto de control.** Un trait bajo `#[http_client]` produce un +> `Impl` que puedes construir con `::new(url)`. Llamar a +> `get_order("42".into())` emite `GET /api/v1/orders/42` y decodifica el cuerpo en +> `Order`. Si tienes una errata en un `:var` de modo que no liga ningún argumento, la +> compilación falla: eso es la macro haciendo su trabajo. + +## Paso 8 — Autowire del cliente como bean + +Con `#[http_client(... bean)]`, el `OrdersClientImpl` generado se registra como un +bean al estilo `@Service` y se liga a `dyn OrdersClient`, así que un colaborador solo +declara `#[autowired] orders: Arc` y el contenedor lo resuelve: el +beneficio del autowire de cliente Feign que conociste en +[Cableado de dependencias](./04-dependency-wiring.md). El registro toma un bean +`WebClient` compartido del contenedor (uno con nombre cuando escribes `client = "…"`), +así que todo cliente declarativo sobre el mismo upstream puede compartir un único pool +de conexiones afinado. + +Qué acaba de ocurrir: `bean` ata el cliente declarativo al mismo grafo de DI que +cablea los controladores y handlers de Lumen. El trait debe ser object-safe para la +ligadura `dyn` (la macro lo comprueba de antemano y añade los supertraits +`Send + Sync`), de modo que una forma no object-safe falla con un mensaje claro en +lugar de un error `dyn Trait` aguas abajo. + +> **Tip** **Punto de control.** Un trait `#[http_client(... bean)]` convierte +> `Arc` en una dependencia inyectable. Añade `#[autowired] orders: +> Arc` a cualquier bean y el contenedor te entrega la implementación +> generada: sin construcción manual en el punto de llamada. + +## Paso 9 — Envolver la llamada en un circuit breaker + +Ambos clientes son deliberadamente pequeños. Para corte de circuito, limitación de +tasa (rate limiting) o bulkheads, envuelve las llamadas en decoradores de +`firefly-resilience` (los mismos que [Caché](./17-caching.md) aplica al trabajo +entrante, aplicados de la misma forma a las llamadas salientes). El circuit breaker es +lo que evita que un servicio de Payments enfermo arrastre a Lumen con él. + +> **Note** **Término clave — circuit breaker.** Un *circuit breaker* vigila los +> fallos recientes de una dependencia. Tras suficientes fallos se *abre* y rechaza de +> inmediato las siguientes llamadas durante un enfriamiento, en lugar de dejar que +> cada llamante espere en un timeout condenado; luego pasa a semiabierto para sondear +> la recuperación. El análogo en Spring/Java es el `CircuitBreaker` de Resilience4j. + +```rust,ignore +use firefly::resilience::{CircuitBreaker, CircuitConfig}; + +// CircuitBreaker::execute returns the operation's value (Result), so the +// guarded call still yields the Payment. +let breaker = CircuitBreaker::new(CircuitConfig::default()); + +let payment = breaker.execute(|| async { + payments.request::<_, Payment>(Method::POST, "/payments", Some(&req)).await +}).await?; +``` + +Qué acaba de ocurrir: `CircuitBreaker::new(CircuitConfig::default())` construye un +breaker; `breaker.execute(|| async { ... })` ejecuta el closure bajo supervisión, +registrando cada resultado y propagando el `Result` de la operación (de modo que +la llamada protegida sigue produciendo el `Payment`). + +Por qué importa: cuando llamadas repetidas fallan, el breaker se abre y rechaza las +llamadas siguientes de inmediato con `ResilienceError::CircuitOpen` en lugar de +esperar en un timeout, así que un único upstream lento no puede agotar el pool de +tareas de Lumen. La resiliencia pertenece a la capa de *cliente*, configurada una sola +vez, no dispersa por cada handler. + +> **Tip** **Punto de control.** Lleva al upstream a fallar suficientes veces y la +> siguiente `breaker.execute(...)` devuelve `Err(ResilienceError::CircuitOpen)` *de +> inmediato*, sin espera de timeout. `err.is_circuit_open()` lo confirma. + +## Paso 10 — Conocer la capa de experiencia (un BFF de Lumen) + +Un frontend móvil o web rara vez quiere la forma cruda de un único servicio de +dominio; quiere un *recorrido* (journey): «muéstrame el saldo de esta wallet **y** sus +pagos pendientes, en una sola llamada». Llamar a Lumen por el saldo y a Payments por +la lista de pendientes y combinarlos en el cliente significa dos viajes de ida y +vuelta, dos dominios de fallo, y que el frontend filtre conocimiento de las +interioridades de ambos servicios. El patrón Backend-for-Frontend (BFF) mueve esa +composición al lado del servidor. + +Firefly incluye un starter dedicado para esta capa, `firefly-starter-experience`. Se +construye sobre el mismo `WebStack` que usa Lumen (así que hereda CORS, cabeceras de +seguridad, métricas de petición, correlación y la superficie del actuator) y añade los +bloques de construcción del BFF: + +- `DomainClients`: un registro de `RestClient`s con nombre para los servicios de + dominio aguas abajo; +- `SignalService`: regula un paso de workflow de larga duración, dirigido por señales, + en el que se detiene hasta que un llamante entrega una señal con nombre (el + `Workflow` de capa de experiencia de [Sagas](./12-sagas.md)); +- un `WorkflowState` con capacidad para Redis indexado por id de correlación, más un + `WorkflowQueryService` para lecturas de estado del recorrido. + +Un servicio de experiencia de Lumen registra sus clientes aguas abajo de antemano y +luego los compone: + +```rust,ignore +use firefly::starter_experience::{ExperienceStack, CoreConfig}; + +let bff = ExperienceStack::new(CoreConfig { + app_name: "lumen-mobile-bff".into(), + ..Default::default() +}); + +// Register the domain SDKs this BFF composes. `register` returns an +// Arc already wired with correlation + trace propagation. +let wallets = bff.clients.register("wallets", "https://lumen.internal"); +let payments = bff.clients.register("payments", "https://payments.internal"); +``` + +Qué acaba de ocurrir: `ExperienceStack::new(CoreConfig { app_name, .. })` cablea la +capa web más los bloques de construcción del BFF; `bff.clients` es el registro +`DomainClients`, y `register(name, base_url)` devuelve un `Arc` ya cableado +con propagación de correlación + traza. La raíz de composición se ramifica entonces a +través de los clientes registrados —ambas llamadas salen de forma concurrente, así que +la latencia compuesta queda acotada por el upstream más lento en vez de por su suma— y +se degrada con elegancia si un upstream tiene el circuito abierto (muestra el saldo, +deja la lista de pendientes vacía) en lugar de hacer fallar toda la respuesta. + +> **Note** La capa de experiencia tiene un capítulo propio — +> [La capa de experiencia](./20a-experience-tier.md)— que cubre en profundidad +> `SignalService`, `WorkflowState`, el fan-out concurrente (`Mono::zip_with`) y los +> handlers de degradación parcial. Esta sección es la introducción; el tratamiento +> completo vive allí. + +> **Design note.** El límite de capa es estricto: la dirección de dependencia es +> `channel → experience → domain → core`. Un servicio de experiencia *nunca* posee una +> base de datos, *nunca* llama a un servicio core directamente y *nunca* llama a un +> servicio de experiencia hermano: solo compone SDKs de *dominio*. Lumen es un servicio +> de estilo dominio/core construido sobre la fachada `firefly`; el BFF es un crate +> aparte que depende de `firefly-starter-experience` y del cliente publicado de Lumen. +> Esa separación es la razón por la que el starter de experiencia *no* va incluido en +> la fachada de dependencia única: un servicio de dominio no lo necesita. + +## Otros protocolos + +Más allá de REST, el crate incluye builders y andamiajes para los protocolos que +necesita una plataforma de back-office, seleccionados por feature para que las +dependencias pesadas no entren en servicios que no las usan: + +- `GraphQlBuilder` / `GraphQlClient`: hacen POST de un `{ query, variables?, + operationName? }`, lanzan `ClientError::GraphQl` ante un array `errors` no vacío y + decodifican `data` en un `T` tipado. Siempre disponibles (sin dependencias extra). +- `SoapBuilder` / `SoapClient`: envuelven un cuerpo en un sobre SOAP 1.1, hacen POST de + `text/xml` con una cabecera `SOAPAction` opcional y devuelven el XML crudo de la + respuesta. Siempre disponibles. +- `GrpcBuilder`: construye un canal `tonic` para un stub generado que proporciona el + llamante. Tras la feature `grpc` (`grpc-tls` para TLS). +- `WsBuilder` / `WsClient`: conectan y hacen streaming sobre `tokio-tungstenite`. Tras + la feature `websocket`. + +Las superficies REST, GraphQL y SOAP están totalmente cableadas; los protocolos de +streaming (gRPC y WebSocket) están tras feature gate. Igual que con los clientes HTTP, +cada llamada saliente hereda automáticamente el id de correlación del llamante, así que +una petición que se ramifica hacia tres upstreams se cose entre sí en tus trazas. + +## Resumen — qué cambió en Lumen + +| Antes | Después de este capítulo | +|--------|--------------------| +| cada tramo de transferencia es un `ledger.deposit(...)` local | el tramo de abono puede convertirse en un `payments.request(...)` saliente y resiliente a través de la red | +| sin modos de fallo salientes | los problemas RFC 9457 upstream se decodifican en un `FireflyError` tipado, clasificado por predicados `is_*` | +| sin idempotencia de red | una `Idempotency-Key` estable reenviada de forma consistente a lo largo de los reintentos | +| — | el `WebClient` reactivo (`body_to_mono` / `body_to_flux` / `exchange`), con reintentos *compuestos* vía `Mono::retry_backoff`, no integrados | +| — | traits declarativos `#[http_client]` autowired como beans `Arc`, más una llamada protegida por `CircuitBreaker` | + +Ahora también sabes: + +- Que el `RestClient` eager, el `WebClient` reactivo y el `#[http_client]` + declarativo comparten un mismo conjunto de automatismos: cabeceras por defecto, + propagación de correlación / traza y decodificación de problemas RFC 9457. +- Por qué el `WebClient` no tiene presupuesto de reintentos: la política de reintentos + es una propiedad del punto de llamada, expresada con `Mono::retry` / + `Mono::retry_backoff`. +- Que un cliente declarativo refleja un `#[rest_controller]` (misma sintaxis de ruta + `:id`, mismos atributos de verbo), y que `bean` lo ata al grafo de DI. +- El patrón de capa de experiencia (BFF) y su estricto límite `channel → experience → + domain → core`, cuyo tratamiento completo vive en + [La capa de experiencia](./20a-experience-tier.md). + +## Ejercicios + +1. **Decodificar un problema upstream.** Levanta un stub que responda a + `POST /payments` con un cuerpo `422 application/problem+json`. Llámalo a través de + un `RestClient` y comprueba que `err.as_firefly()` devuelve `Some`, que + `fe.status == 422` y que `err.is_unprocessable_entity()` es `true`, de modo que la + saga pueda mapear el rechazo upstream sobre el propio `422` de Lumen en lugar de un + `500`. + +2. **Componer un reintento.** Envuelve una llamada de `WebClient` a un stub + inestable en `Mono::retry_backoff(|| …, Backoff::new(3, Duration::from_millis(50)))`. + Haz que el stub falle dos veces y luego tenga éxito, y comprueba que la llamada se + resuelve finalmente; después confirma que el `WebClient` desnudo (sin envoltorio) se + rinde tras un único intento. + +3. **Envolver el tramo de abono en un circuit breaker.** Toma el paso de abono de la + saga de transferencia y reemplaza `ledger.deposit(...)` por un + `payments.request(...)` protegido por `CircuitBreaker`. Lleva al stub a fallar + suficientes veces para disparar el breaker, y comprueba que la siguiente llamada + devuelve `ResilienceError::CircuitOpen` *de inmediato* (sin timeout), y que la saga + aún compensa el débito. + +4. **Escribir un cliente declarativo.** Define un trait + `#[http_client(path = "/api/v1/orders")]` con un método + `get_order(&self, id: String) -> Result`, constrúyelo con + `::new("http://localhost:PORT")` contra un stub local, y comprueba que emite + `GET /api/v1/orders/42`. Luego cambia la ruta para usar la `{id}` de Spring y + confirma que la compilación falla con la pista de `:id`. + +5. **Componer un resumen de BFF.** Construye un `ExperienceStack` diminuto, registra un + cliente `wallets` y otro `payments` contra dos stubs locales, y escribe un handler + que obtenga el saldo y la lista de pendientes de forma concurrente. Haz que el stub + de payments devuelva un error y comprueba que el handler aún devuelve el saldo con + una lista de pendientes vacía, demostrando degradación parcial en vez de un `500`. + +## Adónde ir después + +- Asegura el lado *entrante* —autenticación bearer JWT y RBAC basado en rutas sobre las + rutas mutadoras de Lumen— en **[Seguridad](./14-security.md)**. +- Profundiza en componer servicios de dominio en una API con forma de recorrido en + **[La capa de experiencia](./20a-experience-tier.md)**. +- Revisita los decoradores de resiliencia (circuit breaker, rate limiter, bulkhead, + timeout) aplicados al trabajo entrante en **[Caché](./17-caching.md)**. diff --git a/docs/book/src-es/14-security.md b/docs/book/src-es/14-security.md new file mode 100644 index 00000000..f4ead77e --- /dev/null +++ b/docs/book/src-es/14-security.md @@ -0,0 +1,868 @@ +# Seguridad + +En [Clientes HTTP](./13-http-clients.md) viste cómo Lumen *llamaría* a un +proveedor externo de pagos o de divisas. Sin embargo, Lumen sigue estando +abierto de par en par: cualquier llamante puede abrir un monedero, ingresar, +retirar o mover dinero entre monederos. Antes de que la Parte V pueda llevar +Lumen a producción, tienes que cerrar esa puerta, y lo harás sin añadir ninguna +dependencia, sin escribir criptografía a mano y sin reescribir un solo handler. + +Al terminar este capítulo, Lumen **autenticará** cada petición con un JWT +firmado, **autorizará** las rutas mutadoras con una cadena de filtros RBAC +basada en rutas, y dejará abiertas las lecturas públicas y la superficie de +gestión. Todo ello se construye sobre la capa de seguridad del framework, a la +que se llega a través de la única fachada `firefly` de la que dependes desde el +[Arranque rápido](./02-quickstart.md). + +Al terminar este capítulo, serás capaz de: + +- Emitir y verificar tokens HS256 firmados con `JwtService`, y entender por qué + cada token emitido lleva un `exp` acotado. +- Adaptar ese servicio a un `Verifier` y convertir el bearer token de una + petición en una `Authentication` (principal, roles, claims). +- Componer un `BearerLayer` y una `FilterChain` RBAC ordenada por rutas, y + entender el orden fail-closed (denegar por defecto). +- Cablear ambos como `#[bean]`s y ver cómo `FireflyApplication` los autodescubre + y los aplica como capas, sin ninguna llamada a `.with_security(...)`. +- Empujar la autorización hasta un método de servicio con + `#[firefly::pre_authorize]` / `#[firefly::post_authorize]` sobre un contexto + de seguridad ambiental. +- Mover esa misma postura a la configuración para que producción intercambie la + clave de demostración por un IdP real sin tocar el código. + +## Conceptos que conocerás + +Antes de la primera línea de código, aquí tienes las ideas en las que se apoya +este capítulo. Cada una se reintroduce en contexto donde se usa por primera vez; +esta es la versión breve. + +> **Note** **Término clave — autenticación frente a autorización.** La +> *autenticación* responde a "¿quién es este llamante?": valida una credencial y +> resuelve un principal. La *autorización* responde a "¿puede hacer esto?": +> comprueba que el principal resuelto tiene permiso para realizar la operación. +> Son dos etapas distintas, y Firefly las mantiene en dos componentes distintos. + +> **Note** **Término clave — JWT (JSON Web Token).** Un *JWT* es un token +> compacto, URL-safe y firmado que transporta una carga útil JSON de *claims* +> (`sub`, `roles`, `exp`, …). Como la firma demuestra que la carga útil no fue +> manipulada, un servicio sin estado puede confiar en los claims de un token sin +> una sesión del lado del servidor ni un viaje de ida y vuelta a una base de +> datos. El análogo en Spring es un servidor de recursos de Spring Security que +> valida un token `Bearer`. + +> **Note** **Término clave — RBAC (Role-Based Access Control).** El *RBAC* +> concede acceso según los *roles* que ostenta un llamante (aquí `CUSTOMER`) en +> lugar de según su identidad. Una regla dice "esta ruta requiere el rol X"; un +> llamante pasa si su token lleva X. Es el modelo que usan las reglas de +> autorización por URL de Spring Security. + +> **Note** **Término clave — servidor de recursos.** Un *servidor de recursos* +> es un servicio que protege sus propios endpoints validando un token de acceso +> emitido en otro lugar (un proveedor de identidad). Nunca autentica a nadie; +> solo *verifica* la credencial que se le entrega. Lumen es un servidor de +> recursos: en la demostración también emite sus propios tokens para pruebas, +> pero la ruta de verificación es idéntica a la de un IdP de producción. + +La capa de seguridad del framework lleva mucho más de lo que usa Lumen: JWKS, +OAuth2 (cliente + servidor de autorización), jerarquía de roles, guardas de +métodos, CSRF y codificadores de contraseñas. Haremos un recorrido por todo eso +al final; primero, las cuatro piezas que Lumen realmente cablea, en el orden en +que una petición se las encuentra. + +## La canalización de la petición de un vistazo + +Cada petición recorre dos etapas de seguridad antes de llegar a un handler: + +```text + incoming request + │ + ▼ + ┌───────────────────────────────────────┐ + │ BearerLayer │ (authentication) + │ • reads Authorization: Bearer │ + │ • calls the Verifier (JwtService) │ + │ • stores Authentication on the request│ + │ • allow_anonymous → pass empty ctx │ + └───────────────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────────────┐ + │ FilterChain (RBAC) │ (authorization) + │ permit_method("GET", "/api/v1/...") │ + │ permit("/actuator/") │ + │ require("/api/v1/wallets", CUSTOMER) │ + │ 401 / 403 problem+json on a miss │ + └───────────────────────────────────────┘ + │ + ▼ + WalletApi handlers +``` + +El `BearerLayer` autentica (¿quién?), la `FilterChain` autoriza (¿puede?), y +solo una petición que supera ambas alcanza los handlers de Lumen. Los fallos de +autenticación/autorización se renderizan como `application/problem+json` según +la RFC 9457 sobre un 401 o un 403: un contrato de cable estable y basado en +estándares que los clientes y pasarelas comerciales ya entienden. + +> **Note** **Término clave — problem+json de la RFC 9457.** La RFC 9457 (que +> deja obsoleta la RFC 7807) estandariza cuerpos de error legibles por máquina +> bajo el tipo de medio `application/problem+json`: un objeto JSON con los +> miembros `type`, `title`, `status` y `detail`. Firefly renderiza así cada +> rechazo de seguridad, de modo que un 401 o un 403 es un documento +> estructurado, no un cuerpo en blanco. Conociste este renderizador en [Tu +> primera API HTTP](./06-first-http-api.md); aquí también transporta los fallos +> de autenticación/autorización. + +## Paso 1 — Emitir y verificar tokens con `JwtService` + +Lumen es una API sin estado. Las sesiones requerirían enrutamiento adherente +(sticky) o un almacén compartido en cada réplica; un JWT firmado permite que +cada petición transporte su propia credencial, de modo que el servicio escala +horizontalmente sin estado compartido. El `JwtService` del framework tanto +**emite** los tokens de demostración como **verifica** los entrantes, usando una +clave simétrica HS256, que es exactamente lo que hace que Lumen sea ejecutable y +testeable sin un IdP externo. + +> **Note** **Término clave — firma simétrica (HS256).** HS256 firma y verifica +> con el *mismo* secreto compartido. Es el esquema más sencillo de operar (una +> clave, sin servidor de claves) y el adecuado para un ejemplo autocontenido. +> (Un despliegue de producción suele pasar a RS256 *asimétrico*, donde un IdP +> firma con una clave privada y tu servicio verifica con la clave pública +> correspondiente obtenida desde un endpoint JWKS. El Paso 7 muestra ese +> intercambio.) + +Crea `src/security.rs` y empieza con la clave de firma, la constante de rol y el +servicio compartido: + +```rust,ignore +// src/security.rs +use firefly::security::{ + BearerConfig, BearerLayer, FilterChain, JwtService, SecurityError, Verifier, VerifierFn, +}; +use serde_json::json; + +/// The demo signing key. A real service reads this from configuration / a +/// secret store; it is inlined here so the sample is runnable as-is. +pub const DEMO_SIGNING_KEY: &[u8] = b"lumen-demo-signing-key-change-me"; + +/// The role every mutating wallet command requires. +pub const CUSTOMER_ROLE: &str = "CUSTOMER"; + +/// The shared HS256 service that both signs the demo tokens and verifies +/// incoming bearer tokens. +fn jwt_service() -> JwtService { + JwtService::new(DEMO_SIGNING_KEY) +} + +/// Mints a signed HS256 access token for `subject` with `roles`, valid for the +/// service's default lifetime (one hour). +pub fn mint_token(subject: &str, roles: &[&str]) -> String { + jwt_service() + .encode(json!({ "sub": subject, "roles": roles })) + .expect("mint_token: HS256 encode") +} +``` + +Qué acaba de pasar, bloque a bloque: + +- La línea `use` extrae toda la superficie de tokens de `firefly::security`: la + fachada reexporta el crate de seguridad del framework, así que no hay ninguna + dependencia nueva que añadir a `Cargo.toml`. +- `JwtService::new(secret)` construye un servicio HS256 sobre el secreto. La + construcción acepta cualquier `AsRef<[u8]>`, así que la clave en línea (una + cadena de bytes) funciona directamente. +- `encode` firma una carga útil JSON y —este es el detalle de peso— + **inyecta un claim `exp`** cuando la carga útil no lo tiene, con un valor por + defecto de una hora en el futuro (`DEFAULT_EXPIRATION_SECONDS = 3600`). Cada + token que Lumen emite tiene, por tanto, una vida útil acotada. Un token + emitido *sin* `exp` (uno que nunca expiraría) se rechaza en el momento del + decode, porque `decode` lista `exp` como claim obligatorio. +- `mint_token` es el helper que llaman las pruebas HTTP para obtener una + credencial que el verificador aceptará. El `.expect(...)` es seguro aquí: + firmar una forma de claim fija y bien formada no puede fallar. + +> **Tip** **Punto de control.** Una rápida prueba mental en seco: +> `mint_token("u-alice", &["CUSTOMER"])` devuelve una cadena de tres segmentos +> `header.payload.signature`. Decodificar su segmento intermedio (base64) +> mostraría `sub`, `roles` y un `exp` autoestampado aproximadamente una hora en +> el futuro. + +## Paso 2 — Convertir el servicio en un `Verifier` + +`JwtService` ya puede verificar: implementa directamente el trait `Verifier`. +Pero Lumen lo envuelve en un pequeño adaptador para que la *forma del error* sea +exactamente la que el `BearerLayer` quiere renderizar. + +> **Note** **Término clave — `Verifier` (el puerto de autenticación).** Un +> `Verifier` es el *puerto* del servidor de recursos: dado un token en bruto, lo +> valida y devuelve una `Authentication` (el principal, el nombre de usuario, +> los roles y los claims en bruto), o un `SecurityError` en caso de fallo. Es un +> trait, de modo que cualquier validador de tokens —el servicio HS256 de +> demostración, un verificador JWKS, tu propio closure— satisface el mismo +> contrato. `VerifierFn` adapta un simple closure asíncrono a uno de ellos. + +Añade el constructor del verificador: + +```rust,ignore +/// Builds the resource-server Verifier: validates the token's HS256 +/// signature + expiry, then maps `sub` → principal and `roles` → roles onto an +/// Authentication. A bad signature / expired token surfaces as a +/// SecurityError::Verification, which the BearerLayer renders as a +/// `401 application/problem+json`. +pub fn build_verifier() -> impl Verifier { + VerifierFn(|token: String| async move { + jwt_service() + .to_authentication(&token) + .map_err(|e: SecurityError| SecurityError::verification(format!("invalid token: {e}"))) + }) +} +``` + +Qué acaba de pasar: `VerifierFn(closure)` envuelve un simple closure `async` +como un `Verifier`. El closure delega en `JwtService::to_authentication`, que +decodifica el token y mapea sus claims sobre una `Authentication`: `sub` se +convierte en el principal, el array `roles` se convierte en los roles y se +conserva cada claim decodificado. Cualquier fallo (firma incorrecta, expirado, +`exp` ausente) se reenvuelve como `SecurityError::Verification(..)`; el +`BearerLayer` lo convierte en el problem 401 canónico. + +> **Note** **Término clave — `Authentication`.** `Authentication` es el llamante +> resuelto que inspecciona el resto de la pila. Es el análogo en Rust del objeto +> `Authentication` de Spring Security. Sus campos: +> +> | Field | Type | From the claim | +> |-------------|---------------------------------------|-------------------------------| +> | `principal` | `String` | `sub` | +> | `username` | `String` | `preferred_username` / `name`, else `sub` | +> | `roles` | `Vec` | `roles` | +> | `authorities` | `Vec` | `permissions` (and OAuth2 scopes) | +> | `claims` | `HashMap` | every decoded claim | +> +> Sus helpers cubren las comprobaciones habituales: `has_role(r)`, +> `has_any_role(&[..])`, `has_authority(a)` (que casa con un rol *o* con un +> permiso/scope de grano fino), `has_any_authority(&[..])`, y el constructor +> `Authentication::anonymous()`. + +Una prueba unitaria afirma el viaje de ida y vuelta directamente: emite un +token, lo verifica y confirma que el principal y el rol sobrevivieron: + +```rust,ignore +#[tokio::test] +async fn mint_then_verify_roundtrips_claims() { + use firefly::security::Authentication; + let token = mint_token("u-alice", &[CUSTOMER_ROLE]); + let auth: Authentication = build_verifier().verify(&token).await.unwrap(); + assert_eq!(auth.principal, "u-alice"); + assert!(auth.has_role(CUSTOMER_ROLE)); +} +``` + +Un token manipulado (`"not.a.jwt"`) o uno firmado con la clave incorrecta se +rechaza con `SecurityError::Verification`: dos pruebas negativas en `security.rs` +lo demuestran: + +```rust,ignore +#[tokio::test] +async fn tampered_token_is_rejected() { + let err = build_verifier().verify("not.a.jwt").await.unwrap_err(); + assert!(matches!(err, SecurityError::Verification(_))); +} +``` + +> **Tip** **Punto de control.** Ejecuta `cargo test mint_then_verify` (o el +> módulo `security` completo). La prueba de ida y vuelta pasa, y las dos pruebas +> de rechazo confirman que una credencial incorrecta nunca se resuelve a una +> `Authentication`. La autenticación ya funciona de extremo a extremo, antes de +> cualquier cableado HTTP. + +## Paso 3 — Componer el `BearerLayer` y la `FilterChain` RBAC + +`JwtService` responde a *¿quién es este llamante?*; la `FilterChain` responde a +*¿puede hacer esto?* La cadena casa las rutas de la petición con reglas en orden +de declaración —**gana la primera coincidencia**— y renderiza un 401 (sin +credencial o con credencial inválida) o un 403 (autenticado pero con privilegios +insuficientes). Lumen compone la capa bearer y la cadena en una sola función. + +> **Note** **Término clave — `BearerLayer`.** El `BearerLayer` es el middleware +> tower que realiza la autenticación en el cable: lee la cabecera +> `Authorization: Bearer `, llama al `Verifier` y almacena la +> `Authentication` resultante en la petición antes de que se ejecute la cadena. +> Es el análogo en Rust del filtro de autenticación por bearer token de Spring +> Security. + +> **Note** **Término clave — `FilterChain`.** La `FilterChain` es el matcher de +> autorización basado en rutas, el análogo en Rust de las reglas de autorización +> por URL de Spring Security (`authorizeHttpRequests`). La construyes con +> llamadas `permit` / `require` / `permit_method`; cada una añade una regla +> ordenada. + +Añade la función de composición: + +```rust,ignore +/// Builds the BearerLayer + FilterChain that protect the service. +/// +/// | Route | Rule | +/// |------------------------------------------------|-----------------------| +/// | `GET /api/v1/wallets/:id` | permit (public read) | +/// | `GET /actuator/*` | permit (management) | +/// | `POST /api/v1/wallets` | require `CUSTOMER` | +/// | `POST /api/v1/wallets/:id/deposit` / `withdraw` | require `CUSTOMER` | +/// | `POST /api/v1/transfers` | require `CUSTOMER` | +pub fn security_layers() -> (BearerLayer, FilterChain) { + // `allow_anonymous` lets an unauthenticated request reach the chain; the + // chain (not the bearer layer) then decides — a 401 on a `require` route + // without a valid token, a pass on a permitted route. + let bearer = BearerLayer::new(BearerConfig::new(build_verifier()).allow_anonymous(true)); + let chain = FilterChain::new() + .permit_method("GET", "/api/v1/wallets") + .permit("/actuator/") + .require("/api/v1/wallets", &[CUSTOMER_ROLE]) + .require("/api/v1/transfers", &[CUSTOMER_ROLE]) + .any_request_permit(); + (bearer, chain) +} +``` + +Dos decisiones de diseño merecen detenerse en ellas, porque deciden quién recibe +un 401 frente a quién se cuela: + +- **`allow_anonymous(true)` en la capa bearer.** Con ese ajuste, una petición + sin cabecera `Authorization` *no* se rechaza en la capa bearer: llega a la + cadena llevando una `Authentication` anónima. Eso mantiene un único tomador de + decisiones: la `FilterChain` decide en cada ruta. Un `GET` público pasa; una + ruta `require` sin token válido se convierte en un 401. Sin `allow_anonymous`, + la capa bearer rechazaría el tráfico anónimo *antes* de que la cadena pudiera + permitir las lecturas públicas, así que la lectura pública de monedero se + rompería. +- **El orden importa.** `permit_method("GET", "/api/v1/wallets")` y + `permit("/actuator/")` van *primero*, de modo que las lecturas públicas y la + superficie de gestión se deciden antes de que el `require("/api/v1/wallets", + ...)` más amplio pudiera capturarlas. Gana la primera coincidencia, así que un + permit más específico debe preceder a un require más amplio. + `any_request_permit()` reabre entonces la cola sin coincidencias (consulta la + advertencia de abajo). + +> **Warning** En cuanto se declara cualquier regla, una `FilterChain` es +> **fail-closed**: una petición que no casa con ninguna regla se rechaza con un +> 403 (denegar por defecto, igual que Spring Security 6). Reabre la cola sin +> coincidencias explícitamente con `any_request_permit()` / +> `any_request_authenticated()` / `any_request_deny()`. Una cadena *sin* reglas +> en absoluto es un no-op y deja pasar todo, de modo que una cadena vacía nunca +> es un bloqueo total sorpresa, pero en el momento en que añades tu primera +> regla, todo lo que no nombraste queda denegado a menos que un catch-all lo +> reabra. + +> **Tip** **Punto de control.** Recorre a mano cada ruta a través de la lista de +> reglas: `GET /api/v1/wallets/w-1` toca el primer `permit_method` y pasa; `GET +> /actuator/health` toca `permit("/actuator/")` y pasa; `POST /api/v1/wallets` +> cae más allá de ambos permits hasta `require("/api/v1/wallets", [CUSTOMER])`; +> una ruta sin coincidencia como `GET /favicon.ico` alcanza +> `any_request_permit()` y pasa. Si reordenaras mentalmente los requires por +> encima de los permits, la lectura pública exigiría ahora un token: esa es la +> trampa de "gana la primera coincidencia" en acción. + +## Paso 4 — Cablear las capas como beans + +Lumen **no** aplica las capas de seguridad a mano. La `FilterChain` y el +`BearerLayer` se declaran cada uno como un `#[bean]` en `LumenBeans` —el +contenedor `#[derive(Configuration)]` de `src/web.rs` que has ido haciendo +crecer desde el capítulo de DI—, y `FireflyApplication` los autodescubre y los +aplica. Este es el análogo en Rust del bean `SecurityFilterChain` de Spring: +declarar el bean *es* el cableado. + +> **Note** **Término clave — la seguridad como beans descubiertos.** En Spring +> Boot registras un `@Bean` `SecurityFilterChain` y el framework lo aplica; +> nunca llamas a un método `with_security(...)`. Firefly funciona igual: un bean +> `FilterChain` y un bean `BearerLayer` se autodescubren en el arranque y se +> aplican como capas sobre el router. No hay ninguna llamada a +> `.with_security(...)` ni un `.layer(bearer)` manual en el código de la app. + +Añade los dos métodos bean al bloque existente `#[bean] impl LumenBeans`: + +```rust,ignore +// samples/lumen/src/web.rs — inside #[bean] impl LumenBeans { ... } +use firefly::security::{BearerLayer, FilterChain}; + +/// The HTTP security filter chain (path-based RBAC) — the Spring +/// `SecurityFilterChain` bean. `FireflyApplication` auto-discovers + applies it. +#[bean] +fn security_filter_chain(&self) -> FilterChain { + crate::security::security_layers().1 +} + +/// The bearer-token authentication layer — auto-discovered + layered onto +/// the API by `FireflyApplication`. +#[bean] +fn bearer_layer(&self) -> BearerLayer { + crate::security::security_layers().0 +} +``` + +Qué acaba de pasar, y qué hace el framework con ello en el arranque: + +- Cada método `#[bean]` declara un componente para que el contenedor lo + construya. `security_layers()` devuelve la tupla `(BearerLayer, FilterChain)`; + un bean devuelve `.0` y el otro `.1`. +- En el arranque, `run()` resuelve el bean `FilterChain` y lo asigna en la pila + web, luego resuelve el bean `BearerLayer` y lo aplica como capa alrededor de + todo el router para que la cadena siempre vea una `Authentication` poblada. +- La cadena se ejecuta *dentro* del borde heredado de correlación / + security-headers / CORS, de modo que incluso una respuesta 401 lleva esas + cabeceras y un identificador de correlación. La capa bearer va por *fuera*: + axum ejecuta primero la última capa añadida, así que el orden es **autenticar + y luego autorizar**. + +Declarar los dos beans es el cableado *completo*: nada de `with_security`, +ninguna llamada a `apply_middleware`, ninguna edición en `main`. Esta es la +propiedad de "sin cambios en `main`" del [Arranque rápido](./02-quickstart.md) +en acción: la seguridad no es más que más beans para que el framework los +descubra. + +> **Tip** **Punto de control.** Ejecuta `cargo run` y lee la línea +> `:: beans ::` del informe de arranque: `security_filter_chain` y +> `bearer_layer` aparecen ahora en el inventario de beans descubiertos. Luego +> `curl -i -X POST localhost:8080/api/v1/wallets +> -H 'content-type: application/json' -d '{"owner":"mallory","openingBalance":10}'`: +> obtienes un `401` con `content-type: application/problem+json`, porque la +> mutación requiere ahora un token `CUSTOMER`. La lectura pública sigue +> funcionando: `curl localhost:8080/api/v1/wallets/anything` ya no se rechaza por +> falta de token (devuelve un 404 por un id desconocido, que es un resultado +> distinto, a nivel de negocio). + +## Paso 5 — Empujar la autorización hasta un método + +La `FilterChain` protege *rutas*. Pero la autorización suele ser una propiedad de +un *método de servicio*: una operación de dominio que llaman varios handlers, un +trabajo programado y un handler CQRS. Empujar la comprobación hasta el método +significa que se mantiene sin importar cómo se alcance la operación, y la tabla +de rutas queda centrada en las rutas. + +Firefly hace esto con dos macros de atributo y un **contexto de seguridad +ambiental**. Las macros declaran la regla; el contexto transporta la +`Authentication` del llamante a través de la pila de llamadas, de modo que el +método nunca tiene que pasar un argumento ni tocar la `Request`. + +> **Note** **Término clave — contexto de seguridad ambiental.** El *contexto +> ambiental* es una ranura task-local que contiene la `Authentication` actual, +> el análogo en Rust del `SecurityContextHolder` de Spring y su thread-local. El +> `BearerLayer` lo instala durante la duración de cada petición, de modo que +> cualquier método alcanzado aguas abajo puede leer al llamante sin que este +> viaje en cada firma de función. Como la ranura es task-local, anida +> limpiamente y nunca se filtra entre tareas lanzadas (spawned). + +### `#[firefly::pre_authorize(...)]` + +`#[firefly::pre_authorize(...)]` protege una función *antes* de que se ejecute su +cuerpo. Se adjunta a cualquier función que devuelva `Result` donde `E: +From`, y lee la `Authentication` ambiental para +decidir. Las reglas: + +| Rule | Passes when | +|-------------------------------|-----------------------------------------------------| +| *(empty)* / `authenticated` | a real (non-anonymous) caller is in scope (default) | +| `role = "ADMIN"` | the caller has role `ADMIN` | +| `any_role = ["A", "B"]` | the caller has *any* of the listed roles | +| `authority = "wallet:write"` | the caller holds that authority (role or scope) | +| `any_authority = ["a", "b"]` | the caller holds *any* of the listed authorities | + +En caso de denegación, la macro retorna de forma temprana con `Err(..)`: un +`SecurityError` `Unauthenticated` cuando no hay ningún llamante en el ámbito, y +uno `Forbidden` cuando hay un llamante presente pero las autoridades no coinciden. +El `?` dentro del código generado propaga ese error a través de tu impl +`From`. + +```rust,ignore +use firefly_security::SecurityError; + +/// Only a CUSTOMER may withdraw. The check runs before any balance logic. +#[firefly::pre_authorize(role = "CUSTOMER")] +pub async fn withdraw(wallet: WalletId, amount: Money) -> Result +where + WalletError: From, +{ + // ... domain logic; reached only for an authenticated CUSTOMER ... +} + +/// A coarse "must be logged in" gate — the empty form is `authenticated`. +#[firefly::pre_authorize] +pub fn current_balance(wallet: WalletId) -> Result { + // ... +} + +/// A fine-grained scope check rather than a role. +#[firefly::pre_authorize(authority = "wallet:approve")] +pub async fn approve(wallet: WalletId) -> Result<(), WalletError> { + // ... +} +``` + +### `#[firefly::post_authorize()]` + +A veces solo puedes decidir *después* de tener el valor: "puedes leer este +monedero solo si es tuyo". `#[firefly::post_authorize(...)]` se adjunta a una +`async fn` que devuelve `Result` y evalúa una expresión booleana una vez +que el cuerpo ha producido su `Ok(T)`. La expresión ve dos enlaces (bindings): + +- `result`: un `&T`, el valor que la función está a punto de devolver (el + *return object* de Spring). +- `auth`: un `&Authentication`, el llamante ambiental. + +Si la expresión es `false`, el valor se **descarta** y la llamada se resuelve a +un error `Forbidden` en su lugar; si no hay ningún contexto activo en absoluto, +se resuelve a `Unauthenticated`: + +```rust,ignore +/// A caller may fetch a wallet only if they own it. +#[firefly::post_authorize(result.owner == auth.principal)] +pub async fn get_wallet(id: WalletId) -> Result { + repo().load(id).await // produces Ok(Wallet); the rule then vets the owner +} +``` + +### Las funciones del contexto ambiental + +Ambas macros leen la `Authentication` ambiental en lugar de un argumento. Ese +ámbito lo gestiona un pequeño conjunto de funciones en `firefly_security`, al que +se llega a través de la fachada como `firefly::security`: + +```rust,ignore +use firefly::security::{ + with_authentication_scope, current_authentication, check_access, + AccessRule, Authentication, SecurityError, +}; + +// Run `fut` with `auth` installed as the ambient caller for its whole duration. +let wallet = with_authentication_scope(auth, async { + withdraw(id, amount).await // #[pre_authorize] inside sees `auth` +}).await?; + +// Read the current caller anywhere downstream (None if no scope is active). +let who: Option = current_authentication(); + +// Imperative check when a macro doesn't fit — returns the Authentication on +// success, a SecurityError on failure. +let auth: Authentication = check_access(&AccessRule::Role("CUSTOMER"))?; +``` + +`AccessRule` es la forma en tiempo de ejecución de las reglas de las macros: +`AccessRule::Authenticated`, `Role(&str)`, `AnyRole(&[&str])`, `Authority(&str)` +y `AnyAuthority(&[&str])`. + +La recompensa es que **el `BearerLayer` instala el ámbito por ti**. En cada +petición —tanto la ruta verificada *como* la ruta anónima (`allow_anonymous`)— la +capa bearer envuelve la llamada aguas abajo en `with_authentication_scope`, de +modo que un método de servicio decorado con `#[pre_authorize]` funciona +correctamente aunque nunca vea la `Request`. Las reglas de URL y las reglas de +método se componen entonces: la `FilterChain` es tu perímetro grueso, las macros +de método son tu defensa en profundidad. + +> **Tip** **Punto de control.** El invariante clave que debes tener en mente: un +> método con `#[pre_authorize]` llamado *fuera* de cualquier ámbito (por ejemplo, +> directamente desde un simple `#[test]` sin `with_authentication_scope_sync`) +> devuelve `Unauthenticated`: la macro falla en cerrado cuando no hay ningún +> llamante, exactamente igual que la cadena de rutas. + +## Paso 6 — Demostrarlo de extremo a extremo sobre HTTP + +La suite HTTP (`tests/http.rs`, en el ejemplo en `src/http_test.rs`) dirige el +router completamente cableado con `tower::ServiceExt::oneshot` y afirma el +comportamiento de seguridad directamente, sin enlazar ningún socket. El router +viene de `build_router`, que arranca la misma app que arranca `main()`: + +```rust,ignore +// The testable in-process public router — every bean (including the +// FilterChain + BearerLayer) is auto-discovered, exactly as in `main`. +#[cfg(test)] +pub(crate) async fn build_router() -> axum::Router { + firefly::FireflyApplication::new(APP_NAME) + .version(VERSION) + .bootstrap() + .await + .expect("lumen bootstrap") + .api_router +} +``` + +> **Note** **Costura de pruebas.** `bootstrap()` es el hermano de `run()` del +> [Arranque rápido](./02-quickstart.md): ensambla la misma app —beans de +> seguridad incluidos— pero devuelve un valor `Bootstrapped` *sin* servir, de +> modo que una prueba puede dirigir el router público cableado +> (`Bootstrapped::api_router`) en proceso. Conociste esto en [Tu primera API +> HTTP](./06-first-http-api.md); aquí permite que la suite ejercite el +> `BearerLayer` + `FilterChain` reales. + +Un helper de peticiones construye la cabecera `Authorization` a partir de +`mint_token`, de modo que una petición autenticada es simplemente +`post(path, body, true)` y una no autenticada es `post(path, body, false)`: + +```rust,ignore +fn bearer() -> String { + format!("Bearer {}", mint_token("u-alice", &[CUSTOMER_ROLE])) +} + +fn post(path: &str, body: serde_json::Value, auth: bool) -> Request { + let mut b = Request::post(path).header("content-type", "application/json"); + if auth { + b = b.header("authorization", bearer()); + } + b.body(Body::from(serde_json::to_vec(&body).unwrap())).unwrap() +} +``` + +Una mutación **sin** token es un problem 401: + +```rust,ignore +#[tokio::test] +async fn missing_token_is_401_problem_on_mutations() { + let app = build_router().await; + let res = send( + &app, + post( + "/api/v1/wallets", + serde_json::json!({ "owner": "mallory", "openingBalance": 10 }), + false, // no Authorization header + ), + ) + .await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert!(content_type(&res).contains("application/problem+json")); +} +``` + +Todas las demás pruebas de la suite —abrir, obtener, ingresar/retirar, +transferir— se ejecutan como el `CUSTOMER` emitido (pasan `true`), de modo que la +autenticación también se ejercita en la ruta feliz. Un 401 demuestra que el +perímetro está cerrado; las pruebas verdes de la ruta feliz demuestran que un +token válido sigue pasando. + +> **Tip** **Punto de control.** Ejecuta `cargo test` para Lumen. La prueba +> `missing_token_is_401` es la prueba rojo-luego-verde de que la puerta de +> entrada está cerrada, y las pruebas de ida y vuelta del monedero confirman que +> un token `CUSTOMER` sigue abriéndola. Si la prueba del 401 falla con un +> `201 Created`, el bean `FilterChain` no se está descubriendo: confirma que +> ambos métodos `#[bean]` compilan dentro del bloque `LumenBeans`. + +## Paso 7 — Mover la postura a la configuración + +Lumen incrusta en línea su clave de firma y construye la capa bearer a mano +porque eso hace que el ejemplo sea ejecutable tal cual. Un servicio desplegado, en +cambio, lee su postura de seguridad desde la configuración, y `firefly_security` +la enlaza directamente: sin contenedor de DI, sin callback del framework. Las +propiedades viven bajo `firefly.security.*` y se enlazan mediante `serde`: + +```rust,ignore +use firefly::security::{ + SecurityProperties, JwtProperties, BearerProperties, + verifier_from_config, bearer_layer_from_config, +}; +``` + +Una postura de servidor de recursos JWKS en `firefly.yaml`: + +```yaml +firefly: + security: + jwt: + jwk-set-uri: "https://idp.example.com/.well-known/jwks.json" + issuer-uri: "https://idp.example.com/" + audience: "lumen" + algorithm: "RS256" + bearer: + header-name: "Authorization" + allow-anonymous: true +``` + +Los structs reflejan esa forma; cada uno deriva `Default` + `Deserialize` con +`#[serde(default)]`, de modo que un campo ausente cae a su valor cero: + +```rust,ignore +pub struct SecurityProperties { + pub jwt: JwtProperties, + pub bearer: BearerProperties, +} + +pub struct JwtProperties { + pub jwk_set_uri: String, + pub issuer_uri: String, + pub audience: String, + pub secret: String, + pub algorithm: String, + pub expiration_seconds: u64, +} + +pub struct BearerProperties { + pub header_name: String, + pub allow_anonymous: bool, +} +``` + +Dos funciones constructoras convierten las propiedades enlazadas en componentes +listos: + +```rust,ignore +use std::sync::Arc; +use firefly::security::{Verifier, BearerLayer, SecurityError}; + +// Pick a verifier by what configuration provides — JWKS first, then HMAC, +// then nothing. +let verifier: Option> = verifier_from_config(&props.jwt)?; + +// The fully-assembled bearer layer (header name + anonymous policy applied), +// or None when no verifier is configured. +let bearer: Option = bearer_layer_from_config(&props)?; +``` + +Qué acaba de pasar: `verifier_from_config(&JwtProperties)` resuelve el +verificador por precedencia: un `jwk_set_uri` no vacío construye un verificador +de servidor de recursos JWKS (RS256); en su defecto, un `secret` no vacío +construye un verificador HMAC (HS256/384/512); en su defecto, devuelve `None`. +`bearer_layer_from_config(&SecurityProperties)` construye el verificador de la +misma manera y, si hay uno, lo envuelve en un `BearerLayer` con el nombre de +cabecera configurado y la política anónima ya aplicada: la misma capa que +`security_layers` construye a mano, pero obtenida desde la configuración en lugar +de a mano. Cambiar Lumen de la clave HMAC de demostración a un IdP de producción +se convierte en un cambio de configuración, sin tocar `security.rs`. + +> **Note** **Término clave — JWKS (JSON Web Key Set).** Un *JWKS* es el conjunto +> de claves públicas que un proveedor de identidad publica en una URL conocida. +> Un servidor de recursos lo obtiene para verificar tokens RS256, indexando por +> el `kid` (id de clave) de cada token y cacheando el resultado. `JwksVerifier` +> es el `Verifier` plug-and-play del framework para esto: el mismo puerto +> `Verifier`, así que `security_layers` —y cada handler— queda intacto cuando +> intercambias el verificador HMAC de demostración por él. Esa es la promesa de +> "intercambia el adaptador, conserva el código" aplicada a la identidad. + +## El resto de la capa de seguridad — el margen de crecimiento de Lumen + +Lumen usa la vía rápida de clave simétrica. El mismo crate lleva la superficie de +producción a la que recurres a medida que madura un servicio de monedero real: + +- **Verificación JWKS.** `JwksVerifier::new("https://idp.example.com/.well-known/jwks.json")` + es un `Verifier` plug-and-play para tokens RS256 de un IdP externo (Keycloak, + Auth0, Cognito): caché de `kid`, comprobaciones de `iss`/`aud` vía + `.issuer(..)` / `.audience(..)`, `exp` obligatorio, y el mismo mapeo de claims + `sub`/`roles`/`permissions`. +- **Guardas de método.** Para comprobaciones imperativas por handler, el módulo + `guards` compone predicados tipados: + `guards::has_role("CUSTOMER").or(guards::has_authority("wallet:approve"))`, + y luego `guard.authorize(Some(&auth))?`: `Unauthenticated` sin principal, + `Forbidden` si el predicado es falso. Para una grafía declarativa, prefiere las + [macros de method-security](#step-5--push-authorization-down-to-a-method) de + arriba. +- **Jerarquía de roles.** `RoleHierarchy::from_string("ADMIN > CUSTOMER")` parsea + la especificación; adjúntala con `chain.with_role_hierarchy(..)` para que + conceder `ADMIN` implique `CUSTOMER` en todas partes donde la cadena comprueba + un rol. +- **Reglas por patrón.** Junto a las reglas por prefijo que usa Lumen, la cadena + ofrece un DSL de globs al estilo fnmatch: `permit_pattern("/public/**")`, + `require_pattern("/api/admin/**", &["ADMIN"])`, + `require_authority("/api/reports/**", &["reports:read"])` y + `authenticated("/api/**")`. +- **Sesiones.** Para flujos de navegador donde cerrar sesión debe significar + cerrar sesión, el crate `firefly-session` añade un `SessionLayer` sobre un + `SessionStore` (`MemorySessionStore` para desarrollo, un almacén respaldado por + Redis para escalar). Un handler obtiene la `Session` de la petición con el + extractor `SessionExt` y llama a `session.rotate_id().await` tras el login + (defensa contra fijación de sesión), `session.set_attribute("user_id", + &id).await`, y `session.invalidate().await` al cerrar sesión. +- **OAuth2.** El módulo `oauth2` cubre ambos lados: `ClientRegistration` (con + presets `google` / `github` / `keycloak`) + `OAuth2LoginHandler` para el flujo + de login con código de autorización (state + nonce + PKCE S256, validación del + id-token OIDC), y un `AuthorizationServer` que emite tokens para + `client_credentials` / `refresh_token`. +- **CSRF y contraseñas.** `CsrfLayer` implementa el patrón double-submit-cookie + para flujos de sesión por cookie; `BcryptPasswordEncoder` (factor de trabajo + por defecto 12) hashea credenciales, y `Argon2PasswordEncoder` (Argon2id, con + los valores por defecto de OWASP vía `new()` —`m=19456` KiB, `t=2`, `p=1`— o + `with_params(m, t, p)`) es la alternativa memory-hard detrás del *mismo* puerto + `PasswordEncoder`. Tanto los hashes bcrypt `$2b$` como las cadenas PHC + autodescriptivas `$argon2id$` son intercambiables con el adaptador + `firefly-idp-internal-db` y con cualquier otro puerto. + +Ambos codificadores comparten un mismo trait, así que son intercambiables: + +```rust +use firefly_security::{Argon2PasswordEncoder, BcryptPasswordEncoder, PasswordEncoder}; + +let enc = BcryptPasswordEncoder::new(); // work factor 12 (the default) +let hash = enc.hash("s3cret").unwrap(); +assert!(enc.verify("s3cret", &hash).unwrap()); +assert!(!enc.verify("wrong", &hash).unwrap()); + +// Argon2id — the OWASP-preferred encoder, same PasswordEncoder port. +let argon = Argon2PasswordEncoder::new(); // OWASP defaults (m=19456, t=2, p=1) +let argon_hash = argon.hash("s3cret").unwrap(); +assert!(argon_hash.starts_with("$argon2id$")); +assert!(argon.verify("s3cret", &argon_hash).unwrap()); +``` + +## Resumen — qué cambió en Lumen + +Este capítulo cerró la puerta de entrada abierta de Lumen sin añadir una +dependencia ni una línea de lógica de negocio a los handlers: + +| Before | After this chapter | +|--------|--------------------| +| any caller could open/deposit/withdraw/transfer | mutating routes require a `CUSTOMER` JWT; reads and `/actuator/*` stay public | +| no token machinery | one HS256 `JwtService` mints and verifies, auto-stamping a one-hour `exp` | +| no authorization | a path-ordered, fail-closed RBAC `FilterChain` plus method-level `#[pre_authorize]` / `#[post_authorize]` | +| — | the `FilterChain` + `BearerLayer` `#[bean]`s, auto-discovered and layered by `FireflyApplication` — no `with_security` call | + +También sabes ahora: + +- Que `JwtService::encode` autoestampa un `exp` de una hora y `decode` rechaza + cualquier token que no lo tenga, de modo que cada credencial está acotada. +- Que `build_verifier` convierte el servicio en un `Verifier` vía `VerifierFn`, + mapeando `sub` → principal y `roles` → roles, y haciendo que un token + incorrecto aflore como `SecurityError::Verification` → un problem 401. +- Que `security_layers` compone un `BearerLayer` (con `allow_anonymous(true)`) y + una `FilterChain` de gana-la-primera-coincidencia, donde el orden de las reglas + decide quién queda permitido. +- Que declarar la cadena y la capa como `#[bean]`s es el cableado *completo*: el + framework asigna la cadena dentro del borde de correlación/cabeceras y aplica + la autenticación bearer por fuera (autenticar y luego autorizar). +- Que la seguridad de método empuja la autorización a las operaciones de dominio + a través de un contexto ambiental que el `BearerLayer` instala, de modo que un + método de servicio aplica la regla sin ver nunca la `Request`. +- Que `verifier_from_config` / `bearer_layer_from_config` mueven toda la postura + a `firefly.security.*`, de modo que la clave de demostración se convierte en un + IdP de producción sin tocar el código. + +## Ejercicios + +1. **Añade una ruta solo para ADMIN.** Dale a Lumen una hipotética lista de + colección `GET /api/v1/wallets` y protégela con + `require_pattern("/api/v1/wallets", &["ADMIN"])` para que solo un `ADMIN` + pueda listar todos los monederos, mientras `CUSTOMER` conserva el acceso a la + lectura de un único monedero. Emite un token `ADMIN` en una prueba y afirma + que un token `CUSTOMER` obtiene un 403. +2. **Jerarquía de roles.** Introduce un rol `SUPER` que implique `CUSTOMER`. + Construye un `RoleHierarchy::from_string("SUPER > CUSTOMER")`, adjúntalo con + `chain.with_role_hierarchy(..)`, emite un token solo con `SUPER`, y afirma que + pasa la regla `require("/api/v1/wallets", &["CUSTOMER"])`. +3. **Intercambia JWKS.** Esboza un `build_verifier_jwks()` que devuelva un + `JwksVerifier::new("https://idp.example.com/.well-known/jwks.json")` y confirma + (leyendo el trait `Verifier`) que `security_layers` no necesita ningún otro + cambio. ¿Por qué al resto de Lumen no le importa qué verificador recibió? +4. **Expiración.** Baja la vida útil del token con + `JwtService::new(KEY).expiration_seconds(1)`, emite un token, espera dos + segundos y afirma que el verificador devuelve ahora + `SecurityError::Verification`. +5. **Seguridad de método en aislamiento.** Decora una función simple con + `#[firefly::pre_authorize(role = "CUSTOMER")]`, llámala desde un `#[test]` + *sin* un ámbito y afirma `Unauthenticated`, luego envuelve la llamada en + `firefly::security::with_authentication_scope_sync(auth, || ...)` con una auth + `CUSTOMER` y afirma que pasa. + +## Adónde ir después + +Un servicio seguro solo es de fiar si puedes *ver* lo que está haciendo. El +siguiente capítulo le da a Lumen ojos y oídos: logs estructurados, salud, +métricas y el panel de administración. + +- Haz Lumen observable en **[Observabilidad](./15-observability.md)**: la + superficie de gestión junto al perímetro de seguridad que acabas de construir. +- Revisita cómo el framework descubre y cablea beans como la `FilterChain` en + **[Cableado de dependencias](./04-dependency-wiring.md)**. +- Dirige el router cableado en pruebas con `bootstrap()` en + **[Pruebas](./18-testing.md)**. diff --git a/docs/book/src-es/15-observability.md b/docs/book/src-es/15-observability.md new file mode 100644 index 00000000..ff5d15e4 --- /dev/null +++ b/docs/book/src-es/15-observability.md @@ -0,0 +1,748 @@ +# Observabilidad + +En [Seguridad](./14-security.md) protegiste las rutas mutadoras de Lumen tras un +JWT y un filtro de roles. El servicio ahora es seguro, pero sigue siendo una caja +negra. Cuando un depósito va lento en producción, necesitas saber *dónde* se fue +el tiempo; cuando el broker se degrada, quieres un panel que se ponga rojo antes +que tu busca; cuando un auditor pregunta por qué se rechazó una transferencia, +necesitas una línea de log estructurada con contexto suficiente para reconstruir +la decisión. + +La buena noticia —y el tema de todo este capítulo— es que casi nada de esto es +código que tengas que escribir. `FireflyApplication::run()` ya instaló la capa de +logging, el composite de salud, el registro de métricas, el middleware de +métricas de petición, la originación de trace-context W3C y un panel `/admin` +autohospedado vinculado a los componentes en vivo. Lumen es observable desde +[Tu primera API HTTP](./06-first-http-api.md); este capítulo te enseña a *leer* +lo que ya está ahí, y a añadir el puñado de piezas opcionales —un info +contributor, una sonda de salud, una métrica de dominio, una vista de panel +personalizada— que solo tu aplicación puede aportar. + +Al terminar este capítulo, serás capaz de: + +- Alcanzar la **superficie de gestión** de Lumen —`/actuator/*` en el puerto de + gestión— y explicar por qué vive en un listener separado de la API pública. +- Registrar un **info contributor** para que `/actuator/info` informe sobre qué + infraestructura está ejecutando esta instancia. +- Añadir un **health indicator** al composite y verlo agregarse en + `/actuator/health`. +- Leer las **métricas de petición** que el framework ya registra, y registrar tu + propio contador y gauge en el mismo registro. +- Comprender el **logging estructurado y enriquecido por correlación** y el + **trace-context W3C**: cómo una petición se hilvana a través de logs, eventos y + llamadas salientes sin enhebrado manual. +- Abrir el **panel de administración autohospedado**, leer sus quince vistas + —incluida la vista **Beans** poblada— y añadir una vista personalizada propia. + +## Conceptos que conocerás + +Cada idea de las que siguen se reintroduce en contexto allí donde se usa por +primera vez; esta es la versión breve para tener el vocabulario en su sitio antes +del primer comando. + +> **Note** **Término clave — superficie de gestión / actuator.** La *superficie +> de gestión* es un conjunto de endpoints HTTP operativos —comprobaciones de +> salud, información de compilación, métricas, introspección de configuración, +> control del nivel de log en ejecución— que existen para operadores y +> herramientas, no para los usuarios finales. Firefly los sirve bajo +> `/actuator/*` en un **puerto separado** de tu API de negocio. Esto refleja +> Spring Boot Actuator. + +> **Note** **Término clave — info contributor.** Un *info contributor* es un +> pequeño callback que añade una sección JSON a `/actuator/info`. Lo registras en +> el builder de la aplicación; el framework lo invoca cuando un operador accede al +> endpoint. El análogo en Spring es un bean `InfoContributor`. + +> **Note** **Término clave — health indicator y composite.** Un *health +> indicator* es una sonda asíncrona que informa `UP` / `DEGRADED` / `DOWN` (con un +> mensaje y detalles opcionales). Un *composite* agrega muchos indicadores en una +> única consolidación y la sirve en `/actuator/health`. Esto es el +> `HealthIndicator` de Spring Boot más su agregador de salud. + +> **Note** **Término clave — id de correlación.** Un *id de correlación* es un +> único identificador adjunto a todo lo que toca una sola petición —cada línea de +> log, cada evento que publica, cada llamada saliente que hace— de modo que puedas +> reconstruir la historia completa a partir de un único valor. Firefly lo +> establece en un ámbito task-local a la entrada; el análogo en Spring es una +> entrada MDC enhebrada a través de una petición. + +## Los dos puertos, y qué sirve cada uno + +Antes del primer endpoint, fija el modelo mental. Lumen ejecuta **dos +listeners**: + +- la **API pública** en `0.0.0.0:8080` —tus rutas de negocio y nada más—; +- la **superficie de gestión** en `0.0.0.0:8081` —`/actuator/*` más el panel + `/admin` autohospedado más la documentación de API autogenerada. + +`FireflyApplication` ensambla y sirve ambos routers; Lumen no escribe ningún +cableado de actuator ni de admin. La separación es la clave: un endpoint +operativo como `/actuator/env` (que puede revelar configuración) o `/admin` (un +panel en vivo) nunca se filtra a la red pública, porque el listener público +sencillamente no monta esas rutas. + +> **Note** **Término clave — sobrescritura de la dirección de bind.** +> `FIREFLY_SERVER_ADDR` y `FIREFLY_MANAGEMENT_ADDR` son las dos variables de +> entorno que mueven los listeners público y de gestión de forma independiente +> (con valores por defecto `0.0.0.0:8080` / `0.0.0.0:8081`). Las conociste en +> [Inicio rápido](./02-quickstart.md); son la forma de poner el puerto de gestión +> en una interfaz privada en producción. + +> **Tip** **Punto de control.** Con Lumen en ejecución (`cargo run --bin lumen`), +> la superficie de gestión responde en el `8081` y la API pública en el `8080`. +> Confirma la separación: `curl localhost:8081/actuator/health` devuelve JSON, +> mientras que `curl localhost:8080/actuator/health` devuelve un documento de +> problema 404 —el actuator no está en el puerto público. + +## Paso 1 — Alcanzar el actuator + +Incluso sin código de observabilidad propio, el actuator está vivo. Arranca +Lumen y, desde una segunda terminal, recorre tres endpoints. + +```bash +curl localhost:8081/actuator/health +# {"status":"UP", ...} + +curl localhost:8081/actuator/info +# {"app":{"name":"lumen","version":"26.6.28"}, ...} + +curl localhost:8081/actuator/metrics +# {"names":[ "http_server_requests_seconds", ... ]} +``` + +Qué acaba de ocurrir: `/actuator/health` agregó cada health indicator registrado +en un único `status`; `/actuator/info` reflejó el nombre de la aplicación que +pasaste a `FireflyApplication::new("lumen")` más la versión del framework; +`/actuator/metrics` listó los medidores que el framework ha estado registrando +desde el arranque —incluido el temporizador de petición por ruta que leerás en el +Paso 4. + +La superficie de gestión completa aparece a continuación. Lumen llega al puerto de +gestión en `http://localhost:8081/actuator/*`: + +| Endpoint | Devuelve | +|--------------------------------|----------------------------------------------------| +| `/actuator/health` | la consolidación del composite (+ sondas de liveness/readiness) | +| `/actuator/info` | metadatos de la app + tus info contributors | +| `/actuator/metrics` | el listado de medidores registrados | +| `/actuator/metrics/:name` | el detalle de un medidor | +| `/actuator/prometheus` | el destino de scrape en exposición de texto Prometheus | +| `/actuator/env` | fuentes de propiedades enmascaradas y atribuidas a su origen | +| `/actuator/scheduledtasks` | descriptores de tareas programadas | +| `/actuator/version` | la versión en ejecución | +| `/actuator/beans` | cada bean de DI (tipo, scope, estereotipo, primary)| +| `/actuator/mappings` | cada ruta `#[rest_controller]` (método/path) | +| `/actuator/conditions` | las guardas condicionales por bean | +| `/actuator/loggers[/:name]` | control del nivel de log en ejecución | +| `/actuator/threaddump` | un volcado de hilos/tareas | +| `/actuator/httpexchanges` | intercambios HTTP recientes (cuando está cableado) | +| `/actuator/caches[/:name]` | listado de cachés + desalojo (cuando está cableado)| +| `/actuator/refresh` | recarga la configuración (el hook `Refresher`) | + +> **Note** Los informes `beans` / `mappings` / `conditions` reflejan la +> introspección de inyección de dependencias de Spring Boot Actuator: se +> autorregistran en el framework junto con el resto, de modo que puedes +> introspeccionar el grafo de objetos cableado por HTTP sin código de aplicación. +> Viste el mismo inventario impreso en el arranque en +> [Inicio rápido](./02-quickstart.md); estos endpoints lo sirven en vivo. + +> **Tip** **Punto de control.** Los tres `curl` de arriba devuelven JSON. Si +> `curl` conecta pero cada path da 404, estás accediendo al `8080` (público) en +> lugar del `8081` (gestión). El puerto público no tiene `/actuator/*`. + +## Paso 2 — Describir esta instancia con un info contributor + +`/actuator/info` ya informa el bloque `app` —nombre y versión—, pero no puede +saber qué *infraestructura* está ejecutando esta instancia concreta. Eso es +conocimiento de la aplicación, así que es la única pieza de código de +observabilidad que Lumen podría añadir. La aportas como un **info contributor** +registrado de forma fluida en el builder de la aplicación. + +> **Note** **Término clave — `InfoContributor`.** El tipo es +> `Box serde_json::Map + Send + Sync>` —un closure +> en una caja que devuelve un objeto JSON. El mapa de cada contributor se +> convierte en una sección de `/actuator/info`. El closure se ejecuta en cada +> petición al endpoint, de modo que puede informar valores en vivo. + +```rust,ignore +use firefly::starter_core::InfoContributor; + +let contributor: InfoContributor = Box::new(|| { + let mut info = serde_json::Map::new(); + info.insert( + "sample".into(), + serde_json::json!({ "name": "lumen", "store": "in-memory", "eventBus": "in-memory" }), + ); + info +}); + +firefly::FireflyApplication::new("lumen") + .info_contributor(contributor) // adds a "sample" section to /actuator/info + .run() + .await +``` + +Qué acaba de ocurrir, bloque a bloque: + +- `InfoContributor` se reexporta a través de la fachada en + `firefly::starter_core::InfoContributor`, de modo que Lumen sigue dependiendo + únicamente del crate `firefly`. +- El closure construye un `serde_json::Map` con una única clave, `sample`, cuyo + valor describe el store y el tipo de event-bus que ejecuta esta instancia. +- `.info_contributor(contributor)` lo registra en el builder. El framework hilvana + cada contributor registrado en el handler de `/actuator/info` cuando construye el + router de gestión: sin llamada a `actuator_router(..)` ni gestión del segundo + listener en tu código. + +Tras esto, `/actuator/info` informa ambos bloques: + +```jsonc +// GET /actuator/info +{ + "app": { "name": "lumen", "version": "26.6.28" }, + "sample": { "name": "lumen", "store": "in-memory", "eventBus": "in-memory" } +} +``` + +El bloque `app` se rellena con `app_name` / `app_version` que Lumen estableció en +[Configuración](./03-configuration.md); el bloque `sample` es el contributor de +arriba. Un operador que acceda a `/actuator/info` ve ahora de un vistazo que esta +instancia está sobre la infraestructura en memoria, no sobre Postgres + Kafka. + +> **Tip** **Punto de control.** Tras añadir el contributor y volver a ejecutar, +> `curl localhost:8081/actuator/info` muestra un objeto `sample` de nivel superior +> que informa `"store":"in-memory"`. Puedes registrar más de un contributor; sus +> mapas se fusionan en el mismo documento JSON. + +## Paso 3 — Añadir un health indicator + +El composite que respalda `/actuator/health` parte de los indicadores propios del +framework. Un despliegue real de Lumen añadiría los suyos —una sonda de liveness +del broker, una comprobación de alcanzabilidad del store— para que un orquestador +pueda distinguir una instancia degradada de una sana. + +> **Note** **Término clave — `IndicatorFn`.** `IndicatorFn::new(name, closure)` +> adapta un simple closure asíncrono a un `Indicator` de salud. El closure +> devuelve un `HealthResult` —`HealthResult::up()`, +> `HealthResult::degraded(msg)` o `HealthResult::down(msg)`, cada uno +> opcionalmente enriquecido con `.with_detail(..)`. El composite consolida los +> resultados: `DOWN` si algún indicador está `DOWN`, si no `DEGRADED` si alguno +> está `DEGRADED`, y en caso contrario `UP`. + +El `Core` del framework ya lleva el `HealthComposite`. Puenteas un indicador hacia +él con `Core::add_observability_indicator(..)`. Hay dos lugares limpios para +hacerlo: declarar el indicador como un `#[bean]` (el framework lo descubre durante +el escaneo de componentes), o alcanzar el composite desde un hook +`FireflyApplication::on_ready` una vez escaneado el contenedor. La forma con hook +tiene este aspecto: + +```rust,ignore +use firefly::observability::{HealthResult, IndicatorFn}; + +// `core` is the wired Core (it owns the HealthComposite). Registering an +// indicator on it makes the probe appear under /actuator/health. +core.add_observability_indicator(IndicatorFn::new("event-bus", || async { + HealthResult::up() // a real probe would ping the broker and return down() on failure +})); +``` + +Qué acaba de ocurrir: `IndicatorFn::new("event-bus", ..)` envuelve el closure +asíncrono como un `Indicator` llamado `event-bus`; +`add_observability_indicator` lo registra en el composite. La siguiente llamada a +`/actuator/health` ejecuta cada indicador de forma concurrente y pliega los +resultados en el `status` global, listando tu sonda por nombre en el desglose por +componente. + +> **Note** Health expone dos subrutas a las que acceden las sondas de tu +> orquestador: `/actuator/health/liveness` y `/actuator/health/readiness`. Están +> separadas para que una migración en curso que falle la *readiness* (todavía no +> me mandes tráfico) no tenga por qué hacer saltar la *liveness* (mátame y +> reiníciame). Devolver `degraded` mantiene una sonda `UP` mientras señala el +> problema en la consolidación. + +> **Tip** **Punto de control.** Tras cablear un indicador, `curl +> localhost:8081/actuator/health` muestra el nombre de tu sonda junto al +> `status`. Haz que el closure devuelva `HealthResult::down("broker unreachable")` +> una vez y observa cómo el `status` global pasa a `DOWN` —esa es la regla de +> precedencia en acción. + +## Paso 4 — Leer las métricas de petición que ya tienes + +No tuviste que pedir la latencia por ruta: las métricas de petición se +auto-instrumentan **activadas por defecto**, tanto en la capa `Core` (de modo que +incluso un `Core` desnudo las emite) como a través del stack web, que rellena una +`RequestMetricsConfig` por defecto si no fijaste ninguna. + +> **Note** **Término clave — métricas de petición.** Por cada petición el +> middleware registra el temporizador etiquetado `http_server_requests_seconds` +> más un gauge acompañante `…_max`, etiquetados con `method` / `uri` plantillado +> (la ruta coincidente, de modo que `/api/v1/wallets/:id` y no el id concreto) / +> `status` / `outcome` / `exception`. Una petición limpia lleva +> `exception="None"`. Esta es la convención de Micrometer/Spring Boot, así que los +> scrapers listos para usar la leen sin cambios. + +Como el medidor ha estado registrando desde el momento en que Lumen arrancó en +[Tu primera API HTTP](./06-first-http-api.md), este capítulo solo lo *expone*. +Accede a una ruta unas cuantas veces y luego lee el medidor: + +```bash +curl localhost:8080/api/v1/wallets/$ID # generate some traffic +curl localhost:8081/actuator/metrics/http_server_requests_seconds +``` + +El nombre del medidor con puntos y guiones bajos se mapea directamente a +Prometheus, de modo que apuntar un `scrape_config` de Prometheus a +`/actuator/prometheus` enciende Grafana sin código extra: + +```bash +curl localhost:8081/actuator/prometheus | grep http_server_requests_seconds +``` + +> **Note** Para desactivar la auto-instrumentación, establece +> `CoreConfig { disable_request_metrics: true, .. }`. Para ajustar la ventana de +> máximo móvil o las exclusiones de path en lugar de desactivarla, proporciona +> `request_metrics: Some(RequestMetricsConfig { .. })`. Ambos se configuran de la +> misma forma en que configuraste todo lo demás en +> [Configuración](./03-configuration.md). + +### Registrar tus propios medidores + +Más allá del temporizador de petición, registras medidores de dominio en el +**mismo** registro, de modo que afloran en `/actuator/metrics` y +`/actuator/prometheus` de inmediato. Obtén el registro del `Core` con +`metric_registry()` (también es un bean de DI resoluble que puedes +`#[autowired]`), y luego crea un contador o un gauge: + +```rust,ignore +let metrics = core.metric_registry(); + +// A domain counter, bumped each time the transfer saga completes. +let transfers = metrics.counter("lumen_transfers_total"); +transfers.inc(); // or transfers.add(3) for an explicit count + +// A gauge sampling a live value (e.g. wallets currently held in the read model). +let active = metrics.gauge("lumen_wallets_active"); +active.set(wallet_count as f64); +``` + +Qué acaba de ocurrir: `counter(name)` y `gauge(name)` devuelven un +`Arc` / `Arc` registrado bajo ese nombre. `Counter::inc()` suma +uno (`add(n)` añade un recuento explícito); `Gauge::set(v)` registra un valor +muestreado. Ambos medidores aparecen ahora en el listado y en el scrape de +Prometheus sin ningún cableado de exporter. + +> **Tip** **Punto de control.** Tras incrementar `lumen_transfers_total` y leer +> `/actuator/metrics`, el listado de medidores incluye `lumen_transfers_total`; el +> scrape de Prometheus muestra su recuento actual. El registro es compartido, así +> que tus medidores de dominio y el temporizador de petición del framework +> conviven uno al lado del otro. + +## Paso 5 — Logging estructurado y correlación + +`FireflyApplication` instala una capa de `tracing` que formatea cada evento como +una línea estructurada y la enriquece con el id de correlación de la petición +(establecido por el middleware de correlación, activado por defecto). Llama a +`init_logging` por ti en el arranque —en modo best-effort, de modo que un arnés de +test que ya posee el subscriber global no entra en pánico— y, con la feature +`admin` activada, duplica los registros hacia el búfer de logs en vivo del panel. + +> **Note** **Término clave — `init_logging`.** `init_logging(LogConfig)` instala +> el subscriber estructurado de `tracing` como el global por defecto. Su hermano +> `init_logging_with_layers([..])` hace lo mismo pero apila capas de `tracing` +> adicionales sobre la capa de correlación —el hook que usa el panel de +> administración para duplicar cada registro de log hacia su búfer en memoria +> mientras el flujo JSON de consola sigue fluyendo. Nunca llamas a ninguno de los +> dos tú mismo; el framework lo hace en el arranque. + +```rust,ignore +// What FireflyApplication does at boot — Lumen writes none of this: +let _ = web.init_logging(); +// (or web.init_logging_with_layers(vec![log_buffer]) when the admin tail is on) +``` + +Después de eso, las macros simples de `tracing` producen líneas enriquecidas, y +los campos registrados en un span envolvente se fusionan en cada evento: + +```rust,ignore +tracing::info!(wallet_id = %id, amount = %money, "deposit accepted"); +``` + +Los nombres de campo (`time`, `level`, `msg`, `service`, `correlationId`) siguen +un esquema estable y documentado, de modo que un único pipeline de logs parsea +cada servicio Firefly de forma consistente. + +Como el id de correlación vive en un ámbito task-local, fluye automáticamente +hacia cada línea de log, cada evento que el ledger de Lumen publica +(`Event::new` lo estampa) y cada llamada de cliente saliente (el `traceparent` +W3C se propaga). Una petición que abre una wallet, publica `WalletOpened` y lo +proyecta en el read model se hilvana bajo un único id sin enhebrado manual —el id +de correlación task-local sustituye a la fontanería MDC thread-local que +escribirías a mano en otros stacks. + +### Configurar el logging + +El logging se configura igual que configuras todo lo demás: desde el único +fichero de configuración principal. Vincula la sección `firefly.logging.*` a un +`LogConfig` con +`firefly::observability::log_config_from_properties(props, base)`: + +```yaml +firefly: + logging: + format: json # json (default) | text (logfmt) | console + level: info # root level + level.firefly_web: warn # per-logger levels (Spring's logging.level.) + level.app::ledger: trace + service: lumen # the `service` field stamped on every line + file: + name: lumen.log # enable the rolling file appender + max-size: 10MB + max-history: 7 +``` + +Qué hacen estas claves: `format` elige el renderizador de salida; el `level` +escueto es el nivel raíz, y `level.` sobrescribe un logger concreto +(coincidiendo con `logging.level.` de Spring); `service` se estampa en +cada línea; el bloque `file` activa el rolling file appender y ajusta su rotación. +Por tanto, los niveles por logger, el formato de salida y el rolling file appender +provienen todos de la configuración. Un fichero de logging externo puede además +incorporarse con `apply_external_config`. + +Y cada nivel puede cambiarse **sin reiniciar** mediante +`POST /actuator/loggers/{name}` —el control de loggers en ejecución del actuator. +El endpoint informa el `configuredLevel` / `effectiveLevel` de cada logger, la +forma convencional que esperan las herramientas de gestión: + +```bash +# Raise app::ledger to TRACE on a running instance, no redeploy. +curl -X POST localhost:8081/actuator/loggers/app::ledger \ + -H 'content-type: application/json' \ + -d '{"configuredLevel":"TRACE"}' +``` + +> **Tip** **Punto de control.** `curl localhost:8081/actuator/loggers` lista cada +> logger con su `configuredLevel` / `effectiveLevel`. Haz POST de un nuevo nivel a +> un logger, vuelve a hacer GET y confirma que el nivel cambió en el proceso en +> vivo. + +## Paso 6 — Trace context y OpenTelemetry + +La cadena de middleware por defecto que `FireflyApplication` aplica incluye la +`TraceContextLayer`, que **origina** el contexto de traza distribuida en cada +petición. + +> **Note** **Término clave — trace context W3C.** `traceparent` / `tracestate` +> son las cabeceras HTTP estándar que transportan una traza distribuida a través +> de las fronteras de servicio: un trace-id de 32 hex y un span-id de 16 hex +> identifican dónde se sitúa la petición en un árbol de llamadas mayor. *Originar* +> significa: cuando una petición entrante no lleva `traceparent`, la capa acuña un +> span raíz nuevo para que la petición salga de Lumen como cabecera de una traza +> bien formada. + +Así, la capa valida un `traceparent` / `tracestate` entrante cuando está presente +y acuña un span raíz W3C cuando está ausente, lo inserta en la petición y +enriquece cada línea de log con `trace_id` / `span_id`. Una petición que llega sin +cabecera de traza pasa a ser igualmente la cabecera de una traza distribuida, y el +`traceparent` que Lumen propaga en las llamadas salientes se convierte en la +arista padre/hijo hacia el siguiente servicio. + +El cableado del SDK de OpenTelemetry —exporters, sampling, atributos de recurso— +se deja a tu aplicación, donde añades tu capa OTel de `tracing` preferida junto a +la capa de correlación. Lumen se distribuye sin exporter (es código didáctico sin +colector externo), pero la originación y propagación de trace-context ya están en +los bordes. Cuando sí quieras spans fluyendo hacia un colector, construye un +tracer OTLP y añade la capa de `tracing-opentelemetry` al subscriber que Firefly +instaló —la capa de correlación sigue funcionando junto a ella: + +```rust,ignore +use opentelemetry_otlp::WithExportConfig; +use tracing_subscriber::prelude::*; + +// Build an OTLP pipeline pointing at your collector. +let tracer = opentelemetry_otlp::new_pipeline() + .tracing() + .with_exporter( + opentelemetry_otlp::new_exporter() + .tonic() + .with_endpoint("http://otel-collector:4317"), + ) + .install_batch(opentelemetry_sdk::runtime::Tokio)?; + +// Register the OTel layer alongside Firefly's structured-log + correlation layers. +tracing_subscriber::registry() + .with(tracing_opentelemetry::layer().with_tracer(tracer)) + .init(); +``` + +Las cabeceras `traceparent` que Firefly ya propaga se convierten en las aristas +padre/hijo entre spans, de modo que una petición que se ramifica hacia una llamada +saliente aparece como una única traza distribuida en tu backend. + +## Paso 7 — Advice global de excepciones (opcional) + +Los errores de Lumen ya se renderizan como `application/problem+json` (RFC 9457) +en la frontera del handler —lo viste desde el primerísimo endpoint en +[Tu primera API HTTP](./06-first-http-api.md). Para una reescritura +*transversal* —mapear toda una clase de errores a un status o cuerpo +personalizado sin tocar cada handler— el framework ofrece una capa de advice +global transparente. + +> **Note** **Término clave — advice global de excepciones.** Un registro de +> transformaciones que post-procesa cada respuesta `application/problem+json` +> después de que el handler la produce —el análogo en Rust del +> `@ControllerAdvice` de Spring. Registras el registro como un `#[bean]`; el +> framework instala una `ExceptionAdviceLayer` como la capa más externa solo +> cuando el registro no está vacío, de modo que un servicio que no declara tal +> bean conserva la vía RFC 9457 simple. + +Registra un bean `ExceptionHandlerRegistry` e indexa las transformaciones por +tipo de problema: + +```rust,ignore +use firefly::web::ExceptionHandlerRegistry; +use firefly::kernel::{ProblemDetail, TYPE_NOT_FOUND}; + +// A #[bean] returning a registry: every "not found" becomes a friendlier 410. +#[bean] +fn exception_advice(&self) -> ExceptionHandlerRegistry { + ExceptionHandlerRegistry::new().on_type(TYPE_NOT_FOUND, |pd: &ProblemDetail| { + let mut out = pd.clone(); + out.status = 410; + out + }) +} +``` + +Qué acaba de ocurrir: `on_type(TYPE_NOT_FOUND, transform)` registra un closure que +recibe el `ProblemDetail` producido y devuelve uno reescrito —aquí cambiando el +status de 404 a 410 (`Gone`). El framework ejecuta la transformación coincidente +en cada documento de problema saliente. Las sobrescrituras locales del controlador +siguen prevaleciendo sobre las reglas globales, de modo que un handler puede +quedar fuera de la reescritura transversal. + +> **Tip** **Punto de control.** Con el bean registrado, solicita una wallet +> inexistente y confirma que el status de la respuesta es ahora `410` mientras el +> cuerpo sigue siendo un documento `application/problem+json` válido. Elimina el +> bean y el status vuelve al `404` por defecto —prueba de que la capa solo se +> instala cuando el registro no está vacío. + +## Paso 8 — El panel de administración autohospedado + +La superficie del actuator es JSON para máquinas. `firefly-admin` monta un panel +de administración de una sola página —empaquetado, sin build de `npm`— que une +salud, métricas, loggers, beans, mappings, cachés, handlers CQRS, trazas y una +cola de logs en vivo en un único panel de control con flujos Server-Sent-Event. + +> **Note** **Término clave — panel autohospedado.** El panel es una SPA en +> JavaScript puro servida por el propio framework en el puerto de gestión: no hay +> un servicio de frontend aparte que desplegar ni paso de build. Con la feature +> `admin` de la fachada habilitada, `FireflyApplication` lo monta en `/admin/` y lo +> vincula a los componentes en vivo. + +Con la feature `admin` activada, **`FireflyApplication` lo autohospeda en el +puerto de gestión** y lo vincula a los colaboradores reales: el composite de +salud, el registro de métricas, el bus CQRS, el scheduler, el contenedor de DI +(que respalda la vista Beans), una instantánea de entorno construida a partir de +los perfiles activos y el entorno de proceso `FIREFLY_*`, un búfer de trazas +alimentado por el recorder de intercambios HTTP, y un búfer de logs alimentado por +la capa de logging duplicada. Los paneles `env` / `config` / `mappings` muestran +**datos reales**, no stubs. Lumen no escribe nada de este cableado —distribuye el +panel en `/admin/` simplemente por ser una `FireflyApplication`. + +```bash +cargo run --bin lumen --features admin +# then open http://localhost:8081/admin/ in a browser +``` + +El panel renderiza quince vistas integradas, agrupadas en la barra lateral: + +| Sección | Vistas | +|----------------|----------------------------------------------------------------| +| Dashboard | Overview, Health | +| Application | **Beans**, Environment, Configuration, Loggers | +| Monitoring | Metrics, Scheduled Tasks, HTTP Traces, Log Viewer | +| Infrastructure | Mappings, Caches, CQRS, Transactions | +| Fleet | Instances (server mode) | + +Cada vista está respaldada por un endpoint JSON `/admin/api/*`; los flujos SSE +(`/admin/api/sse/{health,metrics,traces,logfile,beans,runtime,server}`) empujan +actualizaciones sin que tu código haga polling. Los paths de admin y actuator se +excluyen de la captura de trazas, de modo que nunca contaminan el panel HTTP +Traces. + +### La vista Beans + +La vista **Beans** es la ventana del panel hacia el contenedor de inyección de +dependencias. Como `FireflyApplication` siempre pasa el contenedor escaneado, el +panel sirve: + +| Endpoint | Devuelve | +|------------------------------|------------------------------------------------------| +| `GET /admin/api/beans` | cada bean registrado con su estereotipo y scope | +| `GET /admin/api/beans/graph` | el grafo de dependencias entre beans | +| `GET /admin/api/beans/:name` | el detalle de un bean (tipo, scope, dependencias) | +| `GET /admin/api/sse/beans` | una instantánea en vivo en cada intervalo de refresco| + +La vista Overview también consolida un bloque `beans` (`{ total, stereotypes }`) y +un bloque `wiring` (recuentos en vivo de handlers CQRS y tareas programadas) +extraídos del mismo contenedor, de modo que la página de inicio muestra cuánto +está cableado el servicio sin abrir la vista Beans completa. + +La vista Beans de Lumen está **poblada**, no escasa: el framework escanea por +componentes la configuración que declara los beans de Lumen, de modo que el event +store, el read model, la query cache, el servicio JWT, el `FilterChain` / +`BearerLayer`, el servicio de aplicación del ledger y el controlador `WalletApi` +aparecen todos como beans con sus estereotipos y las dependencias autowired entre +ellos. (Si hospedaras el panel de forma autónoma sin un contenedor, estos +endpoints se degradan con elegancia a un bloque vacío `{ "total": 0 }`.) + +> **Note** `firefly-admin` también funciona en *modo servidor*: las instancias se +> autorregistran mediante un cliente admin, y un servidor central agrega una flota +> de servicios en la vista Instances. El panel es la misma SPA en JavaScript puro +> impulsada por completo por los endpoints JSON + SSE de `/admin/api` —no hay paso +> de build de frontend en ninguno de los dos modos. + +> **Tip** **Punto de control.** Con `--features admin`, abre +> `http://localhost:8081/admin/`, selecciona **Beans** y encuentra el controlador +> `WalletApi`. Sus dependencias autowired `bus` / `ledger` / `query_cache` +> deberían aparecer como aristas en el grafo de beans —prueba de que la vista lee +> el contenedor real, no un stub. + +### Una vista personalizada + +Para añadir tu propia vista en la barra lateral, implementa el trait `AdminView` +y empújala a `AdminDeps::views`; el panel la lista bajo `/admin/api/views[/:id]`. +Una vista "Treasury" de Lumen aflora el balance de custodia total de todas las +wallets, consultado desde el read model: + +```rust,ignore +use std::sync::Arc; +use firefly::admin::AdminView; + +struct TreasuryView { + read_model: Arc, +} + +#[async_trait::async_trait] +impl AdminView for TreasuryView { + fn view_id(&self) -> &str { "treasury" } + fn display_name(&self) -> &str { "Treasury" } + fn icon(&self) -> &str { "wallet" } + + // Backs GET /admin/api/views/treasury (keyed by view_id). + async fn data(&self) -> serde_json::Value { + let total: i64 = self.read_model.all().iter().map(|w| w.balance).sum(); + serde_json::json!({ "custodyTotal": total, "wallets": self.read_model.len() }) + } +} +``` + +Los cuatro métodos del trait son: `view_id` (la clave del registro y el segmento +de path `/views/{id}`), `display_name` e `icon` (lo que la barra lateral +renderiza), y el `data()` asíncrono que produce el payload JSON de la vista. +Registra la vista antes de montar empujándola a `AdminDeps::views`: + +```rust,ignore +let mut deps = AdminDeps::new(/* required collaborators … */); +deps.views.push(Arc::new(TreasuryView { read_model: Arc::clone(&read_model) })); +``` + +> **Note** Cuando dejas que `FireflyApplication` autohospede el panel, nunca +> construyes `AdminDeps` tú mismo —el framework obtiene cada colaborador del stack +> web en vivo y del contenedor escaneado. Solo construyes `AdminDeps` +> directamente en el caso avanzado de más abajo, donde hospedas el panel fuera de +> una `FireflyApplication`. + +> **Design note.** El router del panel es accesible directamente cuando quieres +> hospedarlo fuera de `FireflyApplication` —un servidor personalizado, o un test. +> `mount(AdminConfig, AdminDeps)` devuelve el router; `AdminDeps::new` toma los +> colaboradores requeridos y el resto son campos opcionales que se rellenan con la +> sintaxis de actualización de struct: +> +> ```rust,ignore +> use std::sync::Arc; +> use firefly::admin::{mount, AdminConfig, AdminDeps, LogBuffer, TraceBuffer}; +> +> let deps = AdminDeps { +> scheduler: Some(scheduler), // → Scheduled Tasks view +> bus: Some(bus), // → CQRS view +> container: Some(container), // → Beans view +> ..AdminDeps::new( +> "lumen", +> firefly::VERSION, +> health_composite, // Arc +> metric_registry, // Arc +> Arc::new(TraceBuffer::new()), +> LogBuffer::new(), +> ) +> }; +> let dashboard = mount(AdminConfig::default(), deps); +> ``` +> +> `FireflyApplication` realiza exactamente este mount por ti, que es la razón por +> la que Lumen distribuye el panel sin código de admin propio. + +## Resumen — qué cambió en Lumen + +Este capítulo te enseñó a leer y extender la superficie de observabilidad +siempre-activa de Lumen —toda ella cableada por `FireflyApplication`, sin código +de observabilidad en `main.rs`: + +| Aspecto | Quién lo cableó | Dónde lo lees / extiendes | +|---------|-----------------|----------------------------| +| Superficie de gestión en `:8081` | el framework | `curl /actuator/*`; sobrescribe con `FIREFLY_MANAGEMENT_ADDR` | +| Metadatos de instancia en `/actuator/info` | bloque `app` del framework + tu contributor | `.info_contributor(..)` en el builder | +| Consolidación de salud | composite del framework + tus indicadores | `add_observability_indicator(IndicatorFn::new(..))` | +| Métricas de petición (`http_server_requests_seconds`) | el framework, activadas por defecto | `/actuator/metrics`, `/actuator/prometheus` | +| Tus medidores de dominio | tú, en el registro compartido | `core.metric_registry().counter(..)` / `.gauge(..)` | +| Logs estructurados y enriquecidos por correlación | `init_logging` en el arranque | macros simples de `tracing`; ajusta vía `firefly.logging.*` | +| Trace context W3C | la `TraceContextLayer` | originado/propagado en los bordes automáticamente | +| Panel de administración autohospedado | `FireflyApplication` + la feature `admin` | `/admin/` —quince vistas incluida la **Beans** poblada | + +Ahora también sabes que el id de correlación fluye automáticamente hacia cada +línea de log, evento publicado y llamada saliente; que la `TraceContextLayer` +origina un `traceparent` W3C cuando falta; y que el advice global de excepciones +es un `#[bean]` opcional que el framework instala solo cuando está presente. + +## Ejercicios + +1. **Alcanza el actuator.** Ejecuta `cargo run --bin lumen`, luego `curl + localhost:8081/actuator/info` y confirma que el bloque `sample` informa el + store en memoria. Accede a `/actuator/health` y `/actuator/metrics`, y luego + confirma que `curl localhost:8080/actuator/health` devuelve un problema 404 —el + actuator no está en el puerto público. +2. **Añade un health indicator.** Cablea un `IndicatorFn::new("read-model", ..)` + en el composite con `add_observability_indicator` (desde un hook `on_ready`, o + declarándolo como un `#[bean]`) que devuelva `UP` cuando el read model contenga + al menos una vista de wallet y `DEGRADED` en caso contrario, y luego obsérvalo + aparecer bajo `/actuator/health`. +3. **Una métrica de Lumen.** Registra un contador —p. ej. `lumen_transfers_total`— + en `core.metric_registry()` cada vez que la saga de transferencia se complete, + y verifica que aparece en `/actuator/metrics` y en el scrape de + `/actuator/prometheus`. +4. **Cambia un nivel de log en vivo.** `curl localhost:8081/actuator/loggers` para + listar los loggers, luego haz `POST` de un nuevo `configuredLevel` a uno de + ellos y vuelve a hacer GET para confirmar que el cambio surtió efecto en el + proceso en ejecución —sin reinicio. +5. **Explora la vista Beans.** Ejecuta `cargo run --bin lumen --features admin`, + abre `http://localhost:8081/admin/` y encuentra la vista Beans —observa que + está *poblada*. Localiza el controlador `WalletApi` y confirma que sus + dependencias autowired `bus` / `ledger` / `query_cache` aparecen en el grafo de + beans. + +## Adónde ir después + +Un servicio que puedes ver es un servicio que puedes operar. El siguiente capítulo +le da a Lumen trabajo que hacer por su cuenta —y una forma de llegar a los +clientes. + +- Añade trabajos en segundo plano y notificaciones salientes en + **[Programación y notificaciones](./16-scheduling-notifications.md)** —y observa + cómo las nuevas tareas `#[scheduled]` aparecen bajo `/actuator/scheduledtasks` y + en la vista Scheduled Tasks del panel. +- Repasa cómo el framework descubre y cablea los beans que muestra la vista + **Beans** en **[Cableado de dependencias](./04-dependency-wiring.md)**. +- Conduce el router cableado —y haz aserciones sobre salud y métricas— en tests + con `bootstrap()` en **[Testing](./18-testing.md)**. +- Mueve el puerto de gestión a una interfaz privada y activa infraestructura real + en **[Producción y despliegue](./20-production.md)**. diff --git a/docs/book/src-es/16-scheduling-notifications.md b/docs/book/src-es/16-scheduling-notifications.md new file mode 100644 index 00000000..6b5d01f1 --- /dev/null +++ b/docs/book/src-es/16-scheduling-notifications.md @@ -0,0 +1,578 @@ +# Programación y notificaciones + +Un servicio de monedero real hace mucho más que responder a peticiones HTTP. +Barre monederos abandonados durante la noche, recalcula intereses, reintenta +transferencias atascadas y —la parte que el cliente nota— envía por correo un +extracto diario. Nada de eso lo dispara una petición: se ejecuta según un reloj, +o como reacción a algo que ocurre en otro sitio. Este capítulo dota a Lumen de su +primera pieza de trabajo en *segundo plano* y traza la superficie de integración +que cuelga de ella. + +Cuatro preocupaciones del framework cubren esta historia de back-office, y +Firefly ofrece cada una detrás de la misma fachada `firefly` de la que dependes +desde el [Inicio rápido](./02-quickstart.md): + +- **Programación** (`firefly-scheduling`) — ejecutar código con un temporizador. +- **Notificaciones** (`firefly-notifications`) — entregar mensajes a través de + proveedores intercambiables (correo, SMS, push). +- **Webhooks salientes** (`firefly-callbacks`) — enviar eventos firmados a otros + sistemas que quieren reaccionar a Lumen. +- **Webhooks entrantes** (`firefly-webhooks`) — recibir y validar callbacks del + proveedor de pagos externo de Lumen. + +Construiremos la pieza de programación de principio a fin —una tarea real, +registrada y en ejecución— y luego trazaremos exactamente dónde se enganchan la +notificación, el webhook saliente y el callback entrante. Lumen mantiene la tarea +programada deliberadamente diminuta —registra que se ha ejecutado— para que veas +el cableado sin arrastrar el SDK de un proveedor a la base de enseñanza. + +Al terminar este capítulo, serás capaz de: + +- Declarar una tarea programada con `#[scheduled]` y entender cómo el framework + la *descubre* y la arranca sin una sola línea de cableado en `main`. +- Distinguir los cuatro tipos de disparador —cron, cron con zona, frecuencia fija, + retardo fijo— y elegir el adecuado para un trabajo dado. +- Leer la gramática cron que Firefly acepta, incluidas las formas con zona horaria + y las macros. +- Despachar una notificación a través del `Dispatcher` agnóstico al canal y ver + cómo un proveedor real encaja detrás del mismo trait. +- Esbozar un webhook saliente firmado con `firefly-callbacks` y un webhook + entrante validado con `firefly-webhooks`, y saber dónde colgaría cada uno del + calendario de Lumen. + +## Conceptos que conocerás + +Antes de la primera línea de código, estas son las ideas en las que se apoya este +capítulo. Cada una se reintroduce en contexto allí donde se usa por primera vez; +esta es la versión breve. + +> **Note** **Término clave — tarea programada.** Una *tarea programada* es un +> fragmento de código que el framework ejecuta con un temporizador en lugar de +> como respuesta a una petición. Tú escribes el trabajo; un *disparador* decide +> cuándo se activa. El análogo en Spring es un método anotado con `@Scheduled`. + +> **Note** **Término clave — disparador.** Un *disparador* (trigger) es la regla +> que responde a «¿cuándo se ejecuta esta tarea la próxima vez?» —cada minuto, a +> las 2 a. m. todos los días, 30 segundos después de que terminara la última +> ejecución—. Firefly ofrece cuatro tipos de disparador (cron, cron con zona, +> frecuencia fija, retardo fijo); Spring expresa las mismas opciones mediante +> `@Scheduled(cron=…)`, `fixedRate` y `fixedDelay`. + +> **Note** **Término clave — descubrimiento en tiempo de enlazado.** Firefly +> encuentra tus tareas programadas en *tiempo de enlazado* usando el crate +> `inventory`: cada `#[scheduled]` envía un registro a un registro de tiempo de +> compilación, y el framework vacía ese registro al arrancar. El análogo en Spring +> es el escaneo de componentes —salvo que aquí ocurre en tiempo de +> compilación/enlazado sin reflexión en tiempo de ejecución, de modo que «qué hay +> programado» es un conjunto fijo e inspeccionable—. + +> **Note** **Término clave — canal / dispatcher.** Un *canal* es un transporte que +> entrega un mensaje (correo, SMS, push); un *dispatcher* enruta un mensaje al +> canal registrado para su tipo. Programas contra el *puerto* del canal (un trait) +> y registras un proveedor concreto en el momento del cableado. El análogo en +> Spring es un `NotificationService` que pone una cara a remitentes conectables. + +## Paso 1 — Declarar una tarea programada + +El trabajo en segundo plano de Lumen vive en `src/housekeeping.rs`. La función +completa es una única `async fn` sin argumentos que lleva un atributo +`#[scheduled(...)]`. Crea el archivo con este contenido: + +```rust,ignore +// src/housekeeping.rs +use std::sync::atomic::{AtomicU64, Ordering}; + +use firefly::prelude::*; + +/// The number of times the heartbeat has run — observable from a test (and, in +/// a real service, a counter you would surface on `/actuator/metrics`). +static HEARTBEAT_TICKS: AtomicU64 = AtomicU64::new(0); + +/// A periodic housekeeping heartbeat. `#[scheduled(fixed_rate = "60s")]` makes +/// the framework call this on every tick after the initial delay. +#[scheduled(fixed_rate = "60s", initial_delay = "5s")] +pub async fn ledger_heartbeat() -> Result<(), std::io::Error> { + HEARTBEAT_TICKS.fetch_add(1, Ordering::Relaxed); + Ok(()) +} +``` + +Luego añade el módulo a la raíz de tu crate para que se compile y se escanee. En +`src/main.rs` la línea ya existe en la lista de módulos de Lumen (la configuraste +en el [Inicio rápido](./02-quickstart.md)); si vas siguiendo el tutorial de forma +incremental, añádela ahora: + +```rust,ignore +// src/main.rs +mod housekeeping; +``` + +Lo que acaba de ocurrir, pieza a pieza: + +- **`#[scheduled(fixed_rate = "60s", initial_delay = "5s")]`** es toda la + declaración. `fixed_rate = "60s"` dice «dispara cada 60 segundos»; + `initial_delay = "5s"` dice «espera 5 segundos tras el arranque antes del primer + disparo». Las duraciones se escriben como cadenas legibles (`"60s"`, `"5m"`, + `"500ms"`). +- **`ledger_heartbeat` es el trabajo.** Es una `async fn` corriente que no toma + argumentos y devuelve un `Result`. Aquí solo incrementa un contador atómico; un + despliegue real barrería monederos abandonados o lanzaría una generación de + extractos. +- **`firefly::prelude::*`** trae todo lo que necesita la superficie del framework + —incluido el propio macro `#[scheduled]` y el tipo `Scheduler` que conocerás en + el Paso 3—. Esa única importación de la fachada lo cubre. + +> **Note** **Término clave — registro `inventory`.** `inventory` es el crate de +> Rust que Firefly usa para el descubrimiento en tiempo de enlazado. El macro +> `#[scheduled]` hace dos cosas: genera un ayudante +> `schedule_ledger_heartbeat(&scheduler)` y envía un `ScheduledRegistration` al +> registro de `inventory`. Tú nunca llamas al ayudante: el framework itera el +> registro en el arranque. Este es el mismo mecanismo de descubrimiento que +> encuentra tus controladores y tus handlers de CQRS. + +> **Tip** **Punto de control.** `cargo build` compila sin errores. Escribiste una +> función guiada por temporizador y no registraste nada a mano: el atributo hizo +> el registro. + +## Paso 2 — Deja que el framework sea el dueño del scheduler + +No escribiste ningún `tokio::spawn`, ningún `Scheduler::new()`, ni ninguna llamada +a `start()` —y no lo harás—. `FireflyApplication::run()` (la única línea en el +`main` de Lumen) es el dueño del scheduler. Durante el pipeline de arranque que +leíste en +[Inicio rápido, Paso 6](./02-quickstart.md#step-6--understand-what-run-does), el +framework: + +1. Construye un `Scheduler`. +2. Vacía el registro de `inventory` —llamando a + `firefly::scheduling::register_discovered_scheduled(&scheduler)` para registrar + cada tarea `#[scheduled]` (y una llamada hermana para las tareas declaradas como + métodos de bean). +3. Arranca el scheduler en una tarea de tokio en segundo plano, de modo que se + ejecuta durante toda la vida del proceso. + +Esto significa que `main` no cambia nunca cuando añades una tarea programada: la +nueva tarea se *descubre*, no se enhebra a través de un punto de entrada. Es la +misma propiedad que se cumplía para los controladores y los handlers de CQRS en +capítulos anteriores. + +Para las pruebas, Lumen mantiene un pequeño ayudante que construye un scheduler +*nuevo* y ejecuta el mismo descubrimiento contra él, de modo que una prueba pueda +introspeccionar el calendario sin arrancar la aplicación completa ni esperar a un +tick: + +```rust,ignore +// src/housekeeping.rs (continued) +/// Registers the heartbeat on a fresh scheduler and returns it — used by the +/// tests to assert it registered. `main()` does NOT call this: +/// `FireflyApplication` drains the same `inventory` registry and starts the +/// scheduler. +pub fn build_scheduler() -> std::sync::Arc { + let scheduler = std::sync::Arc::new(Scheduler::new()); + // `#[scheduled]` tasks are DISCOVERED and registered through the + // inventory/DI registry — no manual `schedule_` calls. + firefly::scheduling::register_discovered_scheduled(&scheduler); + scheduler +} + +/// How many heartbeat ticks have run so far. +pub fn heartbeat_ticks() -> u64 { + HEARTBEAT_TICKS.load(Ordering::Relaxed) +} +``` + +Lo que acaba de ocurrir: `build_scheduler` existe *solo* para las pruebas. Llama +exactamente al mismo `register_discovered_scheduled` que llama el framework, de +modo que la prueba ejercita el descubrimiento real. `Scheduler::new()` devuelve un +scheduler vacío cuyo proveedor de bloqueo distribuido es un no-op (comportamiento +de instancia única); la llamada de registro lo puebla desde el registro de +`inventory`. + +> **Note** **Término clave — bloqueo distribuido.** Cuando ejecutas más de una +> copia de un servicio, normalmente quieres que un trabajo programado se ejecute en +> *exactamente una* de ellas. Un *bloqueo distribuido* (el modelo de +> Spring/ShedLock) permite que una tarea adquiera un bloqueo con nombre antes de +> cada tick y omita el tick si otra instancia lo posee. El `Scheduler::new()` por +> defecto usa un bloqueo no-op (cada tick se ejecuta), lo cual es correcto para una +> única instancia; para el caso en clúster se ofrecen bloqueos respaldados por +> Redis y Postgres. + +El scheduler ejecuta cada tarea en su propia tarea de tokio con recuperación ante +pánicos —una tarea que entra en pánico se registra en el log y el calendario +continúa— y `stop()` apaga de forma ordenada, dejando que terminen primero las +ejecuciones en curso. Como `run()` captura SIGINT/SIGTERM, ese apagado ordenado +queda cableado en el ciclo de vida de Lumen sin coste alguno. + +> **Tip** **Punto de control.** Ejecuta Lumen con `cargo run` y observa cómo el +> contador `scheduled tasks:` del informe de arranque sube para incluir +> `ledger_heartbeat`. Cinco segundos después del arranque, el heartbeat empieza a +> dispararse una vez por minuto —en silencio, ya que solo incrementa un contador—. + +## Paso 3 — Observar el calendario desde una prueba + +La tarea está registrada y haciendo tic, pero ¿cómo lo *demuestras* sin esperar 60 +segundos? Dos costuras hacen el calendario observable. Primero, el scheduler expone +una instantánea de cada tarea registrada; segundo, el contador atómico del +heartbeat registra cada ejecución. El módulo de pruebas de Lumen comprueba ambas: + +```rust,ignore +// src/housekeeping.rs (test module) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scheduled_task_registers() { + let scheduler = build_scheduler(); + let names: Vec = scheduler.tasks().into_iter().map(|t| t.name).collect(); + assert!(names.contains(&"ledger_heartbeat".to_string())); + } + + #[tokio::test] + async fn heartbeat_runs() { + let before = heartbeat_ticks(); + ledger_heartbeat().await.unwrap(); + assert_eq!(heartbeat_ticks(), before + 1); + } +} +``` + +Lo que acaba de ocurrir: + +- **`scheduler.tasks()`** devuelve un `Vec` —una instantánea + inmutable tomada en el momento del registro, donde cada entrada lleva un `name`, + el descriptor del disparador y cualquier metadato de bloqueo—. La primera prueba + introspecciona el calendario sin esperar: se registró, así que su nombre está + presente. +- **`ledger_heartbeat().await`** llama directamente al cuerpo. Como el trabajo es + una `async fn` corriente, una prueba puede invocarlo sin el scheduler en absoluto + y comprobar el efecto secundario: el contador avanzó exactamente uno. + +La misma instantánea `tasks()` alimenta el actuator: las tareas programadas +aparecen en `GET /actuator/scheduledtasks` (en el puerto de gestión) y en la vista +de Tareas Programadas del panel de administración, ambas presentadas en +[Observabilidad](./15-observability.md). + +> **Tip** **Punto de control.** `cargo test heartbeat` pasa. Demostraste las dos +> mitades —la tarea se registró, y su cuerpo se ejecuta y es observable— sin un +> solo `sleep` en la prueba. + +## Paso 4 — Elegir el disparador adecuado + +`#[scheduled]` cubre el caso del día a día, pero el `Scheduler` subyacente expone +directamente los cuatro tipos de disparador, que es lo que usaría una generación de +extractos real de Lumen. Cada tipo responde a «¿cuándo la próxima vez?» de forma +distinta: + +```rust,ignore +use std::{sync::Arc, time::Duration}; +use firefly::prelude::Scheduler; + +let s = Arc::new(Scheduler::new()); + +// Cron: a 5-field expression (or the 6-field form with leading seconds). +// Returns Result — the expression is parsed and can be rejected. +s.cron("daily-statements", "0 2 * * *", || async { Ok(()) }).unwrap(); + +// FixedRate: fire every period from a fixed anchor (the schedule slips if a run +// is slow, because the grid is anchored, not chained to the last finish). +s.fixed_rate("metrics-emit", Duration::from_secs(30), || async { Ok(()) }); + +// FixedDelay: fire `delay` after the previous run *finished* (no overlap). +s.fixed_delay("sweep-abandoned", Duration::from_secs(300), || async { Ok(()) }); +``` + +Lo que acaba de ocurrir: registraste tres tareas en un scheduler a mano. Cada +clausura es una fábrica —el scheduler la llama una vez por disparo para obtener un +future nuevo— y cada una devuelve `Result<(), TaskError>`, de modo que una +ejecución fallida se registra a nivel `warn` y el calendario continúa. Observa que +`cron` devuelve un `Result` porque la expresión se parsea; los registros de +frecuencia/retardo toman una `Duration` tipada y no pueden fallar. + +Los cuatro tipos, y cuándo recurrir a cada uno: + +| Disparador | Comportamiento | +|---------------------|-------------------------------------------------------------| +| `CronTrigger` | Se dispara cuando el reloj de pared **local** coincide con la expresión | +| `ZonedCronTrigger` | Se dispara según la expresión en una zona horaria IANA | +| `FixedRateTrigger` | Se dispara cada periodo desde un ancla de inicio fija (se desfasa con ejecuciones lentas) | +| `FixedDelayTrigger` | Se dispara el retardo después de que terminara la ejecución anterior | + +> **Design note.** La distinción entre frecuencia fija y retardo fijo es la que +> hace tropezar a la gente. La *frecuencia fija* cuelga una rejilla de un ancla +> fija: cada 30 s clavados, así que si una ejecución tarda 35 s, la siguiente se +> dispara de inmediato (se desfasó). El *retardo fijo* encadena: espera el retardo +> *después* de que cada ejecución termine, de modo que una ejecución lenta empuja +> la siguiente y dos ejecuciones nunca se solapan. Usa frecuencia fija para un +> muestreo constante (emitir una métrica cada 30 s); usa retardo fijo para trabajo +> en serie que no debe acumularse (barrer, esperar, volver a barrer). + +Para una zona horaria —Lumen ejecutaría los extractos a las 9 a. m. en la región +del cliente— construye un `ZonedCronTrigger` en lugar de depender del reloj local +del host: + +```rust,ignore +use firefly::scheduling::{parse_cron, ZonedCronTrigger}; + +// 1-5 is Monday through Friday (the day-of-week domain is 0 = Sunday .. 6 = Saturday). +let expr = parse_cron("0 9 * * 1-5").unwrap(); +let trigger = ZonedCronTrigger::in_zone(expr, "America/New_York").unwrap(); +``` + +Lo que acaba de ocurrir: `parse_cron` convierte la cadena de 5 campos en un +`CronExpr` tipado, y `ZonedCronTrigger::in_zone` evalúa esa expresión en la zona +IANA nombrada. Ambas llamadas devuelven un `Result` —una expresión malformada o un +nombre de zona desconocido es un error duro que manejas en el registro, nunca una +programación errónea silenciosa—. Para registrar una tarea cron con zona en una +sola llamada, el scheduler también ofrece `s.cron_in_zone(name, expr, zone, run)`. + +> **Note** **Gramática cron.** El parser de Firefly acepta la expresión canónica de +> 5 campos `minuto hora día-del-mes mes día-de-la-semana`, una forma opcional de 6 +> campos con un campo de **segundos** a la cabeza, el comodín `?` de Quartz (tratado +> como `*`) y las macros `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly`. +> El día de la semana va de `0` (domingo) a `6` (sábado). Cuando tanto el día del +> mes como el día de la semana están restringidos, la regla se dispara cuando +> coincide **cualquiera** de los dos (comportamiento de Vixie cron). El atributo +> `#[scheduled]` acepta el mismo `cron = "…"` (con un `zone = "…"` opcional) en +> lugar de `fixed_rate` / `fixed_delay`. + +> **Tip** **Punto de control.** Sabes nombrar los cuatro disparadores y explicar +> por qué una generación de extractos usa cron (una hora de reloj de pared), un +> emisor de métricas usa frecuencia fija (muestreo constante) y un barrido usa +> retardo fijo (sin solapamiento). + +## Paso 5 — Despachar una notificación + +El heartbeat es el gancho para la mensajería saliente. En un tick real de +extractos, Lumen construiría un mensaje por monedero y se lo entregaría a un +dispatcher. El crate `firefly-notifications` te da un sobre `Notification` +agnóstico al canal, un trait de transporte `Channel` y un `Dispatcher` que enruta +un mensaje al canal registrado para su `Kind`: + +```rust,ignore +use std::sync::Arc; +use firefly_notifications::{Dispatcher, Kind, MemoryChannel, Notification}; + +let dispatcher = Dispatcher::new(); +dispatcher.register(Arc::new(MemoryChannel::new(Kind::EMAIL))); + +dispatcher + .dispatch(Notification { + channel: Kind::EMAIL, + to: "alice@example.com".into(), + subject: "Your Lumen statement".into(), + body: "Closing balance: $42.00".into(), + ..Notification::default() + }) + .await + .unwrap(); +``` + +Lo que acaba de ocurrir, bloque a bloque: + +- **`Dispatcher::new()`** crea un enrutador vacío. **`register`** añade un canal, + indexado por el `Kind` al que sirve. Aquí `MemoryChannel::new(Kind::EMAIL)` es un + canal integrado que simplemente registra cada mensaje que recibe —ideal para las + pruebas, y exactamente lo que usa Lumen para que la base de enseñanza no arrastre + el SDK de ningún proveedor—. +- **`dispatch`** construye un sobre `Notification` y lo enruta. El campo `channel` + (`Kind::EMAIL`) selecciona el canal registrado; `to`, `subject` y `body` son el + mensaje. `..Notification::default()` rellena los campos restantes (id, plantilla, + variables, marca de tiempo) con sus valores cero. +- **El newtype `Kind`** lleva los canales canónicos `Kind::EMAIL`, `Kind::SMS` y + `Kind::PUSH`, además de `Kind::new("...")` para un transporte personalizado. + Despachar a un tipo sin canal registrado devuelve + `NotificationError::NoChannel` —un error tipado, no un descarte silencioso—. + +> **Note** **Término clave — puerto y adaptador.** Un *puerto* es el trait contra el +> que programas (`Channel`); un *adaptador* es una implementación concreta tras él +> (`MemoryChannel`, o un remitente SMTP real). Como cada canal es un +> `Arc`, los pesados SDK de los proveedores quedan fuera de cualquier +> servicio que no seleccione ese canal: escribes tu lógica de extractos contra el +> puerto y registras el adaptador concreto en el momento del cableado. Esto es +> arquitectura hexagonal, la misma forma que conociste en +> [Diseño guiado por el dominio](./08-domain-driven-design.md). + +Para producción, registra un canal real en lugar de `MemoryChannel` —el mismo +trait `Channel`, entrega real—. Cada proveedor vive en su propio crate, de modo +que su SDK solo se compila en los servicios que se suman explícitamente: + +| Crate | Canal | Respaldo | +|----------------------------------|---------|----------------------------------| +| `firefly-notifications-smtp` | correo | `lettre` (MIME real, STARTTLS) | +| `firefly-notifications-twilio` | SMS | Twilio | +| `firefly-notifications-firebase` | push | Firebase Cloud Messaging | +| `firefly-notifications-sendgrid` | correo | SendGrid | +| `firefly-notifications-resend` | correo | Resend | + +Así que el extracto diario de Lumen es, en una frase: «en el tick del heartbeat, +construye una `Notification` por monedero y haz `dispatch` de ella». Cambiar el +canal en memoria por uno SMTP real es un cambio de registro de una sola línea, +nunca una reescritura de la lógica de extractos. + +> **Tip** **Punto de control.** Sabes seguir un mensaje desde `dispatch` a través +> del enrutado indexado por `Kind` hasta un canal, y sabes nombrar dónde encajaría +> un proveedor de correo real (la llamada a `register`) sin tocar el código de +> extractos. + +## Paso 6 — Enviar un webhook saliente + +Cuando otro sistema necesita *reaccionar* a un evento de Lumen —un monitor de +fraude que quiere cada depósito grande, por ejemplo—, Lumen le envía un webhook +saliente con `firefly-callbacks`. Los servicios registran `Target`s; el +`HmacDispatcher` firma cada payload, reintenta con backoff exponencial y registra +cada `Attempt` en un `Store` conectable para auditoría: + +```rust,ignore +use std::sync::Arc; +use firefly_callbacks::{CallbackEvent, DispatcherConfig, HmacDispatcher, MemoryStore}; + +let store = Arc::new(MemoryStore::new()); +// Defaults: 3 attempts, 200 ms initial delay, doubling between retries. +let dispatcher = HmacDispatcher::new(store, DispatcherConfig::default()); + +// On a large deposit, Lumen would publish a CallbackEvent; the dispatcher signs +// and POSTs it to every registered Target with a stable HMAC-SHA256 signature. +``` + +Lo que acaba de ocurrir: `HmacDispatcher::new` toma un `Store` (aquí el que está en +memoria, que conserva cada intento de entrega para inspección) y un +`DispatcherConfig`. Cualquier campo dejado en su valor cero se rellena con el valor +por defecto, así que `DispatcherConfig::default()` significa 3 intentos con un +primer retardo de 200 ms, duplicándose. Ante un evento disparador, Lumen publica un +`CallbackEvent`; el dispatcher hace POST del payload a cada `Target` registrado y +registra una fila `Attempt` por intento, independientemente del resultado. + +> **Note** **Término clave — firma HMAC.** HMAC (código de autenticación de mensajes +> basado en hash) permite a un receptor verificar que un payload vino de ti y no fue +> manipulado, usando un secreto compartido. Firefly firma cada entrega con +> HMAC-SHA256 con clave en el secreto del destino, de modo que cualquier receptor +> que posea el mismo secreto puede verificar la petición con una llamada de +> biblioteca estándar —sin necesidad de código específico de Firefly—. + +Cada entrega lleva estas cabeceras, idénticas byte a byte a los ports de Firefly en +Java, .NET, Go y Python, de modo que un receptor escrito contra cualquiera de ellos +verifica las entregas de Lumen sin cambios: + +- `X-Firefly-Event` — el tipo de evento. +- `X-Firefly-Event-Id` — el id del evento. +- `X-Firefly-Timestamp` — segundos Unix en que se envió la petición. +- `X-Firefly-Signature` — `sha256=` con clave en el secreto del destino. + +> **Tip** **Punto de control.** Sabes describir qué es un webhook saliente (Lumen +> haciendo POST de un evento firmado a un destino registrado) y nombrar la cabecera +> que comprueba un receptor (`X-Firefly-Signature`). + +## Paso 7 — Recibir un webhook entrante + +La imagen especular es `firefly-webhooks`: cuando el proveedor de pagos externo de +Lumen llama de *vuelta* —un cargo liquidado, un pago fallido—, el pipeline entrante +valida la firma, deduplica y despacha el evento a un procesador. Configura un +pipeline con un validador de proveedor y monta su router: + +```rust,ignore +use std::sync::Arc; +use firefly_webhooks::{web, MemoryDlq, Pipeline, StripeValidator}; + +let pipeline = Arc::new(Pipeline::new(Arc::new(MemoryDlq::new()))); +pipeline.register_validator(StripeValidator::new(b"whsec_test")); +let app: axum::Router = web::router(pipeline); // mount under /api/webhooks/... +``` + +Lo que acaba de ocurrir: `Pipeline::new` toma una *cola de mensajes fallidos* +(dead-letter queue) —aquí la `MemoryDlq` en memoria, donde aterrizan para su +inspección posterior los eventos cuyo procesamiento falla—. `register_validator` +adjunta una comprobación de firma por proveedor; el `StripeValidator` está indexado +por el secreto del webhook (`whsec_test`). `web::router` convierte el pipeline en un +`axum::Router` que montas junto a las demás rutas de Lumen. + +> **Note** **Término clave — cola de mensajes fallidos (DLQ).** Una *cola de +> mensajes fallidos* (dead-letter queue) es adonde va un mensaje cuando no puede +> procesarse —un webhook validado cuyo procesador dio error, por ejemplo—. +> Aparcarlo en una DLQ en lugar de descartarlo significa que puedes inspeccionarlo, +> corregirlo y reproducirlo más tarde. Este es el mismo patrón de EDA que conociste +> en [EDA y mensajería](./10-eda-messaging.md). + +Se ofrecen validadores para los proveedores comunes; cada uno conoce la cabecera y +el algoritmo que usa su proveedor, de modo que registrar uno es toda la +integración: + +| Validador | Cabecera | Algoritmo | +|-------------------|-------------------------|-----------------------------------------------| +| `HmacValidator` | `X-Signature` (por defecto) | HMAC-SHA256 hex (prefijo `sha256=` opcional) | +| `StripeValidator` | `Stripe-Signature` | `t=,v1=`, tolerancia de 5 minutos | +| `GitHubValidator` | `X-Hub-Signature-256` | HMAC-SHA256 hex | +| `TwilioValidator` | `X-Twilio-Signature` | HMAC-SHA1 base64 de la URL + campos de formulario ordenados | + +Pruebas ese receptor con los firmadores de `firefly-testkit` —`sign_stripe`, +`sign_github`, `sign_twilio`, `sign_hmac`—, que producen valores de cabecera +idénticos byte a byte a lo que esperan los validadores, de modo que una petición de +prueba firmada se valida exactamente igual que lo haría la de un proveedor real. +Los usarás en [Pruebas](./18-testing.md). + +> **Tip** **Punto de control.** Sabes nombrar ambas mitades de la historia de +> integración: saliente (`firefly-callbacks`, tú firmas y envías) y entrante +> (`firefly-webhooks`, tú validas e ingieres), y señalar el validador que coincide +> con un proveedor dado. + +## Resumen — qué cambió en Lumen + +Este capítulo dotó a Lumen de su primer trabajo en segundo plano y trazó su +superficie de integración. + +| Antes | Después de este capítulo | +|-------|--------------------------| +| sin trabajo en segundo plano | `src/housekeeping.rs` declara `ledger_heartbeat` con `#[scheduled(fixed_rate = "60s", initial_delay = "5s")]` | +| nada con temporizador | el framework descubre y arranca la tarea; hace tic una vez por minuto, observable mediante un `AtomicU64` y `scheduler.tasks()` | +| `main` no enhebra nada | `main` sigue siendo la única línea `FireflyApplication::run()` —la tarea se descubre, no se cablea— | +| — | el contador del heartbeat es la costura donde se engancharían una notificación de extracto diario, un webhook saliente de saldo cambiado y un callback entrante de proveedor | + +Además, ahora sabes: + +- Que `#[scheduled]` genera un ayudante `schedule_` *y* envía un + `ScheduledRegistration` al registro de `inventory` que el framework vacía con + `register_discovered_scheduled(&scheduler)` —de modo que nunca mantienes a mano + una lista de llamadas de registro—. +- Los cuatro tipos de disparador y cuándo aplica cada uno, además de la gramática + cron que Firefly acepta (5 campos, 6 campos con segundos, `?`, las macros estilo + `@daily`, zonas IANA mediante `ZonedCronTrigger`). +- Que las notificaciones, los webhooks salientes y los webhooks entrantes cambian + cada uno un proveedor por un registro de una sola línea, nunca un cambio de + código, porque cada transporte es un objeto de trait (`Arc`, un + `Target` registrado, un `Validator` registrado). + +Lumen ahora hace trabajo en segundo plano y sabe exactamente dónde cuelga su +mensajería. + +## Ejercicios + +1. **Pon la generación de extractos en cron.** Sustituye el heartbeat de + `fixed_rate` por una tarea `#[scheduled(cron = "0 2 * * *")]` (o registra + `s.cron("statements", "0 2 * * *", ..)` directamente en un `Scheduler`) y + comprueba que aparece en `scheduler.tasks()` por su nombre —sin esperar a un + tick—. +2. **Despacha en el tick.** Dentro de `ledger_heartbeat`, construye un `Dispatcher` + con un `MemoryChannel::new(Kind::EMAIL)`, despacha un extracto de una línea y + comprueba (vía `MemoryChannel::messages`) que el mensaje quedó registrado. +3. **Firma y recibe.** Levanta un `Pipeline` con un `StripeValidator`, monta + `web::router(..)` y usa `firefly_testkit::sign_stripe` para conducir una petición + firmada a través de él con un `TestClient` —comprueba que se acepta, luego + manipula el cuerpo y comprueba que se rechaza—. +4. **Audita lo saliente.** Cablea un `HmacDispatcher` sobre un `MemoryStore`, + despacha un `CallbackEvent` a un `Target` y lee las filas `Attempt` registradas + del store para confirmar que se disparó la política de reintentos. +5. **Elige retardo fijo en lugar de frecuencia fija.** Registra una tarea + `fixed_delay` cuyo cuerpo duerma más que el retardo, ejecuta el scheduler + brevemente y observa que las ejecuciones nunca se solapan —luego explica por qué + una tarea de frecuencia fija con el mismo periodo se habría desfasado en su + lugar—. + +## Adónde ir después + +- Profundiza en la caché del lado de lectura que introdujo la capa de CQRS en + **[Caché](./17-caching.md)**. +- Revisita el feed `/actuator/scheduledtasks` del actuator y la vista de Tareas + Programadas del panel de administración en **[Observabilidad](./15-observability.md)**. +- Prueba los schedulers, los dispatchers y los validadores de webhook con los + canales en memoria y los firmadores de `firefly-testkit` en + **[Pruebas](./18-testing.md)**. diff --git a/docs/book/src-es/17-caching.md b/docs/book/src-es/17-caching.md new file mode 100644 index 00000000..c4e2e314 --- /dev/null +++ b/docs/book/src-es/17-caching.md @@ -0,0 +1,697 @@ +# Caché + +El endpoint `GET /api/v1/wallets/:id` de Lumen ya sirve una vista de monedero desde +una caché de 30 segundos: lo activaste en su día en [CQRS](./09-cqrs.md) con una +anotación y un bean, y lo has usado desde entonces sin pensar en ello. Este +capítulo abre esa maquinaria. Seguiremos una lectura desde el bus de consultas +hasta el cache port a nivel de bytes que hay debajo, demostraremos *por qué* cada +depósito, retirada y transferencia debe *invalidar* esa caché para que una lectura +después de una escritura nunca mienta, y luego envolveremos la llamada lenta a la +que recurre un fallo de caché (cache miss) con los decoradores de resiliencia que +evitan que tire abajo el servicio entero. + +Dos crates sostienen esta historia, y ambos llegan a Lumen a través de la única +fachada `firefly`: `firefly-cache` (como `firefly::cache`) expone un único cache +port más un puñado de backends y un envoltorio tipado, y `firefly-resilience` +(como `firefly::resilience`) aporta los decoradores de circuit breaker, rate +limiter, bulkhead y timeout. La caché de lecturas de CQRS que ya tienes +—`firefly::cqrs::QueryCache`— se asienta encima del cache port. + +Al terminar este capítulo, serás capaz de: + +- Explicar cómo `#[firefly(cache_ttl = "30s")]` sobre una consulta se convierte en + una caché real y respetada de 30 segundos, y qué bean la respeta. +- Mantener *honesta* una lectura-tras-escritura invalidando una familia de + consultas en cada frontera de escritura, y demostrar que el ciclo se cierra con + el propio test HTTP de Lumen. +- Leer y programar contra el cache port `Adapter` —el único trait que implementa + cada backend (en memoria, Redis, Postgres)— y cambiar el backend en un único + punto de cableado. +- Memoizar un valor arbitrario fuera del bus de consultas con `Typed::get_or_set`. +- Envolver un cargador lento (o cualquier llamada saliente) en un `Chain` de + resiliencia para que un timeout, un circuito abierto o un bulkhead lleno fallen + rápido en lugar de quedarse colgados. + +## Conceptos que conocerás + +Cada uno de estos se reintroduce en su contexto cuando se usa por primera vez; +esta es la versión breve para que las palabras no te resulten nuevas cuando las +encuentres. + +> **Note** **Término clave — caché.** Una *caché* es un almacén rápido, normalmente +> en memoria, que guarda el resultado de un cómputo costoso para que la siguiente +> petición pueda saltarse el trabajo. La parte difícil nunca es el almacenamiento: +> es saber cuándo un valor guardado ha quedado obsoleto. En Spring esto es la +> familia `@Cacheable` / `@CacheEvict` respaldada por un `CacheManager`. + +> **Note** **Término clave — cache port.** Un *port* es una interfaz abstracta de la +> que dependen los consumidores en lugar de un backend concreto, de modo que el +> backend pueda intercambiarse sin tocar a los consumidores. El cache port de +> Firefly es el trait `Adapter`; el análogo en Spring es el SPI `Cache` / +> `CacheManager` que hay detrás de `@Cacheable`. + +> **Note** **Término clave — TTL.** El *time to live* es cuánto tiempo permanece +> válida una entrada cacheada antes de expirar y tratarse como ausente. Un TTL de +> 30 segundos significa que una lectura dentro de los 30 segundos posteriores al +> último relleno se sirve desde la caché; pasado ese tiempo, vuelve a ejecutar el +> trabajo. El TTL por sí solo es un techo de obsolescencia, no una garantía de +> corrección: para eso está la invalidación. + +> **Note** **Término clave — read-through / cache-aside.** Una lectura *read-through* +> (o *cache-aside*) comprueba primero la caché; en un fallo ejecuta el trabajo real +> (el *cargador*), almacena el resultado y lo devuelve. La siguiente lectura dentro +> del TTL se salta el cargador. `Typed::get_or_set` es la primitiva read-through +> de Firefly. + +> **Note** **Término clave — decorador de resiliencia.** Un *decorador de +> resiliencia* envuelve una llamada asíncrona para acotar su fallo: un *circuit +> breaker* deja de llamar a una dependencia enferma, un *rate limiter* limita la +> tasa de salida, un *bulkhead* limita la concurrencia y un *timeout* acota la +> duración. Esto refleja a Resilience4j en el mundo de Spring. + +## Paso 1 — Ve la caché que ya tienes + +No tuviste que escribir nada de código de caché para obtener una lectura cacheada: +la *declaraste*. La consulta `GetWallet` de Lumen lleva su política de caché como +un atributo situado justo al lado del tipo, en `src/commands.rs`: + +```rust +/// `GET /api/v1/wallets/:id` query. `#[firefly(cache_ttl = "30s")]` is reflected +/// on the generated `Message::cache_ttl`, so a `QueryCache` memoises reads for +/// 30 seconds. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Query)] +#[firefly(cache_ttl = "30s")] +pub struct GetWallet { + /// The wallet id to fetch. + pub id: String, +} +``` + +Qué acaba de pasar: la macro `#[derive(Query)]` lee el atributo +`#[firefly(cache_ttl = "30s")]` y emite un método `cache_ttl()` sobre la +implementación generada de `Message`. El atributo es *declarativo*: enuncia la +política donde se define el tipo, y el framework cablea el comportamiento. Nada en +el handler de la consulta menciona la caché en absoluto. + +> **Note** **Término clave — caché declarativa.** La caché *declarativa* significa +> que la política vive como una anotación sobre el tipo o el método, no como código +> imperativo en el cuerpo. `@Cacheable(ttl = ...)` de Spring es el análogo; aquí es +> `#[firefly(cache_ttl = "30s")]`. + +Como el TTL es ahora un hecho en el código generado, Lumen lo fija con un test +unitario para que nunca pueda desaparecer en silencio: + +```rust +#[test] +fn get_wallet_carries_cache_ttl() { + assert!(GetWallet::default().cache_ttl().is_some()); +} +``` + +Qué acaba de pasar: el test construye un `GetWallet` por defecto, llama al +`cache_ttl()` generado y comprueba que devuelve `Some(_)`. Si alguien borra el +atributo, este test falla: el contrato de caché está protegido, no asumido. + +> **Tip** **Punto de control.** Abre `samples/lumen/src/commands.rs` y encuentra la +> línea `#[firefly(cache_ttl = "30s")]` sobre `GetWallet`, más el test +> `get_wallet_carries_cache_ttl`. El atributo y la aserción son los dos extremos de +> la misma declaración. + +## Paso 2 — Encuentra el bean que respeta el TTL + +Un `cache_ttl()` sobre un mensaje es inerte hasta que algo lo *lee* en la ruta de +despacho. Ese algo es el bean `QueryCache` y el middleware de bus que instala. + +> **Note** **Término clave — middleware de bus.** El *middleware* envuelve cada +> mensaje que fluye a través del bus de CQRS, ejecutándose antes y después del +> handler. El middleware de caché de lecturas comprueba la caché antes de que se +> ejecute el handler y la rellena después, de modo que una consulta cacheada nunca +> llega al handler. Esto es la intercepción `@Cacheable` de Spring, materializada +> como un interceptor de bus. + +En Lumen el `QueryCache` se declara como un único `#[bean]` dentro de `LumenBeans` +(el contenedor `#[derive(Configuration)]` en `src/web.rs`): + +```rust +use firefly::cqrs::QueryCache; + +// samples/lumen/src/web.rs — inside `#[bean] impl LumenBeans { ... }`. + +/// The read-side query cache honouring `GetWallet`'s 30s TTL (`@Bean`). +#[bean] +fn query_cache(&self) -> QueryCache { + QueryCache::new() +} +``` + +Qué acaba de pasar: `QueryCache::new()` construye una caché de consultas vacía y en +memoria, indexada por el tipo de mensaje más un hash del valor del mensaje. +Declararla como un `#[bean]` es todo el cableado que haces: cuando +`FireflyApplication::run()` escanea componentes en el contenedor y encuentra un +bean `QueryCache`, llama a `query_cache.middleware()` por ti y registra ese +middleware en el bus. (El middleware de validación lo instala el núcleo; ninguno de +los dos lo registras a mano.) + +Así, cuando un `GetWallet` fluye por el bus, el middleware de caché de lecturas: + +- **en un acierto (hit)** devuelve el `WalletView` memoizado *sin llegar nunca al + handler*; +- **en un fallo (miss)** ejecuta el handler, almacena el resultado bajo la clave de + la consulta durante los 30 segundos declarados y lo devuelve. + +> **Design note.** Este es el mismo patrón de autoconfiguración que viste en +> [Quickstart](./02-quickstart.md): «autoconfigura el bus de CQRS… el middleware de +> caché de lecturas siempre que haya presente un bean `QueryCache`». Añades un +> *bean*, no una llamada de registro. El análogo en Spring es autoconfigurar el +> comportamiento de `@EnableCaching` una vez que existe un bean `CacheManager`. + +El mismo bean también se `#[autowired]` en el controlador, de modo que el lado de +escritura pueda alcanzar la misma caché exacta que lee el middleware: + +```rust +// samples/lumen/src/web.rs — the WalletApi controller. +#[derive(Clone, Controller)] +pub struct WalletApi { + #[autowired] + pub bus: Arc, + #[autowired] + pub ledger: Arc, + /// The query cache, invalidated after a mutation so a read-after-write + /// never serves a stale balance within the 30s `GetWallet` TTL (autowired). + #[autowired] + pub query_cache: Arc, +} +``` + +Qué acaba de pasar: el framework instala *un* `QueryCache` como middleware de bus y +entrega el *mismo* `Arc` al controlador. `QueryCache` está respaldado +por `Arc` y es barato de clonar, así que ambos handles comparten las mismas +entradas: el middleware rellena la caché y el controlador puede eliminar entradas +de ella. + +> **Tip** **Punto de control.** En `src/web.rs`, el `#[bean]` `query_cache` y el +> campo `#[autowired] pub query_cache: Arc` se refieren a la misma caché +> compartida. Uno la lee y la rellena (middleware); el otro la invalida +> (controlador). + +## Paso 3 — Mantén honesta la lectura-tras-escritura + +Un TTL de 30 segundos es un regalo para una vista intensiva en lecturas y un +desastre para la corrección si nunca invalidas. Deposita `$2.50`, luego lee el +saldo dentro de los 30 segundos, y una caché que solo conociera el TTL serviría +alegremente el número *antiguo*. + +> **Note** **Término clave — invalidación.** La *invalidación* (o *expulsión*) es la +> eliminación deliberada de una entrada cacheada que ahora es incorrecta, forzando +> a que la siguiente lectura vuelva a ejecutar el trabajo. La corrección de la +> lectura-tras-escritura proviene de invalidar en la frontera de escritura —el +> momento en que un saldo cambia— y no de esperar a un TTL. + +Lumen evita la obsolescencia invalidando toda la familia `GetWallet` después de +cada mutación. Aquí está el handler de depósito en `src/web.rs`: + +```rust +#[post("/wallets/:id/deposit", summary = "Deposit funds", status = 200)] +async fn deposit( + State(api): State, + Path(id): Path, + Json(body): Json, +) -> WebResult> { + let cmd = Deposit { wallet_id: id, amount: body.amount }; + let view: WalletView = api.bus.send(cmd).await.map_err(cqrs_to_web)?; + api.query_cache.invalidate_type::(); + Ok(Json(view)) +} +``` + +Qué acaba de pasar, línea a línea: + +- `api.bus.send(cmd)` despacha el comando `Deposit` a través del bus y espera el + `WalletView` resultante. `map_err(cqrs_to_web)?` convierte un error de CQRS en un + error web `application/problem+json` según RFC 9457. +- `api.query_cache.invalidate_type::()` elimina *todas* las entradas + `GetWallet` cacheadas. Internamente, `invalidate_type::()` borra cada clave de + caché prefijada con el nombre de tipo de `Q` más el separador `:`, de modo que se + limpia toda la familia `GetWallet`: la siguiente lectura vuelve a ejecutar el + handler y refleja la escritura. + +Por qué importa: el TTL acota cuán obsoleto *puede* llegar a estar un valor; la +invalidación explícita garantiza que una lectura *después de una escritura que tú +hiciste* nunca esté obsoleta en absoluto. + +El handler de retirada hace exactamente lo mismo, y también lo hace el endpoint de +transferencia de [Sagas](./12-sagas.md): una transferencia cambia *dos* saldos, así +que también debe invalidar la familia: + +```rust +// In the transfer handler — a transfer touches both wallets' views. +api.query_cache.invalidate_type::(); +``` + +Qué acaba de pasar: como la clave de caché incluye el *valor* del mensaje, una +transferencia entre el monedero A y el monedero B tendría que expulsar dos claves +específicas. Invalidar todo el tipo `GetWallet` es más sencillo y siempre correcto +—no puede dejarse nunca una clave por el camino— a costa de descartar entradas de +caché de monederos no relacionados, que simplemente se rellenan de nuevo en su +siguiente lectura. + +> **Design note.** Lumen combina *caché read-through sobre el mensaje* +> (`#[firefly(cache_ttl)]`) con *expulsión explícita en la frontera de escritura* +> (`invalidate_type`). El lector memoiza; el escritor descarta la familia en el +> momento en que cambia un saldo. El almacén subyacente es el mismo cache port +> intercambiable `Adapter` que usa cualquier otro consumidor de caché (Paso 4), así +> que esta política es independiente de dónde vivan realmente los bytes. + +El test HTTP de extremo a extremo demuestra que el ciclo se cierra. Abre un +monedero con un saldo de `100`, deposita `+250`, retira `-50` y luego vuelve a +leerlo a través del `GET` cacheado: + +```rust +// after a deposit(+250) and a withdraw(-50) on an opening balance of 100: +let view: WalletView = get_wallet(&app, &opened.id).await; +assert_eq!(view.balance, 300); // read-after-write is honest +assert_eq!(view.version, 3); +``` + +Qué acaba de pasar: cada llamada mutadora invalidó la familia `GetWallet`, así que +el `GET` final volvió a ejecutar la consulta contra el modelo de lectura en lugar +de reproducir una vista cacheada obsoleta. El saldo refleja ambas escrituras +(`100 + 250 - 50 = 300`) y la versión es `3` (un evento por mutación encima de la +apertura). + +> **Tip** **Punto de control.** Ejecuta los tests HTTP del monedero: +> `cargo test -p lumen deposit_and_withdraw_update_the_balance`. Un test en verde +> significa que el ciclo de lectura-tras-escritura se cierra: la caché se respeta +> *y* se invalida. + +## Paso 4 — Sigue la lectura hasta el cache port + +Todo lo de los Pasos 1-3 se ejecuta sobre una caché en proceso por defecto, pero el +`QueryCache` —como cualquier otro consumidor de caché— depende en última instancia +del cache port abstracto `Adapter`, nunca de un cliente concreto. Esa única costura +es lo que te permite mover la caché de Lumen a Redis sin tocar un solo handler. + +Aquí está el port (de `firefly-cache`, accesible como `firefly::cache::Adapter`): + +```rust,ignore +use std::time::Duration; +use async_trait::async_trait; + +#[async_trait] +pub trait Adapter: Send + Sync { + /// Returns the cached bytes for `key`, or `CacheError::NotFound` when absent. + async fn get(&self, key: &str) -> Result, CacheError>; + + /// Stores `value` under `key` for `ttl` (None or zero = no expiry). + async fn set(&self, key: &str, value: &[u8], ttl: Option) -> Result<(), CacheError>; + + /// Removes the entry. A missing key is a no-op. + async fn delete(&self, key: &str) -> Result<(), CacheError>; + + /// Removes every entry. + async fn clear(&self) -> Result<(), CacheError>; + + /// Human-readable adapter identifier (`memory`|`redis`|`noop`|...). + fn name(&self) -> String; + + /// Returns Ok when the backend is reachable. + async fn health_check(&self) -> Result<(), CacheError>; + + // The methods below ship default impls so older adapters keep compiling; + // backends with a cheaper native path (Redis SET NX, SCAN MATCH) override them. + + /// Writes only when `key` is absent; true when the write happened. + async fn set_if_absent(&self, key: &str, value: &[u8], ttl: Option) -> Result; + + /// Whether a live entry exists for `key`. + async fn exists(&self, key: &str) -> Result; + + /// Removes every entry whose key starts with `prefix`; returns the count. + async fn delete_prefix(&self, prefix: &str) -> Result; + + /// A point-in-time counter snapshot, or None when the adapter has none. + async fn stats(&self) -> Option; +} +``` + +Qué acaba de pasar: los valores cruzan el port como `Vec` en bruto; el propio +port no sabe nada de tus tipos. Un fallo de caché se señaliza con la variante +`CacheError::NotFound` (no con un `Option`), y un `ttl` de `None` (o cero) significa +«sin expiración». Los cuatro métodos del trait con implementaciones por defecto +(`set_if_absent`, `exists`, `delete_prefix`, `stats`) permiten que un adaptador se +publique sin ellos y permiten que un backend más rico los sobrescriba con una ruta +nativa más barata. + +> **Note** **Término clave — adaptador.** Un *adaptador* es una implementación +> concreta de un port. Firefly aporta varios, y eliges uno en tiempo de cableado: + +| Implementación | Respaldo | Uso | +|-----------------------|------------------------|-------------------------------------------| +| `MemoryAdapter` | `HashMap` + `RwLock` | en proceso, consciente del TTL — **el predeterminado** | +| `NoOpAdapter` | ninguno | tests / una caché deshabilitada a propósito | +| `FallbackAdapter` | compuesto (dos ports) | primario-y-luego-secundario, escribe en ambos | +| `RedisAdapter` | Redis (RESP) | caché distribuida (`firefly-cache-redis`) | + +Un `NoOpAdapter` reporta todo `get` como `NotFound` y tiene éxito en silencio en +toda escritura: es la caché que no hace nada, perfecta para un test que quiere que +el handler se ejecute siempre. `MemoryAdapter` es el mapa en proceso, vivo y +consciente del TTL, que Lumen usa de fábrica. + +> **Tip** **Punto de control.** Sabes nombrar los cuatro adaptadores y decir cuál es +> el predeterminado (`MemoryAdapter`) y cuál deshabilita la caché (`NoOpAdapter`). +> Todos ellos son `firefly::cache::*` e implementan el único trait `Adapter`. + +## Paso 5 — Memoiza un valor fuera del bus de consultas + +`QueryCache` indexa y serializa los resultados de las consultas por ti. Pero cuando +quieres cachear algo que *no* fluye por el bus de consultas —digamos, la puntuación +de riesgo de un monedero obtenida de un servicio externo— el envoltorio `Typed` +es la primitiva. + +> **Note** **Término clave — `Typed`.** `Typed` envuelve un `Adapter` con +> ayudantes de lectura/escritura codificados en JSON para un tipo concreto `T`. +> Serializa los valores como bytes `serde_json` (compatibles a nivel de cable con +> los demás ports) y te da `get_or_set`: consulta la caché, llama al cargador en un +> fallo, persiste el resultado y lo devuelve. Un error de caché nunca enmascara un +> resultado exitoso del cargador. + +```rust +use std::sync::Arc; +use std::time::Duration; +use firefly::cache::{MemoryAdapter, Typed}; + +#[derive(serde::Serialize, serde::Deserialize)] +struct WalletView { + id: String, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), firefly::cache::CacheError> { + let cache = Arc::new(MemoryAdapter::new()); + let typed: Typed = Typed::new(cache); + + let view = typed + .get_or_set("wallet:wlt_alice", Some(Duration::from_secs(60)), || async { + // loaded from the ledger / read model on a miss + Ok(WalletView { id: "wlt_alice".into() }) + }) + .await?; + assert_eq!(view.id, "wlt_alice"); + Ok(()) +} +``` + +Qué acaba de pasar, bloque a bloque: + +- `Arc::new(MemoryAdapter::new())` construye la caché a nivel de bytes y la envuelve + en un `Arc` para que `Typed` pueda compartirla. `Typed::new(cache)` superpone + codificación JSON para el tipo `WalletView`. +- `get_or_set(key, ttl, loader)` es la llamada read-through. En la primera ejecución + la clave está ausente, así que el cierre del cargador se ejecuta, su `WalletView` + se codifica en JSON y se almacena bajo la clave durante 60 segundos, y el valor se + devuelve. Una segunda llamada dentro de los 60 segundos se salta el cargador y + decodifica los bytes almacenados. +- El cargador devuelve `Result`: cualquier error en su + interior aflora; pero un fallo al *escribir* el valor cargado de vuelta en la + caché **no** enmascara una carga exitosa (el valor se devuelve igualmente). + +`Typed` también ofrece `put` (escribe y devuelve siempre —la ruta de +almacenamiento incondicional—), `delete` (elimina una clave) y `delete_prefix` +(expulsa una familia de claves), pero `get_or_set` es el caballo de batalla. + +> **Tip** **Punto de control.** Sabes describir los tres resultados de `get_or_set`: +> un acierto (decodifica y devuelve, sin cargador), un fallo (ejecuta el cargador, +> almacena, devuelve) y un fallo-de-almacenamiento-tras-carga (devuelve el valor de +> todos modos, descarta el error de escritura). + +## Paso 6 — Intercambia y compón backends en un único punto de cableado + +La caché predeterminada es `MemoryAdapter`. ¿Dónde vive ese valor por defecto? +`Core::new` (y por tanto `WebStack`, sobre el que se construye Lumen) lee +`CoreConfig.cache: Option>` y sustituye un `MemoryAdapter` +cuando es `None`. Para usar un backend distinto, le pasas un `Arc` +diferente ahí: un constructor, nada más. + +> **Note** **Término clave — `FallbackAdapter`.** Un `FallbackAdapter` es él mismo un +> `Adapter` que envuelve un *primario* y un *secundario*: intenta primero el +> primario y, ante un fallo de transporte (cualquier cosa que no sea un simple +> fallo de caché), degrada la petición al secundario y escribe en ambos. Los +> consumidores nunca ven la conmutación por error (failover): solo ven un `Adapter`. + +Para alta disponibilidad, compón Redis con un fallback en proceso de modo que un +parpadeo de Redis degrade a caché local en lugar de hacer fallar la petición: + +```rust,ignore +use std::sync::Arc; +use firefly::cache::{FallbackAdapter, MemoryAdapter, RedisAdapter}; + +// Connect the distributed primary (RESP over the network)... +let redis = Arc::new(RedisAdapter::connect("redis://127.0.0.1:6379/0").await?); + +// ...and fall through to a local in-process cache on a transport error or miss, +// writing to both so the local layer warms up. +let cache: Arc = + Arc::new(FallbackAdapter::new(redis, Arc::new(MemoryAdapter::new()))); +``` + +Qué acaba de pasar: `RedisAdapter::connect(url)` marca a Redis y devuelve un +adaptador listo; `FallbackAdapter::new(primary, secondary)` lo compone con un +`MemoryAdapter`. El compuesto es *también* un `Adapter`, así que se lo entregas a +`CoreConfig.cache` exactamente igual que harías con cualquier backend individual. + +Como todo lo que hay aguas abajo depende del port, cambiar el backend modifica *un* +constructor: los handlers de Lumen, el `QueryCache` y el almacén de sesiones quedan +intactos. Un Lumen de un solo proceso conserva la caché en memoria predeterminada; +un despliegue multinodo intercambia `RedisAdapter` de modo que un `GetWallet` +cacheado en un nodo sea visible en el siguiente, y de modo que un `invalidate_type` +en cualquier nodo limpie la entrada compartida. + +> **Design note.** Esto refleja el intercambio del event store y del broker que ya +> has visto: desarrolla y prueba contra el adaptador en memoria, cablea el backend +> distribuido en producción vía `CoreConfig`. La base de enseñanza sigue siendo un +> `cargo run` sin infraestructura; la ruta de producción es una línea de cableado. +> [Producción y despliegue](./20-production.md) hace exactamente este intercambio de +> verdad. + +> **Tip** **Punto de control.** Sabes nombrar la única costura —`CoreConfig.cache`— +> que cambia el backend de caché para todo el servicio, y explicar por qué ningún +> handler, `QueryCache` o controlador tiene que cambiar cuando la intercambias. + +## Paso 7 — Protege el cargador con decoradores de resiliencia + +Un fallo de caché recurre a una llamada lenta: el modelo de lectura, el event store +o un servicio externo. Si esa llamada se cuelga o empieza a fallar, un cargador sin +protección puede arrastrar consigo todo el servicio. `firefly-resilience` protege +exactamente eso, y cualquier llamada saliente, como la liquidación de Payments de +[Clientes HTTP](./13-http-clients.md). + +> **Note** **Término clave — circuit breaker.** Un *circuit breaker* vigila una +> llamada protegida. Mientras está *cerrado*, las llamadas pasan y se cuentan los +> fallos; tras suficientes fallos se *abre* y cortocircuita las llamadas posteriores +> con un error inmediato, evitando molestar a la dependencia enferma; tras un +> enfriamiento deja pasar una llamada de prueba (*half-open*) para decidir si +> cerrarse de nuevo. Esto es el `CircuitBreaker` de Resilience4j. + +Hay cuatro decoradores, cada uno protegiendo de un modo de fallo: + +| Decorador | Protege contra | Error al saltar | +|------------------|----------------------------------------------|---------------------------------| +| `CircuitBreaker` | fallo en cascada de una dependencia lenta / fallida | `ResilienceError::CircuitOpen` | +| `RateLimiter` | exceso de la tasa de salida (token bucket) | `ResilienceError::RateLimited` | +| `Bulkhead` | agotamiento de recursos por concurrencia desbocada | `ResilienceError::BulkheadFull` | +| `Timeout` | llamadas atascadas | `ResilienceError::Timeout` | + +> **Note** **Término clave — `Chain`.** Un `Chain` compone decoradores en una única +> llamada protegida. Los decoradores se ejecutan de izquierda a derecha con el de +> más a la izquierda como el más externo, así que +> `Chain::new().with(timeout).with(breaker).with(bulkhead)` se evalúa como +> `timeout(breaker(bulkhead(call)))`: una fecha límite acota toda la llamada +> mientras el breaker y el bulkhead protegen la operación interna. + +```rust,no_run +use std::{sync::Arc, time::Duration}; +use firefly::resilience::{Bulkhead, Chain, CircuitBreaker, CircuitConfig, Timeout}; + +# async fn ex() -> Result<(), firefly::resilience::ResilienceError> { +let breaker = Arc::new(CircuitBreaker::new(CircuitConfig::default())); + +let guarded = Chain::new() + .with(Timeout::new(Duration::from_secs(2))) // per-call deadline (outermost) + .with_shared(breaker.clone()) // open the circuit on repeated failures + .with(Bulkhead::new(20)); // cap concurrent in-flight calls + +guarded.execute(|| async { + // the protected operation — a cache loader, an upstream call, ... + Ok(()) +}).await?; +# Ok(()) +# } +``` + +Qué acaba de pasar, línea a línea: + +- `CircuitBreaker::new(CircuitConfig::default())` construye un breaker con la + política por defecto (salta tras 5 fallos, permanece abierto 30 segundos). Va + envuelto en `Arc` para que puedas a la vez entregarlo a la cadena *y* conservar un + handle para inspeccionar su estado. +- `Chain::new()` arranca una cadena vacía. `.with(decorator)` añade un decorador del + que la cadena es *propietaria*; `.with_shared(arc_decorator)` añade uno del que + conservas un handle; por eso el breaker usa `.with_shared(breaker.clone())` + mientras que el `Timeout` y el `Bulkhead` recién construidos usan `.with(...)`. +- `guarded.execute(|| async { ... })` ejecuta tu cierre a través de los tres + decoradores, el de más a la izquierda como el más externo. El cierre devuelve + `Result<(), ResilienceError>`; si cualquier decorador salta, `execute` devuelve el + error de ese decorador y puede que tu operación no llegue a ejecutarse nunca. + +> **Warning** `Chain::with(...)` toma la propiedad y exige que su argumento +> implemente el trait de decorador directamente; un `Arc` pelado +> *no* lo hace. Cuando quieres conservar un handle a un breaker (para leer su +> estado, o para compartirlo entre cadenas), usa `.with_shared(breaker.clone())`, +> que toma el `Arc`. Usar `.with(breaker)` sobre un `Arc` no compilará. + +Cada decorador también funciona por sí solo. A diferencia de `Chain::execute` (cuyo +valor descartas), `CircuitBreaker::execute` *devuelve el valor de la operación*, así +que una lectura protegida sigue entregándote el `WalletView`: + +```rust,ignore +use std::time::Duration; +use firefly::resilience::{Bulkhead, CircuitBreaker, CircuitConfig, RateLimiter, Timeout}; + +let cb = CircuitBreaker::new(CircuitConfig::default()); +let _ = cb.execute(|| async { settle().await }).await; // returns settle()'s value + +let rl = RateLimiter::new(100.0, 200); // 100 rps, burst 200 +let _ = rl.execute(|| async { call().await }).await; + +let bh = Bulkhead::new(20); +let _ = bh.try_execute(|| async { call().await }).await; // non-blocking; BulkheadFull if full + +let to = Timeout::new(Duration::from_secs(2)); +let _ = to.execute(|| async { slow_call().await }).await; +``` + +Qué acaba de pasar: cada primitiva tiene su propio `execute` que envuelve un cierre +que devuelve `Result` y propaga el valor de la operación en caso +de éxito. `Bulkhead` ofrece además `try_execute`, la variante no bloqueante que +devuelve `BulkheadFull` de inmediato en lugar de esperar a una plaza libre. + +> **Tip** **Punto de control.** Sabes explicar la diferencia entre `Chain::execute` +> (valor descartado, devuelve `Result<(), _>`) y `CircuitBreaker::execute` (devuelve +> el `T` de la operación), y sabes recurrir a `.with_shared(arc.clone())` cuando la +> cadena necesita un breaker que aún conservas. + +## Paso 8 — Monta una lectura cache-aside resiliente + +Las dos mitades de este capítulo se componen en una única forma: una lectura +cache-aside cuyo cargador está protegido por un circuit breaker. Esto es +exactamente lo que usaría un Lumen multinodo para servir una vista de monedero +desde Redis, reparando desde el modelo de lectura (o el flujo de eventos) en un +fallo mientras el breaker protege esa reparación: + +```rust,ignore +use std::sync::Arc; +use std::time::Duration; +use firefly::cache::{MemoryAdapter, Typed}; +use firefly::resilience::{CircuitBreaker, CircuitConfig}; + +let typed: Typed = Typed::new(Arc::new(MemoryAdapter::new())); +let breaker = CircuitBreaker::new(CircuitConfig::default()); + +let view = typed + .get_or_set("wallet:wlt_alice", Some(Duration::from_secs(30)), || async { + // the loader is what the circuit protects: the read model / event store. + breaker + .execute(|| async { load_wallet_view("wlt_alice").await }) + .await + .map_err(|e| firefly::cache::CacheError::Backend(e.to_string())) + }) + .await?; +``` + +Qué acaba de pasar: `get_or_set` es la lectura cache-aside externa. En un acierto +devuelve el `WalletView` decodificado y el cargador nunca se ejecuta. En un fallo el +cargador ejecuta la reparación real —pero envuelta en `breaker.execute(...)`—, así +que una racha de fallos abre el circuito y el *siguiente* fallo falla rápido con +`CircuitOpen` en lugar de machacar un modelo de lectura enfermo. El `map_err` adapta +el `ResilienceError` a un `CacheError::Backend` para que encaje con el tipo de error +de `get_or_set`. + +Ahora tienes una ruta de lectura rápida y resiliente —construida a mano a partir de +las dos primitivas—, y la misma forma que el `#[firefly(cache_ttl = "30s")]` +declarativo de Lumen te da gratis sobre el bus de consultas. + +> **Tip** **Punto de control.** Sabes seguir la estratificación: `get_or_set` +> (cache-aside) envuelve `breaker.execute` (protección frente a fallos) envuelve el +> cargador real (modelo de lectura / event store). La caché absorbe la ruta feliz; +> el breaker absorbe la ruta de fallo. + +## Resumen — lo que ahora entiendes sobre la caché de Lumen + +- La caché del lado de lectura que Lumen usa desde [CQRS](./09-cqrs.md) es + *declarativa*: `#[firefly(cache_ttl = "30s")]` sobre `GetWallet` lo respeta el + middleware de bus de caché de lecturas que `FireflyApplication` autoinstala + siempre que hay presente un `#[bean]` `QueryCache`. +- El `QueryCache` es un único bean —instalado como middleware de bus por el + framework y `#[autowired]` en el controlador—, así que cada handler mutador + (depósito, retirada **y** transferencia) llama a + `invalidate_type::()` para mantener honesta la lectura-tras-escritura + dentro del TTL de 30 segundos. +- Bajo el `QueryCache` está el cache port intercambiable `Adapter`: `MemoryAdapter` + por defecto, `NoOpAdapter` para deshabilitar la caché, `FallbackAdapter` para + Redis-con-fallback-local y `RedisAdapter` para un despliegue multinodo, elegido en + un único punto de cableado, `CoreConfig.cache`. +- `Typed::get_or_set` es la primitiva de memoización read-through para valores + fuera del bus de consultas; un fallo de escritura tras una carga exitosa nunca + enmascara el valor. +- `firefly-resilience` aporta `CircuitBreaker`, `RateLimiter`, `Bulkhead` y + `Timeout`, componibles a través de un `Chain`, para proteger tanto un cargador de + caché como cualquier llamada saliente (como la liquidación de Payments de + [Clientes HTTP](./13-http-clients.md)). + +## Ejercicios + +1. **Demuestra que el TTL es real.** Escribe un test que abra un monedero, lo lea + (cebando la caché), luego deposite *directamente a través del ledger* (saltándose + el controlador, de modo que no se ejecute ningún `invalidate_type`) y vuelva a + leerlo dentro de los 30 segundos. Comprueba que sigues viendo el saldo *antiguo* + —demostrando que el TTL está sirviendo genuinamente un valor memoizado—, luego + llama a `query_cache.invalidate_type::()` y comprueba que la siguiente + lectura refleja el depósito. + +2. **Deshabilita la caché con `NoOpAdapter`.** El propio `QueryCache` está en + memoria, pero la caché a nivel de bytes que usa el resto del servicio es + `CoreConfig.cache`. Construye un `CoreConfig` con + `cache: Some(Arc::new(NoOpAdapter::default()))`, arranca el servicio y confirma + que la caché a nivel de bytes reporta siempre un fallo mientras el flujo del + monedero sigue pasando: útil cuando quieres medir la latencia de la ruta fría. + +3. **Intercambia un adaptador de fallback.** Construye un `FallbackAdapter` cuyo + primario siempre dé error en `get`/`set` (un `Adapter` hecho a mano que devuelva + `CacheError::Backend(...)`) y cuyo secundario sea un `MemoryAdapter`. Cablealo en + `CoreConfig.cache`, ejecuta el flujo de depósito/retirada/lectura y comprueba que + la corrección no se ve afectada: la caché degrada a la capa en proceso en lugar + de fallar. + +4. **Protege un cargador con un `Chain`.** Envuelve un cargador deliberadamente lento + en un `Chain::new().with(Timeout::new(Duration::from_millis(50)))` y comprueba que + un cargador que exceda la fecha límite aflora `ResilienceError::Timeout` (comprueba + `err.is_timeout()`) en lugar de quedarse colgado. Luego añade + `.with_shared(breaker.clone())` para un `CircuitBreaker`, hazlo saltar con fallos + repetidos y comprueba que la siguiente llamada falla rápido con + `ResilienceError::CircuitOpen` (comprueba `err.is_circuit_open()`). + +5. **Memoiza fuera del bus.** Usa `Typed::get_or_set` para cachear un valor + calculado (p. ej. la puntuación de riesgo de un monedero) bajo un TTL de 10 + segundos. Llámalo dos veces con un cargador que incremente un contador y comprueba + que el contador avanzó solo una vez, demostrando que la segunda llamada acertó en + la caché en lugar de reejecutar el cargador. + +## Adónde ir después + +- Mira cómo *cada* declaración de este capítulo —`#[firefly(cache_ttl)]`, + `#[bean]`, `#[autowired]`, `#[rest_controller]`— la produce la capa de macros de + Firefly en **[Servicios declarativos con macros](./21-declarative-macros.md)**. +- Dirige el ciclo de lectura-tras-escritura cacheado de extremo a extremo, en + proceso y sin enlazar a ningún socket, en **[Testing](./18-testing.md)**. +- Realiza el intercambio de caché en memoria → Redis para un despliegue real en + **[Producción y despliegue](./20-production.md)**. diff --git a/docs/book/src-es/18-testing.md b/docs/book/src-es/18-testing.md new file mode 100644 index 00000000..64a35a5f --- /dev/null +++ b/docs/book/src-es/18-testing.md @@ -0,0 +1,800 @@ +# Pruebas + +Cada capítulo hasta ahora ha mostrado los listados de Lumen *y* las pruebas que +los mantienen honestos: ese es justamente el propósito del libro, la prosa se +verifica contra un crate que compila y supera su batería de pruebas. Este +capítulo da un paso atrás respecto de cualquier funcionalidad concreta y +contempla la estrategia de pruebas en su conjunto, tal como la diseñarías para tu +propio servicio. Aquí no aprenderás ni una sola regla de negocio nueva; +aprenderás a demostrar las que ya escribiste, en tres niveles, sin arrancar un +servidor ni iniciar una base de datos. + +La buena noticia es que el stack «primero en memoria» de Firefly convierte casi +toda prueba en una simple llamada a función. La infraestructura por defecto de +Lumen —su event store, su event broker y su read model— es Rust puro ejecutándose +en el propio proceso, así que una prueba nunca enlaza un socket, nunca abre una +conexión y nunca espera a un contenedor. El resultado es una batería de pruebas +rápida, determinista y verde en un portátil sin nada instalado. + +Al terminar este capítulo, serás capaz de: + +- Comprender los tres niveles de pruebas de Firefly —pruebas unitarias puras, + pruebas HTTP en proceso que ejercitan el router *real*, y pruebas de + integración condicionadas por variables de entorno contra infraestructura + real— y cuándo recurrir a cada uno. +- Ejercitar en proceso un router de aplicación completamente cableado con + `bootstrap()` y `tower::oneshot`, sin enlazar ningún socket y sin mocks. +- Usar los ayudantes de `firefly-testkit` —`TestClient`, `Slice`, + `assert_event_published` y los firmantes de webhooks— para escribir las mismas + pruebas de forma mucho más concisa. +- Construir una porción de inyección de dependencias acotada a una única unidad, + instalar un colaborador falso (el análogo de `@MockBean`) y ejercitar un + controlador sobre mocks (el análogo de `@WebMvcTest`). +- Escribir una prueba de integración que use Postgres o Kafka reales cuando estén + presentes y que **se omita limpiamente** cuando no lo estén, de modo que + `cargo test` se mantenga verde en todas partes. + +## Conceptos que conocerás + +Antes de la primera prueba, estas son las ideas en las que se apoya este +capítulo. Cada una se reintroduce en su contexto la primera vez que se usa; esta +es la versión breve. + +> **Note** **Término clave — nivel de pruebas (testing tier).** Un *nivel* es una +> capa de la pirámide de pruebas: pruebas unitarias puras en la base (las más +> rápidas y numerosas), pruebas HTTP/de porción en proceso en el medio, y pruebas +> de integración contra infraestructura real en la cúspide (las más lentas y +> escasas). Firefly te ofrece un ayudante conciso por nivel. La división refleja +> el stack de pruebas de JUnit + Spring Boot: `@Test` simple, `@SpringBootTest` / +> `@WebMvcTest`, y `@Testcontainers`. + +> **Note** **Término clave — prueba HTTP en proceso.** Una prueba *en proceso* +> ejercita el router HTTP real entregándole un `Request` y haciendo `await` +> directamente sobre el `Response`; no se abre ningún puerto ni se lanza ninguna +> tarea de servidor. Tiene la velocidad de una prueba unitaria con la cobertura de +> una prueba de extremo a extremo. El análogo en Spring es `MockMvc` (y el +> `WebTestClient` de Spring en modo `MOCK`). + +> **Note** **Término clave — costura de pruebas (test seam).** Una *costura* es un +> punto que el framework expone específicamente para que las pruebas puedan llegar +> a su interior. La costura de Firefly es `bootstrap()`: ensambla la misma +> aplicación completamente cableada que serviría `run()`, pero la devuelve como un +> valor *sin* enlazar un socket. El `@SpringBootTest` de Spring arranca el mismo +> contexto que el `main` de producción; `bootstrap()` es su análogo en Rust. + +> **Note** **Término clave — mock / fake.** Un *fake* es un colaborador +> sustituto que instalas en lugar del real: un repositorio en memoria en vez de +> una base de datos, un servicio predefinido en vez de una llamada de red. +> Instalar uno es la jugada del `@MockBean` de Spring: sobrescribir un bean bajo +> su port para que la unidad bajo prueba cablee el fake en lugar de la +> implementación real. + +## El modelo de pruebas en proceso + +El stack por defecto de Lumen es enteramente en memoria —un `MemoryEventStore`, +un `InMemoryBroker` y un read model basado en `Mutex`— de modo que casi +toda prueba se ejecuta como un simple `#[tokio::test]` **sin socket ni servicio +externo**. Incluso las pruebas HTTP no enlazan un puerto: entregan un `Request` al +router y hacen `await` del `Response`. Ese único hecho es lo que hace que la +batería sea rápida y amigable con CI, y conviene enunciarlo desde el principio +porque cada nivel descrito más abajo se construye en torno a él. + +El modelo tiene una regla organizadora: cada prueba arranca **un** contexto de +aplicación y ejercita cada petición contra él. Es exactamente el modelo de +`@SpringBootTest` de Spring Boot —un contexto cableado por método de prueba— y en +Lumen el ayudante que te lo proporciona es `build_router()`: + +```rust,ignore +// src/web.rs — the test seam, compiled only under #[cfg(test)]. +#[cfg(test)] +pub(crate) async fn build_router() -> axum::Router { + firefly::FireflyApplication::new(APP_NAME) + .version(VERSION) + .bootstrap() + .await + .expect("lumen bootstrap") + .api_router +} +``` + +Lo que acaba de ocurrir: `bootstrap()` ejecuta la misma tubería de arranque que +`run()` —escanea los componentes del contenedor de DI, automonta cada +`#[rest_controller]`, autodescubre la seguridad y el middleware, vacía los +handlers de CQRS / listeners de EDA / tareas `#[scheduled]` registrados en el +inventario— y devuelve un valor `Bootstrapped` en lugar de servirlo. Su campo +`.api_router` es el `axum::Router` público, completamente cableado, sin ningún +listener enlazado. `build_router()` es simplemente `main()` menos el paso de +servicio `.run()`. + +> **Note** **Término clave — costura de bootstrap.** `bootstrap()` es el hermano +> de `run()` que conociste en [Quickstart](./02-quickstart.md): `run()` ensambla +> la aplicación *y la sirve*; `bootstrap()` ensambla la aplicación idéntica y +> devuelve el handle `Bootstrapped` para que una prueba pueda ejercitar +> `Bootstrapped::api_router` en proceso. Los mismos beans, el mismo cableado, sin +> socket. + +Como los handlers de CQRS (`WalletHandlers`) y la proyección del read model +(`WalletProjection`) son **beans de DI autocableados** —no funciones libres sobre +un estado global de proceso— el contenedor de cada prueba es autoconsistente. Los +singletons `Ledger`, `ReadModel` y `QueryCache` que un contenedor resuelve son las +*mismas* instancias que comparten cada handler y la proyección. Así, la cartera +que abre un comando es la cartera que lee una consulta posterior, porque ambos se +ejecutan contra el único contenedor que la prueba arrancó. Y como un +`axum::Router` es barato de clonar (`clone`) (está respaldado por `Arc`), cada +petición clona la aplicación compartida en lugar de reconstruirla. + +> **Tip** **Punto de control.** Ya puedes ejecutar toda la batería de pruebas. +> Desde la raíz del workspace, `cargo test -p firefly-sample-lumen` compila Lumen y +> ejecuta sus pruebas; deberías ver pasar `42 unit + 12 HTTP + 1 doctest`. El resto +> de este capítulo explica qué *son* esas pruebas. + +## Nivel 1 — Pruebas unitarias sin infraestructura + +El nivel inferior no necesita nada: ni router, ni contenedor, ni E/S. El value +object y el agregado de Lumen son Rust puro, así que sus pruebas construyen un +valor y comprueban un invariante directamente. `money.rs` y `domain.rs` +verifican la aritmética exacta en céntimos, los importes positivos, los fondos +suficientes y la regla de «propietario obligatorio» con simples `assert!`. + +La capa de CQRS es igual de directa. Los handlers viven en un bean +`#[derive(Service)]` (`WalletHandlers`) cuyos colaboradores —el `Ledger` del lado +de escritura y el `ReadModel` del lado de lectura— se `#[autowired]` desde el +contenedor en el arranque. Pero nada te impide construir el bean tú mismo con esos +colaboradores en mano y llamar a un método directamente. Este es el núcleo del +módulo de pruebas de `commands.rs`: + +```rust,ignore +use firefly::eda::InMemoryBroker; +use firefly::eventsourcing::MemoryEventStore; + +#[tokio::test] +async fn handler_bean_operates_on_its_autowired_collaborators() { + // Build the handler bean with the same Ledger + ReadModel the container + // would inject — no bus, no process-global, no boot. + let handlers = WalletHandlers { + ledger: Arc::new(Ledger::new( + Arc::new(MemoryEventStore::new()), + Arc::new(InMemoryBroker::new()), + )), + read_model: Arc::new(ReadModel::default()), + }; + + let opened = handlers + .open_wallet(OpenWallet { owner: "alice".into(), opening_balance: 100 }) + .await + .unwrap(); + assert_eq!(opened.balance, 100); + + let after = handlers + .deposit(Deposit { wallet_id: opened.id.clone(), amount: 50 }) + .await + .unwrap(); + assert_eq!(after.balance, 150); +} +``` + +Lo que acaba de ocurrir, y por qué importa: construiste el bean de handlers a mano +con un `Ledger` en memoria y un `ReadModel` recién creado, luego llamaste a +`open_wallet` y `deposit` directamente y comprobaste los balances devueltos. Sin +despacho por el bus, sin contenedor de DI, sin HTTP. El arranque completo de la +aplicación instala el *mismo* bean en el bus vaciando el registro de inventario +(`register_discovered_handlers`), así que esta prueba ejercita la lógica real del +handler sin levantar nada de eso. Cuando quieras saber «¿hace el handler la +aritmética correcta?», este es el lugar más barato para averiguarlo. + +La validación se prueba del mismo modo, sin tocar HTTP en ningún momento. +`OpenWallet` lleva `#[derive(Command)]`, que generó un `.validate()` a partir de +sus campos `#[firefly(validate)]`, así que lo invocas sobre el comando +directamente: + +```rust,ignore +#[test] +fn open_wallet_validates_owner() { + assert!(OpenWallet::default().validate().is_err()); // empty owner fails + assert!(OpenWallet { owner: "alice".into(), opening_balance: 0 }.validate().is_ok()); +} +``` + +Lo que acaba de ocurrir: el valor por defecto vacío falla la validación (sin +propietario), y un comando bien formado pasa, todo antes de que se ejecute ningún +handler. La capa web nunca ve un comando inválido porque el bus lo rechaza antes; +esta prueba fija ese rechazo al nivel más barato posible. + +> **Note** La seguridad (`security.rs`), la saga de transferencia (`transfer.rs`), +> el flujo de cumplimiento (`compliance.rs`), la transferencia en dos fases +> (`tcc_transfer.rs`) y la tarea programada (`housekeeping.rs`) llevan cada una su +> propio `#[cfg(test)] mod tests` en el mismo espíritu: acuñar y luego verificar un +> token, ejecutar el camino feliz de la saga *y* su camino de compensación, +> ejecutar las ramas de aprobación/rechazo del flujo, y registrar el latido y +> comprobar que «tictaquea». Estos son los capítulos +> [Seguridad](./14-security.md), [Sagas, Workflows y TCC](./12-sagas.md) y +> [Programación y Notificaciones](./16-scheduling-notifications.md) demostrándose a +> sí mismos. + +> **Tip** **Punto de control.** En conjunto, estas pruebas suman las **42 pruebas +> unitarias** de Lumen: los invariantes de `money` y `domain`, la validación de +> `commands` más el bean de handlers, el acuñar/verificar/rechazar de `security`, +> el camino feliz + compensación de `transfer`/`tcc_transfer`, el +> aprobar/rechazar de `compliance`, y el registro + tick de `housekeeping`. +> Ejecuta `cargo test -p firefly-sample-lumen --lib` para ver solo estas. + +## Nivel 2 — Pruebas HTTP en proceso con `tower::oneshot` + +El nivel intermedio demuestra que todo el stack se compone. La batería de extremo +a extremo de Lumen vive en `src/http_test.rs` —un `#[cfg(test)] mod http_test` +declarado en `main.rs`, de modo que se ejecuta como parte del propio target de +pruebas del binario— y ejercita el `build_router()` **completamente cableado**: +las rutas `#[rest_controller]` automontadas, el bean de handlers de CQRS, el +ledger con event sourcing, el bean de proyección del read model, la saga de +transferencia *y* la aplicación de JWT/RBAC autodescubierta de +[Seguridad](./14-security.md). Sin mocks: cada capa es la capa de producción, solo +que sobre infraestructura en memoria. + +> **Note** **Término clave — `tower::oneshot`.** `oneshot` (de +> `tower::ServiceExt`) envía exactamente una petición a través de un `Service` +> —aquí un `axum::Router`— y se resuelve a su `Response`, luego descarta el +> servicio. Es la forma de llamar a un router como una simple función asíncrona. El +> tipo de cuerpo del router proviene de `http_body_util::BodyExt`, que usas para +> recopilar los bytes de la respuesta. + +### Paso 1 — Escribir los ayudantes de petición/respuesta + +El patrón es un `Router` por prueba más `oneshot` por petición. Una prueba arranca +la aplicación una vez con `let app = build_router().await` y ejercita cada +petición contra ella; un pequeño ayudante `send` clona el `&Router` compartido por +petición de modo que todas comparten el único contenedor. Estos son los ayudantes +que `http_test.rs` define una sola vez al inicio del archivo: + +```rust,ignore +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::response::Response; +use axum::Router; +use http_body_util::BodyExt; +use tower::ServiceExt; + +/// Sends one request against the (cloned) shared app and returns the response. +async fn send(app: &Router, req: Request) -> Response { + app.clone().oneshot(req).await.unwrap() +} + +/// Builds a POST with a JSON body, optionally carrying a bearer token. +fn post(path: &str, body: serde_json::Value, auth: bool) -> Request { + let mut b = Request::post(path).header("content-type", "application/json"); + if auth { + b = b.header("authorization", bearer()); // "Bearer " + } + b.body(Body::from(serde_json::to_vec(&body).unwrap())).unwrap() +} + +/// Buffers the response body and decodes it as JSON into `T`. +async fn body_json(res: Response) -> T { + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} +``` + +Lo que acaba de ocurrir: `send` es todo el mecanismo —`app.clone().oneshot(req)` +ejecuta la petición a través del router real en proceso—. `post` ensambla una +petición JSON y, cuando `auth` es verdadero, añade una cabecera +`Authorization: Bearer …` acuñada por `bearer()` (que llama al +`mint_token("u-alice", &[CUSTOMER_ROLE])` de Lumen del módulo de seguridad). +`body_json` drena el cuerpo de la respuesta con `BodyExt::collect` y lo +deserializa. Tres ayudantes, y cada prueba de más abajo se lee como un guion. + +### Paso 2 — Ejercitar un viaje de ida y vuelta a través de CQRS + +Con los ayudantes en su sitio, una prueba arranca la aplicación, abre una cartera +a través de la API pública y comprueba que la lectura proyectada vuelve a través +de CQRS, todo contra el único contexto de aplicación: + +```rust,ignore +#[tokio::test] +async fn open_then_get_round_trips_through_cqrs() { + let app = build_router().await; // one app context per test + let opened = open_wallet(&app, "alice", 1_000).await; // POST /api/v1/wallets, asserts 201 + assert_eq!(opened.owner, "alice"); + assert_eq!(opened.balance, 1_000); + + // GET dispatches the #[query_handler] on the handler bean; it reads the + // projection (or repairs from the event stream) — both resolved from the + // SAME container as the command that opened the wallet. + let fetched = get_wallet(&app, &opened.id).await; + assert_eq!(fetched.id, opened.id); + assert_eq!(fetched.balance, 1_000); +} +``` + +Lo que acaba de ocurrir, y por qué importa: el `POST` ejecutó un comando a través +del bus, que añadió eventos al ledger en memoria; el `GET` ejecutó una consulta +que leyó la proyección que esos eventos alimentaron. Ambos resolvieron el *mismo* +`Ledger` y `ReadModel` desde el único contenedor que la prueba arrancó, así que la +lectura ve la escritura. Esta única prueba demuestra que el lado de comandos, el +lado de consultas, la proyección y su cableado compartido encajan todos juntos +—algo que ninguna prueba unitaria puede mostrar, porque la costura que se prueba +*es* el cableado. + +### Paso 3 — Demostrar que los modos de fallo se renderizan como problemas + +El mismo archivo demuestra el camino feliz de la saga +(`transfer_saga_happy_path_moves_funds_between_wallets`), el camino de +compensación (`transfer_saga_overdraft_compensates_and_is_422`) y la +renderización como problema de los modos de fallo. Un token ausente es un 401, un +propietario vacío es un 422, y un id desconocido es un 404, cada uno comprobando +el tipo de contenido `application/problem+json`: + +```rust,ignore +#[tokio::test] +async fn missing_token_is_401_problem_on_mutations() { + let app = build_router().await; + let res = send( + &app, + post( + "/api/v1/wallets", + serde_json::json!({ "owner": "mallory", "openingBalance": 10 }), + false, // no Authorization header + ), + ) + .await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert!(content_type(&res).contains("application/problem+json")); +} +``` + +Lo que acaba de ocurrir: el `POST` sin autenticar fue rechazado por la capa de +seguridad autodescubierta con un 401, *y* el cuerpo volvió como un documento +`application/problem+json` conforme a la RFC 9457, no como un 401 en blanco. La +misma forma se mantiene para las pruebas del 422 (validación) y el 404 (cartera +desconocida). Esa única batería es la prueba de que todo el stack —enrutado, +seguridad, CQRS, event sourcing, sagas y renderización de problemas— se compone +correctamente. + +> **Note** **Término clave — respuesta de problema RFC 9457.** La RFC 9457 (que +> deja obsoleta la antigua RFC 7807) define `application/problem+json`: un cuerpo +> de error estructurado con un `type`, un `title`, un `status` y un `detail`. +> Firefly renderiza automáticamente todo error de handler y toda ruta no +> emparejada como uno de estos, razón por la cual las pruebas pueden comprobar el +> tipo de contenido. Conociste esto en [Tu primera API +> HTTP](./06-first-http-api.md). + +> **Tip** **Punto de control.** Estos doce escenarios —abrir → consultar → +> depositar/retirar → transferir (feliz + compensado) → flujo de cumplimiento → +> transferencia en dos fases → problemas 401/422/404— son las **12 pruebas HTTP** +> de Lumen. Ejecuta `cargo test -p firefly-sample-lumen --test '*' 2>/dev/null || +> cargo test -p firefly-sample-lumen` y observa pasar el módulo `http_test`. + +## El Nivel 2, a la manera concisa — el `firefly-testkit` + +Las propias pruebas HTTP de Lumen usan la forma cruda de `tower::oneshot` a +propósito, para mostrar el mecanismo sin magia. En *tu* servicio recurrirías a +`firefly-testkit`, que empaqueta exactamente ese código repetitivo en ayudantes +reutilizables. Es un crate aparte con niveles condicionados por features, así que +solo traes lo que usas: + +```toml +# Cargo.toml — add as a dev-dependency, switching on the helpers you need. +[dev-dependencies] +firefly-testkit = { version = "26.6.28", features = ["web", "container"] } +``` + +> **Note** La superficie por defecto (los firmantes de webhooks, `SpyBroker` y los +> ayudantes JSON) no acarrea dependencias pesadas. La feature `web` añade el +> `TestClient` en proceso; `container` añade el `Slice` de DI; y `testcontainers` +> añade los fixtures de pruebas de integración. Un servicio que solo firma webhooks +> obtiene una compilación ligera. + +Tres piezas son las que más importan. + +### TestClient — un cliente HTTP en proceso (feature `web`) + +`TestClient::new(router)` envuelve cualquier `Router` de axum y te ofrece +`get` / `post` / `put` / `patch` / `delete` (asíncronos) más una API de aserciones +fluida sobre el `TestResponse` que devuelve. La prueba `open_then_get` de más +arriba, reescrita con `TestClient`: + +```rust,ignore +use firefly_testkit::TestClient; + +#[tokio::test] +async fn open_then_get_with_testclient() { + let client = TestClient::new(build_router().await); + + let created = client + .post("/api/v1/wallets", &serde_json::json!({ "owner": "alice", "openingBalance": 1000 })) + .await; + created.assert_status(201); + let id = created.json_path("$.id").unwrap(); + + client + .get(&format!("/api/v1/wallets/{}", id.as_str().unwrap())) + .await + .assert_status(200) + .assert_json_path("$.balance", 1000); +} +``` + +Lo que acaba de ocurrir: `TestClient` se encargó por ti de construir la petición y +de bufferizar el cuerpo. `post(path, &body)` serializa el JSON y fija el +`content-type`; `assert_status` comprueba el código; `json_path("$.id")` +selecciona un único campo; y `assert_json_path("$.balance", 1000)` comprueba un +valor en lo profundo del cuerpo sin deletrear el documento entero. Cada aserción +devuelve `&Self`, así que se encadenan. + +La superficie de aserciones es: `assert_status`, `assert_success`, +`assert_header` / `assert_header_present`, `assert_body_contains`, +`assert_json_eq`, `assert_json_path` / `assert_json_path_exists` / +`assert_json_path_absent`, más los extractores `json::()`, +`json_path("$.field")`, `text()`, `header(name)` y `body_bytes()`. La gramática de +rutas es un subconjunto de JSONPath de resultado único: un `$` inicial, acceso a +miembros con punto (`$.user.name`) o con corchetes (`$['user']['name']`), e +indexado de arrays (`$[0]`, `$.items[2].id`); sin comodines, filtros ni descenso +recursivo. + +> **Note** Cada verbo tiene además una variante bloqueante —`get_blocking`, +> `post_blocking`, …— que ejecuta la petición sobre un runtime interno de hilo +> actual, de modo que un simple `#[test]` (sin `#[tokio::test]`) se lee +> exactamente como un cliente HTTP síncrono. Usa la forma bloqueante fuera de un +> runtime de Tokio y la forma asíncrona dentro de uno. + +### Slice — un contenedor de DI acotado para una prueba (feature `container`) + +Las pruebas HTTP arrancan la aplicación *completa*. A veces quieres lo contrario: +el cableado de una única unidad y nada más, sin router, sin datasource. `Slice` +construye un `firefly-container` mínimo exactamente para eso. Registras solo los +colaboradores que la unidad bajo prueba necesita, y luego los resuelves. + +> **Note** **Término clave — prueba de porción (slice test).** Una *porción* carga +> un subconjunto acotado del grafo de objetos en lugar de todo el contexto de +> aplicación. Es más rápida que un arranque completo y aísla la unidad bajo prueba. +> Las anotaciones de porción de Spring (`@WebMvcTest`, `@DataJpaTest`) son el +> análogo directo; `Slice` es el constructor explícito que Rust necesita en su +> lugar, ya que no hay escaneo de paquetes. + +```rust,ignore +use firefly_testkit::Slice; +use firefly_container::{Container, ContainerError, Scope}; + +let slice = Slice::new() + .instance(ReadModel::default()) // a ready instance (the mock/override path) + .register::(Scope::Singleton, |c: &Container| { + Ok(MyService::new()) // a factory; resolve deps from `c` + }) + .build(); + +let read_model: std::sync::Arc = slice.get(); +``` + +Lo que acaba de ocurrir: `instance(value)` instala un singleton listo; +`register::(scope, factory)` registra un bean construido por una factoría +que puede resolver sus propias dependencias desde el contenedor `c`; y `build()` +devuelve un `BuiltSlice` desde el que resuelves con `get::()` (o +`get_named::(name)`). También existe `eager::()`, que fuerza la construcción +de un bean en tiempo de `build()`, de modo que un colaborador ausente falle *ahí* +(la barrera de fallo rápido que refleja el arranque de las porciones de Spring) en +lugar de perezosamente en el primer uso. + +El par `instance` + `bind` **es** el `@MockBean`. Instala un fake bajo un port y +el bean bajo prueba lo cablea en lugar del colaborador real: + +```rust,ignore +let slice = Slice::new() + .instance(FakeRepo::default()) // the fake (a "mock_bean") + .bind::(|a| a) // expose it as the `dyn Repo` port + .register::(Scope::Singleton, |c| { + Ok(Service { repo: c.resolve::()? }) // wires the fake + }) + .eager::() // fail fast if `Repo` is missing + .build(); +``` + +Como el fake lo retiene el contenedor, `get::()` después de `build()` te +devuelve la *misma* instancia que el servicio cableó. Así puedes configurarlo y +comprobar contra él mediante mutabilidad interior —la jugada de verificación de +mocks de Spring, sin un framework de mocking. + +### `@WebMvcTest` — un controlador sobre servicios mockeados con `web_client` + +Combina ambos: registra un bean de controlador más sus colaboradores +**mockeados**, luego llama a `built.web_client::(C::routes)` para resolver +ese controlador y envolver su router generado por `#[rest_controller]` en un +`TestClient`. Esto es el `@WebMvcTest(Controller.class)` + `@MockBean(Service.class)` +de Spring: la capa web de un único controlador ejercitada sobre fakes, sin arranque +de la aplicación completa y sin datasource: + +```rust,ignore +use firefly_testkit::Slice; +use firefly_container::Scope; + +// @WebMvcTest(WalletController) + @MockBean(WalletService) +let client = Slice::new() + .instance(FakeWalletService::default()) // the mock + .bind::(|a| a) + .register::(Scope::Singleton, |c| { + Ok(WalletController { service: c.resolve::()? }) + }) + .eager::() + .build() + .web_client::(WalletController::routes); + +client.get_blocking("/api/v1/wallets/unknown").assert_status(404); +``` + +Lo que acaba de ocurrir: `web_client` (feature `web`) toma la +`fn routes(state: C) -> Router` generada del controlador, clona el bean resuelto al +estado del router, y envuelve el resultado en un `TestClient`. Toda la capa web de +un controlador se ejercita ahora sobre fakes. (`FakeWalletService` / +`WalletController` aquí son formas ilustrativas para *tu* servicio; el propio +controlador de Lumen autocablea el bus real, así que su cobertura web proviene de +las pruebas HTTP de Nivel 2 de más arriba.) + +> **Note** Para un **`@DataJpaTest`** —una porción de persistencia sin stack web— +> el mismo `Slice` registra un repositorio sobre una base de datos SQLite en +> memoria. Construye el repositorio con +> `firefly::data_sqlx::repository_for::(db)`, exactamente como hacen las +> pruebas `-models` de `lumen-ledger`: apuntan un `Db` a una URL de SQLite en +> memoria (`sqlite:file:…?mode=memory&cache=shared`) y ejercitan las consultas +> derivadas reales sin ningún Postgres a la vista. Conociste esos repositorios en +> [Persistencia y Repositorios Reactivos](./07-persistence.md). + +### Comprobar los eventos emitidos con `SpyBroker` + +El tercer ayudante de uso cotidiano demuestra que un handler *publicó* el evento +correcto. `SpyBroker` registra lo que un handler publicó, y los ayudantes de +aserción lo leen de vuelta: + +- `assert_event_published(&spy, "Type")` comprueba que se registró un evento de + ese tipo y lo devuelve. +- `assert_event_published_with(&spy, "Type", &json)` también comprueba que la + carga útil (parseada como objeto JSON) contiene los pares clave/valor dados —una + coincidencia de *subconjunto*, así que los campos extra se ignoran. +- `assert_no_events_published(&spy)` comprueba que no se registró ninguno. +- `must_encode` / `must_decode` son ayudantes JSON que entran en pánico al fallar, + para construir y leer cargas útiles. + +Un ejemplo con sabor a Lumen —demostrar que una apertura emite un `WalletOpened`: + +```rust,ignore +use firefly_testkit::{assert_event_published, must_encode, SpyBroker}; + +#[test] +fn open_emits_wallet_opened() { + let spy = SpyBroker::new(); + // The ledger publishes through the broker; here we record the envelope the + // projection would consume. + spy.record( + "wallets.events", + "WalletOpened", + &must_encode(&serde_json::json!({ "id": "wlt_1", "owner": "alice" })), + ); + + let event = assert_event_published(&spy, "WalletOpened"); + assert_eq!(event.topic, "wallets.events"); +} +``` + +Lo que acaba de ocurrir: `spy.record(topic, type, payload)` almacena el sobre de +un evento, y `assert_event_published` encuentra el primero del tipo nombrado (o +hace fallar la prueba, enumerando lo que *sí* se publicó). El `RecordedEvent` +devuelto lleva `topic`, `event_type` y los bytes crudos de `payload`, así que +puedes comprobar más cosas. Cablea un `SpyBroker` en un `Ledger` en una prueba +real y podrás demostrar que un depósito emite un `MoneyDeposited` con el importe +correcto. + +### Firmantes de webhooks + +Cuando Lumen incorpore un webhook entrante (el capítulo [Programación y +Notificaciones](./16-scheduling-notifications.md)), los firmantes HMAC del testkit +—`sign_hmac`, `sign_stripe`, `sign_github`, `sign_twilio`— producen valores de +cabecera idénticos byte a byte a lo que cada validador de `firefly-webhooks` +espera, de modo que una petición de prueba firmada se valida exactamente como lo +haría la de un proveedor real: + +```rust,ignore +use firefly_testkit::sign_stripe; + +let sig = sign_stripe(b"whsec_test", br#"{"type":"charge.succeeded"}"#, 1_700_000_000); +// Attach `sig` as the `Stripe-Signature` header on a TestClient POST and the +// validator accepts it exactly as it would a real Stripe delivery. +``` + +Lo que acaba de ocurrir: `sign_stripe(secret, body, unix_ts)` construye el valor +`t=,v1=` que Stripe envía en `Stripe-Signature`, firmando +`.` con HMAC-SHA256. Como el firmante coincide exactamente con la +forma sobre el cable que espera el validador, una prueba que firma su propia carga +útil demuestra que tu receptor acepta una entrega genuina. + +## Pruebas de pipelines reactivos + +El endpoint de streaming (presentado en [Producción y +Despliegue](./20-production.md)) construye un `Flux`. Conociste `Mono` y `Flux` en +[El Modelo Reactivo](./05-reactive-model.md); aquí está cómo *probar* uno. + +> **Note** **Término clave — operación terminal.** Un pipeline reactivo es perezoso: +> los operadores (`filter`, `map`, …) describen trabajo pero no ejecutan nada hasta +> que un *terminal* consume el stream. `collect_list()`, `count()` y `block()` son +> terminales: llevan el pipeline a su finalización y resuelven un valor. El +> `block()` / `collectList()` de Spring Reactor son el análogo directo. + +Pruebas un pipeline llevándolo a un terminal y comprobando el valor resuelto: + +```rust +use firefly_reactive::Flux; + +#[tokio::test] +async fn pipeline_filters_and_maps() { + let out = Flux::range(1, 5) // emits 1, 2, 3, 4, 5 (start, count) + .filter(|x| x % 2 == 1) // keep the odds: 1, 3, 5 + .map(|x| x * 10) // scale: 10, 30, 50 + .collect_list() // Flux -> Mono> + .block() // Result>, FireflyError> + .await + .unwrap() // unwrap the Result + .unwrap(); // unwrap the Option (the stream was non-empty) + assert_eq!(out, vec![10, 30, 50]); +} +``` + +Lo que acaba de ocurrir, y por qué el doble `unwrap`: `Flux::range(1, 5)` emite +cinco valores empezando en `1`. `filter` y `map` los transforman perezosamente. +`collect_list()` convierte el `Flux` en un `Mono>` —un único valor +que contiene la lista entera— y `block().await` lo lleva a su finalización. +`block()` devuelve `Result>, FireflyError>`: el `Result` expone un +error del pipeline, y el `Option` es `None` solo para un stream vacío, así que una +ejecución exitosa no vacía necesita ambos `unwrap`. Esto son simples aserciones de +Rust asíncrono sobre un stream resuelto; sin runtime de pruebas especial. + +> **Note** Las pruebas de streaming de Lumen (`src/streaming_test.rs`, +> condicionadas tras la feature `streaming`) toman la ruta HTTP en lugar de probar +> el `Flux` directamente: abren una cartera, depositan, luego hacen `GET /events` +> y comprueban dos líneas NDJSON (`WalletOpened` + `MoneyDeposited`) por defecto, +> `text/event-stream` con `?format=sse`, y un 404 para una cartera desconocida. +> Esas son las `+3 streaming tests` que activas con `--features streaming`. + +## Nivel 3 — Pruebas de integración con infraestructura real + +Lumen se ejecuta de forma hermética, pero los adaptadores de producción a los que +recurres en [Producción y Despliegue](./20-production.md) necesitan servicios +reales. El workspace incluye un `docker-compose.yml` con Postgres, Redis, RabbitMQ, +un Redpanda compatible con Kafka, Keycloak, emuladores de S3/Blob y una captura +SMTP. + +La convención a lo largo de los crates de adaptadores mantiene el `cargo test` por +defecto verde en una máquina sin nada: una prueba lee una URL de conexión del +entorno y **se omite cuando está sin definir**. CI activa la batería completa +exportando la variable. + +> **Note** **Término clave — prueba condicionada por entorno (env-gated).** Una +> prueba *condicionada por entorno* solo se ejecuta cuando una variable de entorno +> nombrada está presente (un `DATABASE_URL`, un `REDIS_URL`). Marcarla con +> `#[ignore]` la mantiene fuera de la ejecución por defecto; leer la variable y +> retornar pronto significa que incluso `--ignored` se omite limpiamente donde el +> servicio está ausente. Este es el análogo en Rust de las pruebas protegidas por +> `@Testcontainers` / `@EnabledIf` de Spring. + +```rust,ignore +#[tokio::test] +#[ignore = "requires postgres (DATABASE_URL)"] +async fn postgres_event_store_round_trips() { + // Skip on a bare machine: no DATABASE_URL -> return before touching the DB. + let Ok(url) = std::env::var("DATABASE_URL") else { return }; + // ... drive the Postgres-backed EventStore against the live database at `url`. +} +``` + +Lo que acaba de ocurrir: el `#[ignore]` mantiene esta prueba enteramente fuera de +la ejecución por defecto de `cargo test`. Cuando optas por incluirla con +`--ignored`, la guarda `let … else { return }` sigue omitiéndola limpiamente si +`DATABASE_URL` está sin definir, así que la única forma de que toque Postgres de +verdad es cuando la apuntas a uno en vivo. Para ejecutar la batería condicionada +por entorno, arranca los servicios de respaldo y exporta las URL: + +```bash +docker compose up -d # start the backing services +DATABASE_URL=postgres://firefly:firefly@localhost:5442/firefly \ +REDIS_URL=redis://localhost:6379/0 \ + cargo test --workspace -- --ignored # run the env-gated suite +docker compose down +``` + +> **Note** El archivo de compose mapea Postgres al puerto **5442** del host (no el +> 5432 por defecto) para evitar colisionar con un Postgres local que ya puedas +> tener en marcha, razón por la cual el `DATABASE_URL` de arriba dice +> `localhost:5442`. + +El testkit también puede acortar este nivel. Con la feature `testcontainers`, +`firefly_testkit::containers` mapea el `(host, port)` de un servicio arrancado a +las claves de configuración canónicas `firefly.*` (`config_for(&container)`) y +ofrece una guarda de omisión `docker_available()` —el análogo en Rust del +`@ServiceConnection` de Spring—. Está desacoplado de cualquier biblioteca de +contenedores concreta: aliméntalo con los detalles de conexión que cualquier +herramienta ya te entregue. + +## Ejecutar la batería de Lumen + +Desde la raíz del workspace (con `export PATH="/opt/homebrew/bin:$PATH"` en macOS +para que la toolchain se resuelva): + +```bash +cargo build -p firefly-sample-lumen +cargo test -p firefly-sample-lumen # 42 unit + 12 HTTP + 1 doctest +cargo test -p firefly-sample-lumen --features streaming # + 3 streaming tests +cargo clippy -p firefly-sample-lumen --all-targets -- -D warnings +cargo fmt -p firefly-sample-lumen -- --check +``` + +> **Tip** **Punto de control.** Una ejecución limpia imprime `test result: ok` para +> los niveles unitario y HTTP y para el doctest, con cero advertencias de clippy y +> un `fmt --check` limpio. Si un fragmento de cualquier capítulo se desvía del +> archivo, esta barrera falla —que es precisamente cómo el libro se mantiene +> honesto. + +## Resumen — cómo Lumen se demuestra a sí mismo + +Nada cambió en `src/` este capítulo; es la retrospectiva sobre el código de +pruebas que creció junto a cada funcionalidad. Ahora sabes: + +- **Los tres niveles, y un ayudante por nivel.** Pruebas unitarias `#[tokio::test]` + puras sin E/S; pruebas HTTP/de porción en proceso que ejercitan el router real + sin enlazar un socket; y pruebas de integración condicionadas por entorno contra + infraestructura real. +- **`bootstrap()` es la costura de pruebas.** Ensambla la misma aplicación + completamente cableada que serviría `run()` y devuelve `Bootstrapped::api_router` + —sin socket— de modo que `build_router()` le da a cada prueba un contenedor + autoconsistente donde una escritura es visible para una lectura posterior. +- **Nivel 1 — pruebas unitarias.** Construye un value object, un agregado o un bean + de handlers con sus colaboradores en mano y comprueba directamente; llama a + `.validate()` sobre un comando sin HTTP. Aquí viven las **42 pruebas unitarias** + de Lumen. +- **Nivel 2 — HTTP en proceso.** `tower::oneshot` ejercita `build_router()` de + extremo a extremo sobre infraestructura en memoria; las **12 pruebas HTTP** de + Lumen cubren abrir → consultar → depositar/retirar → transferir (feliz + + compensado) → flujo → 2PC → problemas RFC 9457 401/422/404. El `TestClient`, el + `Slice` (`@MockBean` / `@WebMvcTest` / `@DataJpaTest`) y el `SpyBroker` de + `firefly-testkit` hacen concisa la misma cobertura en tu propio servicio. +- **Los pipelines reactivos** se prueban llevando un `Flux` a un terminal + (`collect_list().block()`) —el único **doctest** del capítulo. +- **Nivel 3 — integración.** Pruebas con `#[ignore]`, condicionadas por entorno, + que leen una URL de conexión, se omiten limpiamente cuando está sin definir, y se + ejecutan contra los servicios de `docker compose` (o los fixtures `containers` del + testkit) cuando está definida. + +## Ejercicios + +1. **Reescribe una prueba con `TestClient`.** Toma las aserciones de lectura de + `deposit_and_withdraw_update_the_balance` en `src/http_test.rs` y reescribe el + viaje de ida y vuelta final del `GET` usando `TestClient` + `assert_json_path`. + (Los ayudantes de petición de `TestClient` no llevan un argumento de cabecera por + petición, así que arranca la aplicación una vez, mantén las mutaciones + autenticadas en la forma cruda de `tower::oneshot` que acuña un token bearer + contra ese `Router`, y luego envuelve el *mismo* `Router` en un `TestClient` para + la lectura pública —un único contexto de aplicación, de modo que la lectura vea + la mutación.) +2. **Una prueba de `Slice` para el read model.** Usa `Slice` para registrar una + instancia `ReadModel::default()`, proyecta un `WalletOpened` en ella a mano, y + comprueba que `find` devuelve la vista —todo sin el bus ni el router. Añade + `.eager::()` y confirma que `build()` tiene éxito, luego resuélvela con + `slice.get::()`. +3. **Aserción de eventos sobre el ledger.** Cablea un `SpyBroker` en un `Ledger` en + una prueba, confirma un depósito, y usa `assert_event_published_with(&spy, + "MoneyDeposited", &serde_json::json!({ "amount": 50 }))` para demostrar que el + campo `amount` de la carga útil es igual a 50. Luego añade + `assert_no_events_published` a un camino sin efecto y observa cómo pasa. +4. **Una porción al estilo `@WebMvcTest`.** Esboza un servicio falso tras un port, + regístralo con `.instance(...)` + `.bind::(|a| a)`, registra un + controlador sobre él, y llama a `web_client::(C::routes)` para ejercitar una + ruta sobre el fake con `get_blocking`. Comprueba un 404 para un id desconocido. +5. **Una prueba de integración que se omite.** Escribe una prueba con `#[ignore]` + que lea `DATABASE_URL`, retorne pronto cuando esté sin definir, y en otro caso + abra una cartera contra un event store respaldado por Postgres. Confirma que se + omite con un simple `cargo test`, que se omite con `--ignored` cuando la variable + está sin definir, y que se ejecuta con la variable definida. + +## Adónde ir después + +- Anda Lumen, inspecciónalo y opéralo con las herramientas de desarrollo de **[La + CLI](./19-cli.md)** —incluidos los comandos `firefly` que ejecutan estas mismas + comprobaciones. +- Sustituye los valores por defecto en memoria por Postgres y Kafka reales, y luego + despliega Lumen, en **[Producción y Despliegue](./20-production.md)** —donde las + pruebas de integración de Nivel 3 por fin tienen infraestructura real contra la que + ejecutarse. diff --git a/docs/book/src-es/19-cli.md b/docs/book/src-es/19-cli.md new file mode 100644 index 00000000..aec60ebe --- /dev/null +++ b/docs/book/src-es/19-cli.md @@ -0,0 +1,562 @@ +# La CLI + +Hasta ahora has construido **Lumen** — el servicio de monedero digital y libro +mayor desde [Inicio rápido](./02-quickstart.md) en adelante — a mano: un archivo +cada vez, un `cargo build` después de cada capítulo. Eso fue deliberado, para que +cada línea sea algo que tecleaste y comprendes. Este capítulo enseña la *otra* +forma de hacer el mismo trabajo: la CLI de desarrollo `firefly`. Es un único +binario compilado que andamia un proyecto, genera los mismos artefactos que los +capítulos anteriores escribieron a mano, ejecuta el binario con perfiles y +sobrescrituras de configuración, sella metadatos de compilación, gestiona +migraciones, exporta un documento OpenAPI e introspecciona una Lumen *en +ejecución* a través de su superficie de actuator — el bucle cotidiano del +desarrollador en una sola herramienta. + +Nada en este capítulo cambia el propio `samples/lumen`; es puramente operativo. +Pero al terminar serás capaz de pilotar todo el ciclo de vida desde la línea de +comandos y — algo igual de importante — sabrás exactamente con qué crate del +framework habla cada comando, porque la CLI nunca inventa una API. Llama a los +mismos `firefly-migrations`, `firefly-openapi` y endpoints de actuator que ya +conoces. + +Al terminar este capítulo, serás capaz de: + +- Instalar el binario `firefly` y leer su catálogo de comandos. +- Andamiar un nuevo servicio de dos maneras — eligiendo un *archetype* y activando + *features* — y previsualizar el plan exacto de archivos con `--dry-run`. +- Generar artefactos de código individuales (un comando CQRS, una consulta, un + agregado, una saga, una migración) dentro de un proyecto existente, y leer lo + que los generadores emiten realmente. +- Ejecutar una aplicación Firefly a través de la CLI, mapeando los flags de perfil + y sobrescritura a las variables de entorno `FIREFLY_*` que el framework lee al + arrancar. +- Introspeccionar una Lumen *en ejecución* — su salud, rutas, beans y métricas — a + través del puerto de actuator, y entender por qué un binario compilado requiere + `--url`. + +## Conceptos que conocerás + +Antes del primer comando, aquí están las ideas en las que se apoya este capítulo. +Cada una se reintroduce en contexto donde se usa por primera vez; esta es la +versión breve. + +> **Note** **Término clave — archetype.** Un *archetype* es una plantilla de +> proyecto que decide la forma inicial de tu crate: qué módulos existen, qué +> features de Firefly están activadas y qué aspecto tiene el código de ejemplo. La +> CLI incluye seis (`core`, `web-api`, `web`, `hexagonal`, `library`, `cli`). El +> análogo en Spring es un "tipo de proyecto" de Spring Initializr más sus +> dependencias preseleccionadas. + +> **Note** **Término clave — feature.** Una *feature* es un subsistema opcional que +> el andamiaje conecta — `web`, `data`, `cqrs`, `eda`, `cache`, `security`, etc. +> Cada una se corresponde con uno o más crates `firefly-*` añadidos al `Cargo.toml` +> generado. En términos de Spring, elegir una feature es como marcar un starter en +> el Initializr. + +> **Note** **Término clave — superficie de actuator.** La *superficie de actuator* +> es el conjunto de endpoints HTTP operativos — `/actuator/health`, +> `/actuator/info`, `/actuator/metrics`, `/actuator/mappings`, `/actuator/beans`, +> `/actuator/conditions`, `/actuator/env` — que una aplicación Firefly en ejecución +> sirve en su puerto de **gestión** (`8081` por defecto), separado de la API +> pública en `8080`. Esto refleja Spring Boot Actuator. Los comandos de +> introspección de la CLI son clientes ligeros sobre estos endpoints. + +> **Note** **Término clave — Segregación de Responsabilidad entre Comandos y +> Consultas (CQRS).** Un patrón que enruta los **comandos** que cambian estado y +> las **consultas** de solo lectura a través de handlers separados sobre un *bus* +> compartido. Construiste los handlers de comando y consulta de Lumen en +> [CQRS](./09-cqrs.md); la CLI puede andamiar las mismas piezas para un proyecto +> nuevo con `firefly generate command` / `firefly generate query`. + +## Paso 1 — Instalar el binario + +La CLI vive en el `crates/cli` del framework. Instálala desde un checkout y luego +pídele que se describa a sí misma. + +```bash +cargo install --path crates/cli # installs the `firefly` binary +firefly --help # prints the banner + every command +firefly --version # 26.6.28 +``` + +Lo que acaba de pasar: `cargo install` compiló el binario `firefly` y lo colocó en +tu `PATH`. `--version` imprime la versión de calendario del framework — la misma +`26.6.28` de la que depende Lumen, porque la CLI se versiona junto con el resto del +workspace. + +> **Tip** **Punto de control.** `firefly --version` imprime `26.6.28` y +> `firefly --help` lista los subcomandos, incluyendo `new`, `generate`, `run`, +> `db`, `openapi`, `doctor` y `health`. Si `firefly` da "command not found", +> asegúrate de que `~/.cargo/bin` esté en tu `PATH`. + +Si prefieres no instalar el binario, puedes pilotar la CLI a través de Cargo desde +un checkout del framework — consulta el [Paso 9](#step-9--run-the-cli-through-cargo). + +## Paso 2 — Leer el catálogo de comandos + +Todo el bucle del desarrollador cabe en una tabla. Échale un vistazo ahora; el +resto del capítulo recorre los comandos que más usarás. + +| Command | Purpose | +|------------------------------------------------------|-----------------------------------------------| +| `firefly new ` | andamia un nuevo proyecto firefly-rust | +| `firefly generate ` (alias `g`) | genera un artefacto de código | +| `firefly run` | `cargo run` con flags de perfil / sobrescritura | +| `firefly build ` | sella `build-info.json` / construye una imagen OCI | +| `firefly info` | información del framework + del entorno | +| `firefly doctor` | comprobaciones de toolchain (rustc, cargo, git, …) | +| `firefly db ` | gestión de migraciones | +| `firefly openapi --format json\|yaml [-o file]` | exporta un documento OpenAPI 3.1 | +| `firefly openapi-client --spec ` | genera un cliente Rust tipado a partir de una spec | +| `firefly actuator --url ` | consulta el `/actuator/*` de una app en ejecución | +| `firefly routes\|env\|health\|metrics --url ` | introspección remota de una app en ejecución | +| `firefly beans\|conditions --url ` | informe de DI / autoconfiguración de una app en ejecución | +| `firefly completion ` | imprime un script de autocompletado de shell | +| `firefly sbom [--json]` | lista de materiales de software desde `Cargo.lock` | +| `firefly license` | informe de licencias del framework + dependencias | + +Lo que acaba de pasar: esa es la superficie completa. Fíjate en la forma del bucle +— *andamiar* (`new`), *crecer* (`generate`), *ejecutar* (`run`), *empaquetar* +(`build`), *operar* (`db`, `openapi`), *introspeccionar* (`actuator`, `routes`, +`health`, …) y *auditar* (`doctor`, `sbom`, `license`). Cada comando se +corresponde con un crate del framework o un endpoint de actuator que ya conoces. + +## Paso 3 — Andamiar un proyecto + +`firefly new` genera un crate de Cargo sin workspace: un árbol `src/` con la forma +del archetype, un `firefly.yaml`, un `.gitignore`, un `README.md`, un `Dockerfile` +y un directorio `tests/`. Es la misma forma inicial que tenía Lumen tras el +[Inicio rápido](./02-quickstart.md). + +```bash +firefly new lumen2 --archetype web-api --features web,data,cqrs --git +firefly new my-lib --archetype library --dep-path ../../ # local dev deps +firefly new --list # archetypes + features +firefly new svc --dry-run # plan without writing +``` + +Lo que acaba de pasar, comando a comando: + +- La primera línea andamia un proyecto `web-api` llamado `lumen2` con las features + `web`, `data` y `cqrs` activadas y (por `--git`) inicializa un repositorio Git + con un commit inicial. +- `--dep-path ../../` apunta las dependencias `firefly-*` generadas a un checkout + de workspace local en lugar del repositorio canónico de GitHub. Cada crate se + resuelve automáticamente en su propio `crates/`. +- `--list` imprime los catálogos de archetypes y features, y luego sale sin crear + nada. +- `--dry-run` imprime el plan exacto de archivos — cada ruta que *se* escribiría — + sin tocar el sistema de archivos. + +Los seis archetypes son `core`, `web-api`, `web`, `hexagonal`, `library` y `cli`. +El archetype `web-api` sella un punto de entrada, un controlador y el árbol por +capas `models/services/repositories` cableado contra el starter web real, de modo +que el primerísimo `cargo run` arranca. El origen de las dependencias `firefly-*` +generadas es configurable: `--dep-path ` para un checkout local, +`--dep-version ` para una release publicada en crates.io y, en caso +contrario, el repositorio Git canónico. `--force` sobrescribe un directorio de +destino existente. + +> **Note** Una feature que no selecciones simplemente está ausente del `Cargo.toml` +> generado — elegir `web,data,cqrs` añade `firefly-web`, `firefly-data` + +> `firefly-migrations`, y `firefly-cqrs`, y nada más. La lista completa de features +> (`web`, `data`, `mongodb`, `eda`, `cache`, `client`, `security`, `scheduling`, +> `observability`, `cqrs`, `shell`, `transactional`) la imprime +> `firefly new --list`, y los crates subyacentes son los que cataloga el +> [capítulo de macros](./21-declarative-macros.md). + +> **Tip** **Punto de control.** Ejecuta +> `firefly new lumen2 --archetype web-api --dry-run`. Deberías ver un plan que +> lista `Cargo.toml`, `firefly.yaml`, `.gitignore`, `README.md`, `Dockerfile`, +> `src/main.rs`, `src/lib.rs`, `src/controllers.rs`, el árbol +> `models/services/repositories` y `tests/api.rs` — sin nada escrito en disco. +> Quita `--dry-run` y aparecerán los mismos archivos bajo `lumen2/`. + +## Paso 4 — Generar artefactos individuales + +Una vez que existe un proyecto, `firefly generate` (alias `g`) escribe un artefacto +cada vez dentro de él, detectando el paquete, el archetype y los flags de feature a +partir de `Cargo.toml` + `firefly.yaml`. Estas son exactamente las piezas que +escribiste a mano para Lumen — un comando y su handler, una consulta, un agregado, +una saga, una migración. + +```bash +firefly generate command OpenWallet # src/cqrs/open_wallet_command{,_handler}.rs +firefly generate query GetWallet # src/cqrs/get_wallet_query{,_handler}.rs +firefly generate aggregate Wallet # src/domain/wallet.rs (embeds AggregateRoot) +firefly generate saga MoneyTransfer --dry-run +firefly generate migration AddWallets # migrations/V###__add_wallets.sql +firefly g handler Deposit # `g` is the alias +``` + +Los tipos de artefacto son `handler`, `route`, `entity`, `repository`, `dto`, +`aggregate`, `command`, `query`, `saga` y `migration`. Los nombres se aceptan en +cualquier caja y se convierten según haga falta (`OpenWallet`, `open-wallet` y +`open_wallet` producen todos los mismos archivos). `--force` sobrescribe un archivo +existente; `--dry-run` planifica sin escribir. + +Lo que acaba de pasar, con los dos generadores CQRS como ejemplo trabajado. Un +`generate command OpenWallet` escribe **dos** archivos dentro de `src/cqrs/`: + +```rust,ignore +// src/cqrs/open_wallet_command.rs +use firefly_cqrs::{CqrsError, Message}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct OpenWallet { + /// Target aggregate identifier. + pub id: String, +} + +impl Message for OpenWallet { + fn validate(&self) -> Result<(), CqrsError> { + if self.id.trim().is_empty() { + return Err(CqrsError::validation("id is required")); + } + Ok(()) + } +} +``` + +```rust,ignore +// src/cqrs/open_wallet_command_handler.rs +use firefly_cqrs::{Bus, CqrsError}; + +use super::open_wallet_command::OpenWallet; + +/// Register the `OpenWallet` command handler on `bus`. Call once at startup. +pub fn register_open_wallet_handler(bus: &Bus) { + bus.register(|command: OpenWallet| async move { + // Implement the OpenWallet command behaviour here. + Ok::<_, CqrsError>(command.id) + }); +} +``` + +El comando es un struct de mensaje sencillo que implementa `firefly_cqrs::Message` +(su `validate` se ejecuta en el middleware de validación del bus antes del +handler). El handler es una *función registradora* `register__handler(bus: +&Bus)` que llama al `bus.register(...)` basado en closures — la misma forma de +registro que usaste en [CQRS](./09-cqrs.md). `generate query GetWallet` refleja +esto con un struct de consulta `GetWallet` y un `register_get_wallet_handler(bus: +&Bus)`. + +> **Note** Los generadores apuntan a las APIs `firefly-*` reales, no a cuerpos +> marcador de posición. `generate aggregate Wallet` escribe `src/domain/wallet.rs` +> con un struct que embebe `firefly_eventsourcing::AggregateRoot` (el búfer de +> eventos sin confirmar), exponiendo `raise(...)` y `take_events(...)`. `generate +> saga MoneyTransfer` escribe `src/sagas/money_transfer_saga.rs` con una función +> `build_money_transfer_saga()` sobre el builder `firefly_orchestration::Saga` — +> `Saga::new("money-transfer")`, `Step::new(...)`, `.with_compensation(...)`. Estas +> son las mismas construcciones que conociste en +> [Event Sourcing](./11-event-sourcing.md) y [Sagas](./12-sagas.md). + +> **Tip** **Punto de control.** Dentro de un proyecto andamiado, ejecuta +> `firefly generate command OpenWallet --dry-run`. Deberías ver un plan que nombra +> `src/cqrs/open_wallet_command.rs` y `src/cqrs/open_wallet_command_handler.rs` como +> acciones `create`, sin nada escrito. + +## Paso 5 — Ejecutar la aplicación + +`firefly run` es un envoltorio ligero sobre `cargo run`. Mapea los flags de perfil +y de sobrescritura de configuración a las variables de entorno `FIREFLY_*` que el +framework lee al arrancar, y luego hace exec de Cargo desde la raíz del proyecto +detectada. + +> **Note** **Término clave — flag de sobrescritura de config.** Un flag +> `-D key=value` sobrescribe un único valor de configuración. La CLI lo mapea a una +> variable de entorno quitando un `firefly.` inicial, pasándolo a mayúsculas y +> reemplazando `.`/`-` por `_`, y anteponiendo `FIREFLY_`. Así, `-D +> logging.level-root=DEBUG` se convierte en `FIREFLY_LOGGING_LEVEL_ROOT=DEBUG`. Esta +> es la misma convención de entorno que describe +> [Configuración](./03-configuration.md). + +```bash +firefly run # cargo run +firefly run -p dev -p test # FIREFLY_PROFILES_ACTIVE=dev,test +firefly run -D logging.level-root=DEBUG # FIREFLY_LOGGING_LEVEL_ROOT=DEBUG +firefly run --env FIREFLY_SERVER_ADDR=0.0.0.0:8080 # a raw env var for the process +firefly run --debug # FIREFLY_LOGGING_LEVEL_ROOT=DEBUG +firefly run --release --bin lumen # cargo run --release --bin lumen +firefly run --dry-run # print the resolved env + cargo command +``` + +Lo que acaba de pasar: los flags se resuelven en un entorno que se aplica antes de +`cargo run`. `-p`/`--profile` es repetible o separado por comas y se aplana en un +único `FIREFLY_PROFILES_ACTIVE`; `-D key=value` se mapea a `FIREFLY_`; +`--env KEY=VALUE` pasa una variable en bruto directamente; `--debug` es atajo de +`FIREFLY_LOGGING_LEVEL_ROOT=DEBUG`; `--release` y `--bin ` se pasan tal cual +a Cargo. Un servicio Firefly es un único binario compilado, así que no hay recarga +en caliente ni selección de proceso de trabajo — recompilas y vuelves a ejecutar. +`--dry-run` imprime el entorno resuelto y el comando `cargo run` exacto sin +ejecutar, que es la forma más segura de aprender el mapeo. + +> **Warning** Una sobrescritura `-D` solo surte efecto si el framework lee +> realmente esa clave. Lumen vincula sus dos puertos desde `FIREFLY_SERVER_ADDR` / +> `FIREFLY_MANAGEMENT_ADDR` (un `host:port` completo), no desde una clave +> `server.port` — así que, para mover los puertos de Lumen, establece directamente +> las variables de entorno de dirección. El equivalente del enlace de dos puertos +> es: + +```bash +firefly run --bin lumen \ + --env FIREFLY_SERVER_ADDR=127.0.0.1:8080 \ + --env FIREFLY_MANAGEMENT_ADDR=127.0.0.1:8081 +``` + +Esta es la misma costura que el [Inicio rápido](./02-quickstart.md) usó con +variables `FIREFLY_*` en bruto — `firefly run --env` simplemente las establece por +ti. + +> **Tip** **Punto de control.** Ejecuta `firefly run -p dev -D +> logging.level-root=DEBUG --dry-run` desde dentro de un proyecto. La salida imprime +> `Would run: cargo run` y un bloque de entorno que lista +> `FIREFLY_PROFILES_ACTIVE=dev` y `FIREFLY_LOGGING_LEVEL_ROOT=DEBUG`. No se lanza +> nada. + +## Paso 6 — Construir para release + +La compilación simple es `cargo build`. El grupo `build` añade los dos artefactos +que un pipeline de release necesita por encima del binario compilado. + +```bash +firefly build info # write build-info.json (git SHA + UTC time) +firefly build info -o target/build-info.json +firefly build image -t lumen:1.0.0 # OCI image via Cloud Native Buildpacks (`pack`) +firefly build image --builder docker # or a plain Dockerfile build +``` + +Lo que acaba de pasar: `build info` escribe un `build-info.json` con la forma +`{"git": {"sha": …}, "build": {"time": …}}` (un SHA vacío cuando git no está +disponible). Ese archivo es la fuente de datos que lee el contribuidor de +compilación de `/actuator/info` cuando está presente, de modo que el SHA de git y +la hora de compilación aparecen junto al bloque `InfoContributor` que cableaste en +[Observabilidad](./15-observability.md). `build image` construye una imagen OCI — +por defecto vía Cloud Native Buildpacks (la herramienta `pack`), o con +`--builder docker` contra el `Dockerfile` andamiado. + +> **Tip** **Punto de control.** Ejecuta `firefly build info -o /tmp/build-info.json` +> y abre el archivo. Es JSON válido con un objeto `git` y `build` de nivel +> superior, y `build.time` es una marca de tiempo UTC RFC 3339 que termina en `Z`. + +## Paso 7 — Gestionar migraciones de base de datos + +Lumen corre sobre un almacén de eventos en proceso, así que no incluye **ninguna** +migración SQL — su árbol `samples/lumen` no tiene directorio `migrations/` en +absoluto. Pero en el momento en que cambies al almacén de eventos de Postgres de +[Producción y despliegue](./20-production.md), `firefly db` gestiona el esquema. +Pilota el propio ejecutor de migraciones forward-only del framework, la misma +biblioteca [`firefly-migrations`](./07-persistence.md) que incluyen los proyectos +generados. + +```bash +firefly db init # migrations/ + starter V001__init.sql +firefly db migrate -m "create wallets" # writes V002__create_wallets.sql +firefly db upgrade --url sqlite://app.db # apply pending migrations +firefly db status --url sqlite://app.db # show applied + pending +``` + +Lo que acaba de pasar: `db init` crea el directorio `migrations/` con un +`V001__init.sql` de arranque; `db migrate -m ` escribe un nuevo +`V###__.sql` vacío con la versión autoincrementada a partir de la migración +existente más alta; `db upgrade` aplica todas las migraciones pendientes (de forma +idempotente — una reejecución aplica cero); `db status` informa de las migraciones +aplicadas y pendientes. La URL de la base de datos se resuelve desde `--url`, luego +`$DATABASE_URL`, luego `firefly.datasource.url` en `firefly.yaml`, con un valor por +defecto de `sqlite://firefly.db`. + +> **Note** El ejecutor de migraciones es **forward-only** (un historial de solo +> adición, al estilo Flyway). Por eso no existe `firefly db downgrade` — +> ejecutarlo falla de forma ruidosa en lugar de hacer un no-op silencioso. Para +> deshacer un cambio, escribe en su lugar una nueva migración correctiva con +> `firefly db migrate`. + +> **Warning** El backend de migraciones de la CLI es **SQLite vía `rusqlite`**. Una +> URL `postgres://` o `mysql://` devuelve un claro error de "not wired into the +> CLI". Para otro driver en producción, adapta el puerto +> `firefly_migrations::Database` y llama a `firefly_migrations::run` directamente +> desde tu build, en lugar de hacerlo a través de la CLI de conveniencia. + +> **Tip** **Punto de control.** En un directorio temporal ejecuta `firefly db init` +> y luego `firefly db status --url ":memory:"`. Deberías ver una migración +> *pendiente* (`V001__init.sql`) y cero aplicadas, porque cada conexión `:memory:` +> empieza vacía. + +## Paso 8 — Exportar OpenAPI y generar clientes + +La CLI puede emitir un documento OpenAPI para el proyecto actual y, en sentido +inverso, generar un cliente Rust tipado a partir de cualquier spec. + +```bash +firefly openapi # OpenAPI 3.1 JSON to stdout +firefly openapi --format yaml -o openapi.yaml +firefly openapi-client --spec openapi.json -o client.rs --client-name WalletClient +``` + +Lo que acaba de pasar: `firefly openapi` lee los metadatos del documento +(`info.title` / `info.version` / `info.description`) desde `firefly.yaml`, luego +desde `Cargo.toml`, y emite un documento OpenAPI 3.1. Como un binario compilado no +puede arrancar una aplicación arbitraria para enumerar rutas en vivo, el documento +exportado es un **esqueleto** sellado con metadatos — un bloque `info` correcto y el +componente `ProblemDetail` estándar (Firefly renderiza los errores como +`application/problem+json` según RFC 9457), pero `paths` vacío. Para emitir las +rutas *reales* de Lumen, constrúyelas con `firefly_openapi::Builder` (que lee la +tabla de rutas de `#[rest_controller]`) y sírvelas con `Builder::router()` — la +spec en vivo que tu aplicación ya publica en `/v3/api-docs` en el puerto de +gestión. + +`firefly openapi-client` es lo inverso: dado un documento OpenAPI 3.x, emite un +cliente tipado autocontenido sobre `firefly_client::RestClient` — un struct/enum de +modelo por cada entrada de `components.schemas` y una `async fn` por operación, con +parámetros de ruta tipados y cuerpos JSON. `--client-name` da nombre al struct +generado (por defecto `ApiClient`). + +> **Tip** **Punto de control.** Ejecuta `firefly openapi --format yaml | head`. La +> primera línea es `openapi: 3.1.0`, seguida de un bloque `info:` que lleva el +> título y la versión de tu proyecto. + +## Paso 9 — Introspeccionar una app en ejecución + +Estos comandos consultan una Lumen *en ejecución* a través de HTTP. Un binario +compilado no tiene un contexto de DI offline que arrancar — no hay nada que +introspeccionar sin un proceso en vivo — así que `--url` es obligatorio, apuntado +al puerto de **gestión** de Lumen (la superficie de actuator de +[Observabilidad](./15-observability.md)). + +```bash +firefly health --url http://localhost:8081 # -> /actuator/health +firefly env --url http://localhost:8081 # -> /actuator/env +firefly routes --url http://localhost:8081 # -> /actuator/mappings +firefly metrics requests --url http://localhost:8081 +firefly actuator info --url http://localhost:8081 --json +firefly actuator metrics requests --url http://localhost:8081 --json +firefly beans --url http://localhost:8081 # the DI container's bean table +firefly conditions --url http://localhost:8081 # the auto-configuration report +``` + +Lo que acaba de pasar: cada comando hace un GET a un endpoint de actuator mapeado e +imprime el JSON con formato legible. `routes` se mapea a `/actuator/mappings` (cada +ruta `#[rest_controller]`), `health`/`env`/`metrics`/`info` se mapean a sus +endpoints homónimos, y `beans`/`conditions` renderizan la tabla de beans de DI y el +informe de evaluación de beans condicionales — la introspección de DI de Spring +Boot Actuator. `firefly actuator ` es la forma general; +`firefly health|env|routes|metrics|beans|conditions` son atajos de conveniencia. +`--json` emite el cuerpo en bruto para canalizar por tubería. + +> **Note** **Término clave — bean.** Un *bean* es un objeto que el framework +> construye y gestiona por ti. `/actuator/beans` lista cada uno (tipo, scope, +> estereotipo), y `/actuator/conditions` informa de las guardas `@Profile` / +> `@ConditionalOn…` declaradas por cada bean condicional. Estos se leen a través de +> HTTP desde un servicio en ejecución, de la misma forma en que consultarías +> `/beans` y `/conditions` de Spring. Consulta +> [Inyección de dependencias](./04a-dependency-injection.md) para el propio +> contenedor de beans. + +> **Tip** **Punto de control.** En una terminal ejecuta `cargo run --bin lumen`; en +> otra, ejecuta `firefly health --url http://localhost:8081`. Deberías ver un cuerpo +> JSON con `"status":"UP"`. Si `firefly routes --url …` devuelve un error sobre un +> contexto en proceso ausente, has omitido `--url` — estos comandos siempre lo +> requieren. + +## Paso 10 — Diagnosticar, completar y auditar + +Los comandos restantes informan sobre tu entorno y tus dependencias. + +```bash +firefly info # framework version + which optional adapters are built +firefly doctor # checks rustc, cargo, git, clippy, rustfmt, docker +firefly completion zsh # > ~/.zfunc/_firefly (bash | zsh | fish | powershell) +firefly sbom # a software bill of materials from Cargo.lock +firefly sbom --json # machine-readable, for a compliance pipeline +firefly license # the framework + dependency license report +``` + +Lo que acaba de pasar: `firefly doctor` es lo primero que ejecutar en una máquina +nueva. Informa de tus versiones de `rustc` y `cargo` (las dos herramientas +*requeridas*) y de si `git`, `clippy`, `rustfmt` y `docker` están en el `PATH` (las +*opcionales*), más el paquete, el archetype del proyecto detectado y si hay un +`firefly.yaml` y un `migrations/` presentes — terminando con "All required checks +passed!" o una lista de qué arreglar. `firefly completion ` imprime un script +de autocompletado de shell generado a partir de la definición viva de la CLI, así +que siempre coincide con los subcomandos y flags disponibles. `firefly sbom` y +`firefly license` leen `Cargo.lock` para producir una lista de materiales de +software (SBOM) y un informe de licencias de dependencias para un pipeline de +cumplimiento. + +> **Tip** **Punto de control.** Ejecuta `firefly doctor`. Dentro del workspace del +> framework informa de `rustc` y `cargo` como comprobaciones requeridas superadas e +> imprime un bloque `Project`. La línea final es "All required checks passed!". + +## Paso 11 — Ejecutar la CLI a través de Cargo + +Si no has instalado el binario, pilota la CLI a través de Cargo desde un checkout +del framework — útil en CI, o mientras iteras sobre la propia CLI. + +```bash +make cli ARGS="doctor" +make cli ARGS="new orders --archetype web-api" +cargo run -p firefly-cli --bin firefly -- info +``` + +Lo que acaba de pasar: cada forma ejecuta el mismísimo binario `firefly`, solo que +sin instalarlo primero. El `--` separa los argumentos propios de Cargo de los que +se pasan a `firefly`. + +## Resumen — la CLI se corresponde con crates que ya conoces + +No cambiaste `samples/lumen` en este capítulo; es operativo. Pero viste el camino +de la CLI hacia cada artefacto que Lumen hizo crecer a mano: + +- `firefly new --archetype web-api` andamia el esqueleto del + [Inicio rápido](./02-quickstart.md) — punto de entrada, controlador, árbol por + capas, `Cargo.toml`, `firefly.yaml`, `Dockerfile`, `tests/`. +- `firefly generate command/query/aggregate/saga/migration` escribe las piezas de + CQRS, DDD, orquestación y esquema — como funciones registradoras y construcciones + `firefly-*` reales, no marcadores de posición. +- `firefly run --bin lumen` lo lanza, mapeando los flags `-p`/`-D`/`--env` al + entorno `FIREFLY_*`, y `--env FIREFLY_SERVER_ADDR/MANAGEMENT_ADDR` mueve los dos + puertos. +- `firefly build info` sella el `build-info.json` que el contribuidor de + compilación de `/actuator/info` expone; `firefly db` pilota el ejecutor + forward-only `firefly-migrations` una vez que adoptas un almacén SQL. +- `firefly health/routes/beans/conditions --url http://localhost:8081` + introspecciona la superficie de actuator a través de HTTP, que es por lo que + `--url` es obligatorio: un binario compilado no tiene contexto offline que + arrancar. + +El hilo conductor: la CLI nunca inventa una API. Cada comando llama a un crate del +framework (`firefly-migrations`, `firefly-openapi`, `firefly-client`) o a un +endpoint de actuator que ya conoces, de modo que la línea de comandos no es más que +una puerta más rápida al mismo edificio. + +## Ejercicios + +1. **Andamia un gemelo de Lumen.** Ejecuta `firefly new lumen2 --archetype web-api + --features web,cqrs --dry-run`, y luego de nuevo sin `--dry-run`. Compara el + árbol `src/` generado con el de Lumen, y hazle un `cargo build`. +2. **Genera las piezas de CQRS.** En el proyecto andamiado, ejecuta `firefly generate + command OpenWallet` y `firefly generate query GetWallet`. Abre los cuatro + archivos generados y confirma que los handlers son funciones registradoras + `register__handler(bus: &Bus)` que llaman a `bus.register(...)` — la forma + de registro de [CQRS](./09-cqrs.md), no una macro. +3. **Aprende el mapeo de entorno.** Arranca la aplicación con `firefly run -p dev -D + logging.level-root=DEBUG --dry-run` y lee el entorno `FIREFLY_*` resuelto que + exportaría. Luego mueve los puertos de verdad con `firefly run + --bin lumen --env FIREFLY_SERVER_ADDR=127.0.0.1:9090 --env + FIREFLY_MANAGEMENT_ADDR=127.0.0.1:9091` y `curl localhost:9091/actuator/health`. +4. **Introspecciona la Lumen real.** `cargo run --bin lumen`, luego en otra shell + ejecuta `firefly health --url http://localhost:8081`, `firefly routes --url + http://localhost:8081` y `firefly beans --url http://localhost:8081`. Coteja la + tabla de rutas con las constantes de endpoint en `src/web.rs`. +5. **Audita el toolchain.** Ejecuta `firefly doctor` en tu máquina y anota qué + herramientas opcionales (`git`, `clippy`, `rustfmt`, `docker`) están presentes, y + luego ejecuta `firefly sbom --json | head` para ver el manifiesto de + dependencias resueltas que la CLI lee de `Cargo.lock`. + +## Adónde ir después + +Con un proyecto andamiado, generado, ejecutado e introspeccionado, el siguiente +capítulo lleva a Lumen hasta producción — cambiando el almacén de eventos en +proceso por Postgres y Kafka, donde `firefly db` y `firefly build` finalmente se +ganan el sueldo. Continúa en **[Producción y despliegue](./20-production.md)**. diff --git a/docs/book/src-es/20-production.md b/docs/book/src-es/20-production.md new file mode 100644 index 00000000..f452366b --- /dev/null +++ b/docs/book/src-es/20-production.md @@ -0,0 +1,734 @@ +# Producción y despliegue + +Lumen ha crecido a lo largo del libro desde un esqueleto desnudo hasta convertirse +en un servicio CQRS seguro, observable y con event sourcing, dotado de una saga, un +flujo de trabajo, una transferencia en dos fases y una tarea programada de +mantenimiento. Todo lo que añadiste llegó de la misma forma: *declarando un bean* +que el framework descubre, y el punto de entrada nunca cambió. Este capítulo final +del arco de construcción cierra el círculo: veremos exactamente cómo ese +**`main` de una línea** arranca y se apaga de forma fiable, activaremos el +**endpoint reactivo de streaming** opcional y recorreremos el camino desde +"funciona en mi máquina" hasta un contenedor en producción: apagado controlado, la +separación entre el puerto público y el de gestión, configuración dirigida por el +entorno, empaquetado y el reemplazo del almacén de eventos y el broker en memoria +por Postgres y Kafka duraderos. + +Nada de esto reescribe Lumen. El endpoint de streaming es un bean más; el cambio a +Postgres es una factoría de bean editada en su sitio; el resto es postura +operativa. Esa es la recompensa del diseño de puertos y adaptadores que has estado +construyendo todo este tiempo. + +Al terminar este capítulo, serás capaz de: + +- Seguir `run()` de principio a fin: el pipeline de arranque de ocho etapas, los + dos servidores y el drenaje controlado ante SIGINT/SIGTERM que asigna un apagado + limpio a `Ok(())`. +- Añadir el endpoint reactivo de streaming opcional (`GET /api/v1/wallets/:id/events` + → NDJSON o SSE) como un bean `RouteContributor` protegido por feature, y entender + por qué el 404 se resuelve antes de que arranque el cuerpo del streaming. +- Servir el actuator en un puerto de gestión separado y protegido por firewall, y + apuntar las sondas de liveness/readiness de tu orquestador a las sub-rutas + correctas. +- Activar el middleware de endurecimiento para producción a través de `CoreConfig` + y leer la cadena efectiva de filtros de fuera hacia dentro. +- Reemplazar el almacén de eventos y el broker en memoria por Postgres y Kafka + editando una sola factoría `#[bean]`, sin que cambie nada aguas abajo. +- Empaquetar Lumen como un contenedor y verificarlo frente a una lista de + comprobación de despliegue. + +## Conceptos que conocerás + +Antes del primer paso, aquí están las ideas en las que se apoya este capítulo. +Cada una se reintroduce en contexto donde se usa por primera vez; esta es la +versión corta. + +> **Note** **Término clave — apagado controlado (graceful shutdown).** El *apagado +> controlado* significa que, cuando se pide al proceso que se detenga, deja de +> aceptar nuevas peticiones, permite que terminen las peticiones en curso (dentro +> de un presupuesto de tiempo) y solo entonces sale. El análogo en Spring Boot es +> el ajuste `server.shutdown=graceful` más el drenaje del servidor embebido; +> Firefly lo hace por defecto sin ninguna configuración. + +> **Note** **Término clave — superficie de gestión.** La *superficie de gestión* es +> el conjunto de endpoints HTTP operativos —salud, info, métricas, entorno, control +> del nivel de log— que existen para operadores y orquestadores, no para usuarios +> finales. Firefly los sirve en un listener separado del de tu API de negocio. Esto +> refleja Spring Boot Actuator en un `management.server.port` dedicado. + +> **Note** **Término clave — RouteContributor.** Un *`RouteContributor`* es un bean +> que aporta un sub-router (`axum::Router`) a la API pública. El framework descubre +> cada bean `RouteContributor` y fusiona sus rutas en la aplicación ensamblada, de +> modo que puedes añadir rutas sin tocar `main` ni ningún `#[rest_controller]`. El +> análogo en Spring es aportar un bean `RouterFunction` que el +> contexto recoge automáticamente. + +> **Note** **Término clave — stream reactivo / `Flux`.** Un *`Flux`* es una +> secuencia reactiva de cero o más valores `T` producidos a lo largo del tiempo con +> contrapresión (backpressure): el análogo en Rust del `Flux` de Project Reactor. +> Devuelto desde un handler como `application/x-ndjson` o `text/event-stream`, hace +> streaming elemento a elemento hacia el cliente en lugar de almacenar en búfer una +> respuesta completa. + +> **Note** **Término clave — puerto y adaptador.** Un *puerto* es una capacidad +> abstracta de la que depende el dominio (aquí `EventStore`, `Broker`); un +> *adaptador* es una implementación concreta de ese puerto (en memoria hoy, +> Postgres/Kafka en producción). El dominio solo habla con el puerto, así que +> cambiar el adaptador no altera nada aguas abajo. Este es el patrón de arquitectura +> hexagonal que Spring expresa con interfaces y factorías `@Bean`. + +## Paso 1 — Lee el `main` de una línea una vez más + +Abre `src/main.rs`. Después de cada capítulo del arco de construcción, el punto de +entrada sigue siendo una única llamada: + +```rust,ignore +// src/main.rs +#[tokio::main] +async fn main() -> Result<(), firefly::BoxError> { + firefly::FireflyApplication::new("lumen").run().await +} +``` + +Lo que acaba de ocurrir: esa única llamada a `run()` hace component-scan de los +beans de Lumen —el bus CQRS, el ledger con event sourcing, la proyección del +modelo de lectura, la caché de consultas y la cadena de seguridad, todo sobre +infraestructura en memoria—, auto-monta cada `#[rest_controller]`, autodescubre la +seguridad y el middleware del bus de read-cache, drena los handlers CQRS / el +listener EDA / la tarea `#[scheduled]` registrados en el inventario, auto-hospeda el +panel de administración, imprime el banner y el informe de arranque línea por línea +y sirve ambos puertos con apagado controlado. Todo lo que has visto capítulo a +capítulo se *declara como un bean*; `main` simplemente entrega el crate al framework. + +> **Note** El binario de Lumen arranca con un almacén de eventos y un broker en +> memoria, así que `cargo run --bin lumen` no necesita nada externo: ni base de +> datos ni message broker. Los tests ejercitan el mismo cableado en proceso a través +> de `build_router()`, que llama a `FireflyApplication::bootstrap()` en lugar de +> servir sobre un socket. + +> **Tip** **Punto de control.** `cargo run --bin lumen` imprime el banner de +> Firefly, las dos URL de gestión y el informe de arranque, y luego se queda en +> ejecución en `:8080` (público) y `:8081` (gestión). `Ctrl-C` sale limpiamente. Si +> eso funciona, el resto de este capítulo trata de lo que sucede por debajo y de cómo +> llevarlo a producción. + +## Paso 2 — Entiende el pipeline de arranque + +No hay cableado de ciclo de vida escrito a mano en Lumen: `run()` lo hace todo. Por +debajo, `run()` son exactamente dos llamadas: + +```rust,ignore +// firefly::FireflyApplication::run (simplified) +pub async fn run(self) -> Result<(), BoxError> { + self.bootstrap().await?.serve().await +} +``` + +`bootstrap()` ensambla la aplicación completamente cableada y devuelve un valor +`Bootstrapped` (el router, el contenedor de DI, el scheduler y las dos direcciones +de bind) *sin* servir. `serve()` lo ejecuta luego sobre el `Application` del ciclo +de vida, que atrapa SIGINT/SIGTERM, da a cada tarea de servidor su propia señal de +drenaje y concede un presupuesto de drenaje antes de salir. El pipeline, en orden: + +1. **Construir la pila web** y bifurcar el logging hacia el búfer de captura del + panel de administración. +2. **Hacer component-scan del contenedor**: auto-registrar los beans de + infraestructura del framework y luego descubrir las factorías + `#[derive(Configuration)]` / `#[bean]` de Lumen, los controladores + `#[derive(Controller)]` y los campos `#[autowired]`. +3. **Auto-configurar el bus CQRS**: propagación de correlación siempre; el + middleware de read-cache porque Lumen declara un bean `QueryCache`. +4. **Autodescubrir la seguridad**: los beans `FilterChain` + `BearerLayer` + ([Seguridad](./14-security.md)), aplicados como capas sobre la API sin ninguna + llamada `.security(...)`. +5. **Auto-montar los controladores**: cada `#[rest_controller]` y cada bean + `RouteContributor` (incluido el endpoint de streaming añadido en el Paso 3), + luego aplicar la cadena de middleware y originar el contexto de traza W3C. +6. **Drenar los handlers descubiertos**: los handlers de comando/consulta CQRS, el + listener de proyección EDA y la tarea de mantenimiento `#[scheduled]`, desde los + registros de inventario. +7. **Auto-hospedar el panel de administración** en el puerto de gestión y servir + automáticamente los docs de OpenAPI (Swagger UI, ReDoc, la especificación + OpenAPI 3.1), todo en el puerto de gestión, nunca en el público. +8. **Imprimir el informe de arranque** y luego **servir ambos puertos** con + drenaje controlado. + +> **Note** **Término clave — `bootstrap()` frente a `serve()`.** `bootstrap()` es la +> costura de pruebas (test seam): devuelve la app `Bootstrapped` cableada —incluyendo +> `Bootstrapped::api_router`, el router público completamente ensamblado— sin enlazar +> un socket, de modo que los tests ejercitan la app real en proceso. `serve()` es la +> ruta de producción que de hecho escucha. `run()` no es más que +> `bootstrap().await?.serve().await`. + +Los dos servidores y el drenaje son la parte que importa para producción: + +- **Dos servidores, dos drenajes.** La API pública sirve en `:8080` y la superficie + de gestión (`/actuator/*` más el panel `/admin` auto-hospedado más los docs de la + API) en `:8081`. Cada uno se ejecuta en su propia tarea con su propio handle de + `shutdown`, de modo que una señal drena ambos listeners de forma independiente: + `axum::serve(...).with_graceful_shutdown(shutdown.wait())` por servidor. +- **`run()` se bloquea hasta una señal.** Retorna cuando se recibe SIGINT/SIGTERM y + el drenaje se completa. Un apagado limpio aflora internamente como un error + *cancelado*, que `serve()` asigna a `Ok(())`; cualquier otro error se propaga + fuera de `main` y el proceso sale con código distinto de cero. +- **Binds sobreescribibles por el entorno.** `FIREFLY_SERVER_ADDR` y + `FIREFLY_MANAGEMENT_ADDR` sobreescriben los valores por defecto + (`0.0.0.0:8080` / `0.0.0.0:8081`), de modo que un contenedor lee sus puertos del + entorno sin cambiar código. + +Merece la pena ver exactamente la asignación "cancelado es limpio", porque es la +razón por la que `Ctrl-C` no es un error: + +```rust,ignore +// Bootstrapped::serve (the tail of it) +match application.run().await { + Ok(()) => Ok(()), + // A handle/signal-triggered stop is a clean shutdown, not a failure. + Err(err) if err.is_cancelled() => Ok(()), + Err(err) => Err(Box::new(err)), +} +``` + +Lo que acaba de ocurrir: el `Application` del ciclo de vida ejecuta ambas tareas de +servidor; un SIGINT/SIGTERM las cancela, lo que aflora como un error *cancelado*; +`serve()` captura exactamente ese caso y devuelve `Ok(())`, de modo que `main` sale +con código cero. Cualquier fallo genuino (un puerto ya enlazado, un panic en una +tarea de servidor) se propaga y el proceso sale con código distinto de cero, que es +lo que quieres que provoque un reinicio en un orquestador. + +> **Tip** **Punto de control.** Ejecuta `cargo run --bin lumen` y luego pulsa +> `Ctrl-C`. El proceso sale sin traza de pila y con código cero (`echo $?` imprime +> `0`). Esa es la asignación cancelado-a-`Ok(())` en acción. + +## Paso 3 — Añade el endpoint reactivo de streaming (feature `streaming`) + +El último endpoint de Lumen hace streaming del historial de eventos de un wallet. +Está protegido por feature para que la base didáctica se mantenga ligera: no +necesita nada más allá de la fachada `firefly` (`firefly::reactive::Flux` más +`firefly::web::{NdJson, Sse}`). `Cargo.toml` ya declara el flag, desactivado por +defecto: + +```toml +# Cargo.toml +[features] +# The reactive streaming endpoint is feature-gated so the teaching baseline +# stays lean; this chapter turns it on. It needs nothing beyond the facade. +default = [] +streaming = [] +``` + +### 3a — Declara la ruta como un bean + +El endpoint se cablea **declarando un bean**, no editando un punto de entrada. Un +`#[derive(Service)]` que `provides = "dyn firefly::web::RouteContributor"` aporta el +sub-router; `FireflyApplication` lo resuelve como el puerto `dyn RouteContributor` +(Paso 2, etapa 5) y fusiona sus rutas, de modo que un endpoint protegido por feature +aparece en la API simplemente porque su crate lo compiló. Añade esto a `src/web.rs`: + +```rust,ignore +// src/web.rs +/// (feature `streaming`) A `RouteContributor` bean adding the reactive +/// `GET /api/v1/wallets/:id/events` endpoint. The framework discovers it +/// (resolved as the `dyn RouteContributor` port) and merges its routes — a +/// feature-gated endpoint wired by declaring a bean, not by a composition root. +#[cfg(feature = "streaming")] +#[derive(Service)] +#[firefly(provides = "dyn firefly::web::RouteContributor")] +struct StreamingRoutes { + #[autowired] + api: Arc, +} + +#[cfg(feature = "streaming")] +impl firefly::web::RouteContributor for StreamingRoutes { + fn routes(&self) -> axum::Router { + streaming_router((*self.api).clone()) + } +} +``` + +Lo que acaba de ocurrir, bloque a bloque: + +- `#[derive(Service)]` convierte `StreamingRoutes` en un bean de DI. El atributo + `#[firefly(provides = "dyn firefly::web::RouteContributor")]` lo registra bajo el + *puerto* `RouteContributor`, de modo que el framework lo encuentra cuando recopila + los contribuidores de rutas; nunca nombras `StreamingRoutes` en ningún otro lugar. +- `#[autowired] api: Arc` inyecta el mismo bean de controlador que usa el + `#[rest_controller]`, así que el stream lee los mismos wallets que escribe el + resto de la API. +- `impl RouteContributor` devuelve el sub-router que construye `streaming_router`. + `RouteContributor::routes(&self) -> axum::Router` es el único método que requiere + el trait. + +> **Note** Todo en esta sección está detrás de `#[cfg(feature = "streaming")]`, de +> modo que con la feature desactivada el archivo compila sin nada adicional y el +> endpoint no existe. Activarlo es un flag de compilación, no un cambio de código en +> `main`. + +### 3b — Construye el sub-router y el handler + +El sub-router mapea la única ruta al handler sobre el estado del controlador, y el +handler carga los eventos persistidos del wallet, los mapea a la forma de vista, los +envuelve en un `Flux` y devuelve NDJSON por defecto o SSE cuando se pasa +`?format=sse`: + +```rust,ignore +// src/web.rs +/// Builds the streaming sub-router over the controller state. +#[cfg(feature = "streaming")] +fn streaming_router(api: WalletApi) -> axum::Router { + axum::Router::new() + .route( + "/api/v1/wallets/:id/events", + axum::routing::get(stream_events), + ) + .with_state(api) +} + +/// The reactive streaming handler: builds a `Flux` over the +/// wallet's persisted stream and returns it as NDJSON (one JSON document per +/// line) or, with `?format=sse`, as Server-Sent Events. +#[cfg(feature = "streaming")] +async fn stream_events( + State(api): State, + Path(id): Path, + axum::extract::Query(params): axum::extract::Query, +) -> Response { + use crate::domain::WalletEvent; + use axum::response::IntoResponse; + use firefly::reactive::Flux; + use firefly::web::{NdJson, Sse}; + + // `load_events` returns `Err(NotFound)` for an absent wallet, so the 404 is + // decided before the streaming response head is committed. + let events = match api.ledger.load_events(&id).await { + Ok(events) => events, + Err(e) => return WebError::from(domain_to_web(e)).into_response(), + }; + let items: Vec = events.iter().map(WalletEvent::from_domain).collect(); + let flux = Flux::just(items); + if params.format.as_deref() == Some("sse") { + Sse(flux).into_response() + } else { + NdJson(flux).into_response() + } +} +``` + +Lo que acaba de ocurrir, bloque a bloque: + +- `streaming_router` devuelve un `axum::Router` simple con `GET + /api/v1/wallets/:id/events` mapeado a `stream_events` y el estado `WalletApi` + adjunto. Este es el sub-router que `StreamingRoutes::routes` entrega. +- `stream_events` primero llama a `api.ledger.load_events(&id)`. Si el wallet está + ausente, el ledger devuelve `Err(NotFound)`, y el handler lo renderiza como un 404 + RFC 9457 `application/problem+json` *y retorna*, antes de que haya comenzado ningún + cuerpo de streaming. +- En caso de éxito, mapea los eventos de dominio a la forma de vista `WalletEvent`, + envuelve el `Vec` en `Flux::just(...)` y elige la codificación: `Sse(flux)` para + `?format=sse`, en caso contrario `NdJson(flux)`. + +> **Note** **Término clave — `NdJson` y `Sse`.** `NdJson(flux)` (`pub struct +> NdJson(pub Flux)`) renderiza el `Flux` como un documento JSON por línea con +> tipo de contenido `application/x-ndjson`; `Sse(flux)` renderiza Server-Sent Events +> con tipo de contenido `text/event-stream`. Ambos envuelven un `Flux` e +> implementan `IntoResponse`, de modo que un handler los devuelve directamente. + +> **Warning** Aquí el orden importa. El 404 para un wallet desconocido debe +> resolverse *antes* de que se confirme la cabecera de respuesta, porque una vez que +> arranca un cuerpo de streaming la línea de estado ya está en el cable y no puede +> cambiar. Por eso `load_events` se espera y se comprueba primero, y solo entonces se +> construye un `Flux`. + +> **Note** `Flux::just(items)` materializa un `Vec` conocido: perfecto para un +> historial de eventos finito que ya está cargado. Un stream de producción sobre una +> fuente viva y no acotada (p. ej. una suscripción a un broker) usaría en su lugar +> `Flux::from_stream(...)`, de modo que el cuerpo se produzca de forma perezosa con +> contrapresión en lugar de almacenarse en búfer por adelantado. + +### 3c — Demuestra los tres comportamientos con un test + +`src/streaming_test.rs` (compilado solo bajo `#[cfg(all(test, feature = +"streaming"))]`) arranca un contexto de app, abre un wallet, hace un depósito —de +modo que el stream tiene dos eventos— y verifica el NDJSON por defecto, el cambio a +SSE y el 404. El caso por defecto: + +```rust,ignore +// src/streaming_test.rs +#[tokio::test] +async fn events_stream_as_ndjson_by_default() { + let app = build_router().await; + let id = open_with_deposit(&app).await; // two events: WalletOpened + MoneyDeposited + let res = app + .clone() + .oneshot( + Request::get(format!("/api/v1/wallets/{id}/events")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + let ct = res + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned(); + assert!(ct.contains("ndjson"), "default stream should be NDJSON, got {ct:?}"); + + let body = res.into_body().collect().await.unwrap().to_bytes(); + let text = String::from_utf8(body.to_vec()).unwrap(); + let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!(lines.len(), 2, "expected 2 NDJSON lines, got: {text:?}"); + assert!(text.contains("WalletOpened")); + assert!(text.contains("MoneyDeposited")); +} +``` + +Lo que acaba de ocurrir: `build_router().await` devuelve el router público +completamente cableado en proceso (llama a `FireflyApplication::bootstrap()` por +debajo, como en [Pruebas](./18-testing.md)). El test lo ejercita con +`tower::ServiceExt::oneshot` —sin enlazar ningún socket—, abre un wallet con un +depósito, luego hace `GET` al stream de eventos y verifica que la respuesta es +`200`, es `application/x-ndjson` y lleva exactamente dos líneas JSON (el +`WalletOpened` y el `MoneyDeposited`). Los tests hermanos verifican que +`?format=sse` cambia el tipo de contenido a `text/event-stream` y que un id de +wallet desconocido es un `404`. + +> **Tip** **Punto de control.** Compila y prueba con la feature activada: +> +> ```bash +> cargo test -p firefly-sample-lumen --features streaming +> ``` +> +> Los tres tests de streaming pasan. Luego ejecuta el binario de la misma forma — +> `cargo run --bin lumen --features streaming`—, abre un wallet, haz un depósito y +> `curl http://127.0.0.1:8080/api/v1/wallets//events` para ver las dos líneas +> NDJSON en el puerto público. + +## Paso 4 — Separa la superficie de gestión para producción + +Sirve siempre el actuator en un **listener diferente** del de la API pública para +que `/actuator/*` sea alcanzable por tu orquestador pero nunca en la red pública: +exactamente la separación `:8080` / `:8081` que Lumen usa por defecto. Protege con +firewall el puerto de gestión hacia la red interna de tu clúster. Las sub-rutas de +salud alimentan las sondas de tu orquestador: + +| Sonda | Endpoint | +|-----------|-------------------------------------| +| liveness | `/actuator/health/liveness` | +| readiness | `/actuator/health/readiness` | +| general | `/actuator/health` | + +Lo que acaba de ocurrir: el router de gestión (Paso 2, etapa 7) monta el árbol +completo del actuator más el panel de administración y los docs de la API. La sonda +de liveness reporta solo los indicadores etiquetados para liveness (¿está vivo el +proceso?); la de readiness reporta solo los indicadores de readiness (¿puede servir +tráfico, están las dependencias en pie?). Apunta la sonda de liveness de tu +orquestador a `/actuator/health/liveness` y su sonda de readiness a +`/actuator/health/readiness`, ambas en `:8081`. + +> **Note** Las métricas para scraping viven en el mismo puerto de gestión: +> `/actuator/prometheus` sirve la exposición de Prometheus con etiquetas, y +> `/actuator/metrics` sirve la vista JSON. Apunta tu scraper a `:8081`, nunca a +> `:8080`. + +> **Tip** **Punto de control.** Con Lumen en ejecución, desde una segunda terminal: +> `curl localhost:8081/actuator/health/readiness` devuelve un cuerpo JSON con un +> `"status"`, y la misma ruta en `:8080` no devuelve nada: el puerto público no +> tiene `/actuator/*`. + +## Paso 5 — Activa el middleware de endurecimiento para producción + +El framework ya activa las "pilas" de la capa web cuando `FireflyApplication` +construye la pila web: el renderizador de problemas RFC 9457, la propagación del +id de correlación, la originación del contexto de traza W3C, las métricas de +peticiones y el replay de idempotencia. El middleware de producción restante es +opt-in a través de `CoreConfig`, ajustado vía +`FireflyApplication::configure(|cfg| { … })`, y cada parámetro teje su capa en el +orden de filtro correcto: + +```rust,ignore +// Opt-in production middleware, tuned at the entry point. +firefly::FireflyApplication::new("lumen") + .configure(|cfg| { + cfg.cors = Some(firefly::web::CorsConfig::default()); + cfg.security_headers = Some(firefly::web::SecurityHeadersConfig::default()); + cfg.csrf = Some(firefly::web::CsrfLayer::new()); // browser flows only + cfg.request_log = Some(firefly::web::RequestLogLayer::default()); + }) + .run() + .await +``` + +Los parámetros y lo que añade cada uno: + +| Parámetro | Añade | +|--------------------|-------------------------------------------------------| +| `cors` | preflight CORS + decoración de simple-request | +| `security_headers` | cabeceras de respuesta OWASP (`nosniff`, `DENY`, HSTS, …) | +| `csrf` | CSRF de double-submit-cookie (para flujos de navegador) | +| `request_log` | un evento estructurado de access-log por petición | +| `request_metrics` | `http_server_requests_seconds` + `_max` (actuator) | +| `http_exchanges` | registrador de intercambios recientes + `/actuator/httpexchanges` | +| `loggers` | control en runtime del nivel de log en `/actuator/loggers` | + +Lo que acaba de ocurrir: `configure(|cfg| { … })` te entrega el `CoreConfig` antes +de que se construya la pila web, de modo que las capas que activas se tejen en el +arranque. Cada parámetro opcional está OFF por defecto, salvo las métricas de +peticiones, que están activadas por defecto (auto-instrumentación al estilo Spring +Boot) y se ajustan —o se desactivan— a través de `request_metrics` / +`disable_request_metrics`. + +La cadena efectiva, de la más externa (la más cercana a la red) a la más interna (la +más cercana a tu handler), es: + +```text +CorsLayer (cors) — preflight + simple-request edge +ProblemLayer (always) — panic → RFC 9457 500 +SecurityHeadersLayer (security_headers) — decorate every response +TraceContextLayer (always) — validate/originate W3C traceparent +CorrelationLayer (always) — X-Correlation-Id (+ request ctx) +MetricsLayer (request_metrics) — http_server_requests_* +HttpExchangesLayer (http_exchanges) — record into the recorder +RequestLogLayer (request_log) — one access-log event +CsrfLayer (csrf) — double-submit cookie +IdempotencyLayer (always) — replay on Idempotency-Key + │ + ▼ + your router +``` + +La idempotencia se queda en la posición más interna para que una petición repetida +siga pasando por todas las preocupaciones externas (correlación, métricas, el +access-log). El `TraceContextLayer` de W3C se sitúa justo por fuera de la correlación +para poder originar un span raíz y un `traceparent` que la capa interna de +correlación luego hace eco en la respuesta. + +> **Design note.** Esta es la misma postura de actuator y middleware que trae Spring +> Boot, pero activada de forma declarativa en un único punto de llamada en lugar de a +> través de una dispersión de propiedades y clases `@Configuration`. Un +> `FireflyApplication` desnudo ya te da el núcleo siempre activo de Problem → +> TraceContext → Correlation → Idempotency; `configure(...)` añade el resto. + +> **Tip** **Punto de control.** Ejecuta Lumen con `security_headers` activado y +> `curl -i localhost:8080/api/v1/wallets/anything`. La respuesta lleva +> `X-Content-Type-Options: nosniff` y `X-Frame-Options: DENY` incluso en el cuerpo de +> problema del 404: prueba de que la capa decora cada respuesta, incluidos los +> errores recuperados. + +## Paso 6 — Configura para producción desde el entorno + +Enlaza la configuración desde fuentes en capas con las sobreescrituras del entorno +en la cima, de modo que un contenedor lea sus ajustes del entorno +([Configuración](./03-configuration.md)). Para Lumen las dos direcciones de bind ya +están dirigidas por el entorno, así que el contenedor de producción no necesita +ningún archivo de configuración solo para mover sus puertos: + +```bash +FIREFLY_PROFILE=prod \ +FIREFLY_SERVER_ADDR=0.0.0.0:8080 \ +FIREFLY_MANAGEMENT_ADDR=0.0.0.0:8081 \ + ./lumen +``` + +Lo que acaba de ocurrir: `FIREFLY_SERVER_ADDR` / `FIREFLY_MANAGEMENT_ADDR` se leen en +tiempo de construcción (Paso 2) y sobreescriben los valores por defecto +`0.0.0.0:8080` / `0.0.0.0:8081`, mientras que `FIREFLY_PROFILE=prod` selecciona la +capa de propiedades de producción. Las variables `FIREFLY_*` ganan a los archivos +YAML, los secretos se enmascaran en `/actuator/env` y los marcadores `${...}` se +resuelven entorno-luego-config-luego-defecto. + +> **Warning** La clave de firma JWT de [Seguridad](./14-security.md) es lo más +> evidente que se inyecta de esta forma —desde el entorno o un almacén de secretos— +> en lugar de la constante `DEMO_SIGNING_KEY` en línea que Lumen trae con fines +> didácticos. Nunca incrustes una clave de firma real en el binario ni la subas al +> control de versiones. + +## Paso 7 — Reemplaza la infraestructura en memoria por Postgres y Kafka + +Esta es la recompensa de toda la arquitectura. Lumen reemplaza sus valores por +defecto en memoria por backends reales **cambiando una factoría `#[bean]` en +`LumenBeans`**, y nada aguas abajo cambia. Recuerda la factoría en memoria de +`src/web.rs`: + +```rust,ignore +// src/web.rs — today +#[bean] +impl LumenBeans { + /// The in-memory event store (`@Bean`). + #[bean] + fn event_store(&self) -> MemoryEventStore { + MemoryEventStore::new() + } + + // … the ledger factory autowires the EventStore + the framework Broker port + #[bean] + fn ledger(&self, store: Arc, broker: Arc) -> Ledger { + let store: Arc = store; + Ledger::new(store, broker) + } +} +``` + +Para pasar a Postgres, devuelve el almacén de eventos respaldado por SQL del +framework (`firefly::eventsourcing::SqlEventStore`) detrás del mismo puerto +`EventStore`. Toma un puerto `Database`, así que el cambio queda contenido en la +factoría: + +```rust,ignore +// src/web.rs — production: a Postgres-backed event store behind the EventStore port. +use firefly::eventsourcing::{EventStore, SqlEventStore}; + +#[bean] +impl LumenBeans { + /// An async `#[bean]` factory: connect the pool, build the SQL event store, + /// create its table once, and hand back a `dyn EventStore` for the `ledger` + /// factory to autowire. Any error here aborts startup (fail-fast). + #[bean] + async fn event_store(&self, db: Arc) -> SqlEventStore { + let store = SqlEventStore::new(db); + store.initialize().expect("create event-store table"); + store + } +} +``` + +Lo que acaba de ocurrir: la factoría `ledger` depende del *puerto* `EventStore`, y +el `Ledger`, la proyección del modelo de lectura, los handlers CQRS, la saga, la +transferencia TCC y todos los tests están escritos contra los puertos `EventStore` y +`Broker`, de modo que el dominio del wallet nunca se entera de que pasó de un +`HashMap` a Postgres. La misma forma se aplica a la mensajería: donde Lumen +sobreescribe el broker, un `#[bean]` devuelve un adaptador de Kafka detrás del puerto +`Broker` del framework, y el listener de proyección EDA consume de Kafka en lugar del +bus en proceso sin cambiar una sola línea de la proyección. + +> **Note** El bean `event_store` aquí es una `async fn`. El framework espera las +> factorías de bean asíncronas durante el component-scan (Paso 2, etapa 2), de modo +> que el pool se conecta y el almacén está vivo antes de que nada lo resuelva, y un +> fallo de conexión aborta el arranque en lugar de aflorar en la primera petición. +> Esa es la propiedad fail-fast que quieres en producción. + +> **Design note.** "Cambia el adaptador, conserva el código" aplicado a las capas de +> almacenamiento y mensajería, con el cambio localizado en una única factoría de +> bean. El dominio, los handlers, la proyección, la saga y los tests están escritos +> contra puertos: exactamente el diseño hexagonal hacia el que ha construido este +> libro. + +> **Tip** **Punto de control.** No necesitas levantar realmente Postgres para +> aprender la forma: lee la factoría `ledger` y confirma que nombra +> `Arc`, no `MemoryEventStore`. Cualquier cosa que autowire el +> *puerto* está lista para el cambio por construcción. + +## Paso 8 — Empaqueta Lumen como un contenedor + +Una compilación multi-etapa típica compila el binario de release en una imagen de +Rust y luego copia solo el binario en una imagen de runtime ligera: + +```dockerfile +# Dockerfile +FROM rust:1.88 AS build +WORKDIR /app +COPY . . +RUN cargo build --release -p firefly-sample-lumen + +FROM debian:bookworm-slim +COPY --from=build /app/target/release/lumen /usr/local/bin/lumen +EXPOSE 8080 8081 +ENTRYPOINT ["/usr/local/bin/lumen"] +``` + +Lo que acaba de ocurrir: la etapa `build` compila el paquete `firefly-sample-lumen` +(su `[[bin]]` se llama `lumen`, así que el artefacto queda en +`target/release/lumen`); la etapa de runtime copia solo ese binario en una imagen +mínima de Debian y expone ambos puertos. Como `run()` atrapa SIGTERM y drena (Paso +2), el contenedor se detiene limpiamente cuando el orquestador envía una señal de +terminación: sin necesidad de un shim `--init` ni de un wrapper de reenvío de +señales. + +> **Tip** **Punto de control.** `docker build -t lumen .` produce una imagen, y +> `docker run -p 8080:8080 -p 8081:8081 lumen` la arranca. `docker stop` sobre ese +> contenedor sale limpiamente (sin recurrir a SIGKILL dentro del presupuesto de +> drenaje), porque el binario gestiona SIGTERM por sí mismo. + +## Una lista de comprobación de despliegue + +- [ ] Actuator (`:8081`) en un puerto **separado y protegido por firewall** del de la + API (`:8080`). +- [ ] Sondas de liveness/readiness apuntadas a + `/actuator/health/{liveness,readiness}`. +- [ ] `security_headers`, `cors` y (para flujos de navegador) `csrf` activados a + través de `configure(...)`. +- [ ] `request_log` + `request_metrics` activados; logs enviados como JSON, métricas + raspadas desde `/actuator/prometheus`. +- [ ] Propagación de correlación verificada de extremo a extremo entre servicios. +- [ ] Clave de firma JWT inyectada desde el entorno / un almacén de secretos, no en + línea. +- [ ] Almacén de eventos + broker en memoria reemplazados por Postgres + Kafka en las + factorías `#[bean]` de `LumenBeans`. +- [ ] Presupuesto de drenaje del apagado controlado ajustado a la transferencia en + curso más lenta. +- [ ] La puerta de verificación en verde: `cargo test -p firefly-sample-lumen` y + `--features streaming`, más `clippy -D warnings` y `fmt --check`. + +## Resumen — qué cambió en Lumen + +| Antes de este capítulo | Después de este capítulo | +|---------------------|--------------------| +| un servicio cableado que ejecutabas pero no habías llevado a producción | un contenedor desplegable con la separación de gestión, el middleware de endurecimiento y un camino de cambio de puerto | +| sin endpoint de streaming | el bean `RouteContributor` `StreamingRoutes` protegido por feature sirviendo `GET /api/v1/wallets/:id/events` como NDJSON (por defecto) o SSE (`?format=sse`) | +| solo almacén de eventos / broker en memoria | el cambio de un solo `#[bean]` a `SqlEventStore` + un adaptador `Broker` de Kafka, sin que cambie nada aguas abajo | + +Ahora también sabes: + +- Que `run()` es `bootstrap().await?.serve().await`: un arranque de ocho etapas, dos + servidores cada uno con su propio drenaje, y un error *cancelado* asignado a + `Ok(())` para que un apagado por señal salga con código cero. +- Que un endpoint protegido por feature se cablea simplemente declarando un bean + `RouteContributor` —`main` nunca cambia— y que un handler de streaming debe + resolver su 404 antes de que se confirme la cabecera de respuesta. +- Que el endurecimiento para producción (CORS, cabeceras OWASP, CSRF, access-log) es + opt-in a través de `CoreConfig` en un único punto de llamada `configure(...)`, + tejiéndose en el orden de filtro correcto. +- Que el cambio de almacenamiento y mensajería es una única factoría de bean, porque + el dominio, los handlers, la proyección, la saga y los tests dependen todos de los + puertos `EventStore` y `Broker`. + +Eso completa el arco de construcción guiado. Lumen empezó como un directorio vacío en +[Quickstart](./02-quickstart.md); ahora es un servicio CQRS seguro, observable y con +event sourcing que hace streaming de su historial y se despliega como un único +contenedor, y el `main` de una línea nunca cambió. + +## Ejercicios + +1. **Ejecuta y drena.** `cargo run --bin lumen`, abre un wallet, luego `Ctrl-C` y + observa el drenaje controlado. Confirma que el proceso sale con código cero con + `echo $?`, y que no se imprime ninguna traza de pila: la asignación + cancelado-a-`Ok(())` del Paso 2. +2. **Sobreescribe los puertos.** Arranca Lumen con `FIREFLY_SERVER_ADDR=0.0.0.0:9000 + FIREFLY_MANAGEMENT_ADDR=0.0.0.0:9001 cargo run --bin lumen` y confirma que la API + se movió a `:9000` y el actuator a `:9001`, de forma independiente. +3. **Haz streaming del historial.** Compila con `--features streaming`, abre un + wallet y haz un depósito, luego `curl http://127.0.0.1:8080/api/v1/wallets//events` + (NDJSON) y `…?format=sse` (SSE). Compara las dos cabeceras `content-type` y + confirma que `GET /api/v1/wallets/wlt_missing/events` devuelve un documento de + problema `404`. +4. **Endurece la cadena.** Añade `cfg.security_headers = Some(...)` y + `cfg.request_log = Some(...)` a través de `configure(...)`, vuelve a ejecutar e + inspecciona una respuesta con `curl -i`. Encuentra las cabeceras OWASP, luego sitúa + cada capa en la cadena de fuera hacia dentro del Paso 5. +5. **Esboza el cambio a Postgres.** Escribe el `#[bean]` `event_store` en + `LumenBeans` que devuelve un `SqlEventStore` sobre un puerto `Database`, y explica + en una frase por qué la factoría `ledger`, la proyección del modelo de lectura y + los tests no necesitan ningún cambio. + +## Adónde ir después + +- Revisa los macros declarativos que hicieron posible todo esto —los atributos + `#[bean]`, `#[rest_controller]`, `#[command_handler]`, `#[saga]` y `#[scheduled]` + como broche final— en + **[Servicios declarativos con macros](./21-declarative-macros.md)**. +- Busca cualquier bloque de construcción por crate en el + **[Índice de módulos](./91-appendix-modules.md)**, o cualquier término en el + **[Glosario](./92-glossary.md)**. diff --git a/docs/book/src-es/20a-experience-tier.md b/docs/book/src-es/20a-experience-tier.md new file mode 100644 index 00000000..89f99a70 --- /dev/null +++ b/docs/book/src-es/20a-experience-tier.md @@ -0,0 +1,612 @@ +# La capa de experiencia (BFF) + +Lumen, tal como lo ha construido el libro, es un servicio autocontenido: posee el +dominio de la cartera (wallet) *y* la API HTTP que lo expone. Las instalaciones +reales de Firefly reparten esa responsabilidad entre **tres capas de servicio**, y +Lumen se sitúa en las dos inferiores. Este capítulo introduce la única capa en la +que Lumen jamás entra — la **capa de experiencia**, la capa Backend-for-Frontend +de Firefly — y luego conecta un pequeño BFF contra el servicio que ya tienes. El +BFF compone Lumen como un SDK de dominio aguas abajo, conduce un trayecto +multietapa de "financiar una cartera y confirmar" mediante un workflow regulado +por señales, y sobrevive a una desconexión del cliente persistiendo el estado del +trayecto. + +Como esta capa es terreno nuevo, el capítulo la enseña desde primeros principios: +qué son las tres capas y por qué la dirección de dependencia es unidireccional, +qué te ofrece un `ExperienceStack`, cómo registrar un SDK de dominio, cómo una +puerta de señal (signal gate) aparca un workflow hasta que llega un evento +externo, y cómo persistir y consultar un trayecto que abarca varias peticiones +HTTP. Cada API aquí procede del crate real `firefly-starter-experience` — la misma +superficie que su propio test de arranque ejercita de extremo a extremo. + +Al terminar este capítulo, serás capaz de: + +- Explicar el modelo de capas `channel → experience → domain → core` y por qué la + capa de experiencia solo puede componer SDKs de *dominio* — nunca una base de + datos, un servicio core ni un BFF hermano. +- Construir un `ExperienceStack` (el starter del BFF) y entender cómo hereda todas + las baterías web completas a la vez que añade cinco bloques de construcción de la + capa de experiencia. +- Registrar Lumen como un SDK de dominio con nombre a través de `DomainClients` y + resolverlo por su nombre lógico desde cualquier handler o paso de workflow. +- Modelar un trayecto multipetición como un `Workflow` cuya puerta aparca sobre una + señal con nombre, y reanudarlo entregando esa señal desde una petición posterior. +- Persistir el estado de un trayecto con `WorkflowState` (compatible con Redis) y + responder "¿dónde está mi trayecto?" con `WorkflowQueryService`, de modo que un + cliente pueda reconectarse tras una desconexión. +- Ensamblar el controlador REST atómico de tres endpoints que lo une todo. + +## Conceptos que conocerás + +Estas son las ideas en las que se apoya el capítulo. Cada una se reintroduce en +contexto la primera vez que se usa; esta es la versión breve. + +> **Note** **Término clave — Backend-for-Frontend (BFF).** Un BFF es un servicio +> HTTP que existe para servir a *un* frontend o canal: agrega varios servicios +> aguas abajo en endpoints moldeados exactamente para las pantallas y flujos de esa +> interfaz. No posee base de datos propia. El análogo en Spring es un Spring Cloud +> Gateway / servicio de agregación situado delante de tus microservicios de dominio +> — aquí es una capa de primera clase, con baterías incluidas. + +> **Note** **Término clave — SDK de dominio.** Un *SDK de dominio* no es más que un +> cliente HTTP apuntando a la API pública de un servicio de dominio aguas abajo, +> revestido con la propagación de correlación del framework, el códec JSON, la +> decodificación de errores y el reintento/backoff. Un BFF llama a sus dependencias +> a través de estos SDKs exactamente igual que lo haría cualquier cliente externo — +> nunca accede a sus interioridades. + +> **Note** **Término clave — puerta de señal (signal gate).** Una *puerta de señal* +> es un paso de workflow que aparca (suspende) hasta que se entrega una *señal* con +> nombre desde fuera del workflow — normalmente mediante una petición HTTP +> posterior. Modela "esperar a que el cliente confirme" dentro de un trayecto por lo +> demás secuencial. El análogo en Java/Firefly es un paso `@WaitForSignal`; no hay +> equivalente directo en Spring Boot. + +## Paso 1 — Entender las tres capas de servicio + +Las instalaciones de Firefly se estructuran en tres capas de **servicio** +(distintas de las capas del grafo de crates de la documentación de arquitectura). +La dirección de dependencia es estricta y unidireccional: +`channel → experience → domain → core`. Una capa solo puede llamar a la capa +directamente inferior. + +| Capa de servicio | Posee | Habla con | Starter en Rust | +|--------------|------|----------|--------------| +| **core** | la base de datos (sqlx, migraciones, CRUD) | nada por debajo | `firefly-starter-core` / `firefly-starter-data` | +| **domain** | sagas, CQRS, event sourcing, adaptadores de terceros | SDKs de **core** | `firefly-starter-domain` | +| **experience (BFF)** | workflows dirigidos por señales, agregación sin estado, REST atómico | SDKs de **domain** *únicamente* | `firefly-starter-experience` | + +Lumen — con su ledger basado en event sourcing, su bus CQRS y su saga de +transferencia — es un servicio de **dominio**. Un servicio de **experiencia** es el +BFF que lo expone: agrega uno o varios SDKs de dominio en endpoints moldeados para +un único frontend o canal. **Nunca** posee una base de datos, **nunca** llama +directamente a un servicio core y **nunca** llama a un servicio de experiencia +hermano. Compone SDKs de dominio (sobre `firefly-client`) y nada más. + +Lo que acaba de ocurrir: has situado a Lumen en el mapa. El libro ha estado +construyendo un servicio de dominio todo este tiempo; este capítulo construye la +capa *superior* a él. Conocer la dirección importa porque no es una convención que +debas recordar — la imponen los propios starters. + +> **Design note.** El límite entre capas es un *tipo*, no una regla de revisión de +> código. Un experience stack expone un registro que contiene SDKs de dominio y nada +> más (lo conocerás en el Paso 3), y no tiene ninguna superficie de acceso a datos +> contra la que registrar una base de datos. Un BFF que intentara poseer una tabla o +> marcar un servicio core sencillamente no tiene API para hacerlo — la dirección de +> dependencia es unidireccional por construcción. + +> **Tip** **Punto de control.** Puedes enunciar, sin mirar, qué posee cada capa y a +> quién puede llamar. La capa de experiencia compone SDKs de dominio únicamente; +> Lumen es el servicio de dominio al que llamará el BFF de este capítulo. + +## Paso 2 — Construir el stack del BFF con `ExperienceStack` + +Un BFF vive en su propio crate. A diferencia de un servicio de dominio simple — que +depende únicamente de la fachada `firefly` única — un BFF depende directamente del +starter de la capa de experiencia, porque la fachada lleva los starters de core y +web, pero no el de experiencia. + +> **Note** **Término clave — starter de experiencia.** `firefly-starter-experience` +> es el crate que convierte un servicio web en un BFF. Se construye sobre el starter +> web (de modo que obtienes todas las baterías HTTP) y añade la maquinaria de +> trayectos. El análogo en Spring es un *starter* de Spring Boot que agrupa una +> porción coherente de capacidad detrás de una sola dependencia. + +```toml +# Cargo.toml of a BFF crate (e.g. lumen-bff). Note: the experience starter is a +# direct dependency — the `firefly` facade carries the core + web starters but +# not this one. +[dependencies] +firefly-starter-experience = { version = "26.6.28" } +axum = "0.7" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] } +uuid = { version = "1", features = ["v4"] } +``` + +Con la dependencia en su sitio, una sola llamada construye el stack completo: + +```rust,ignore +use firefly_starter_experience::{CoreConfig, ExperienceStack}; + +let bff = ExperienceStack::new(CoreConfig { + app_name: "lumen-bff".into(), + app_version: "1.0.0".into(), + ..CoreConfig::default() +}); +``` + +Lo que acaba de ocurrir: `ExperienceStack::new(cfg)` construye por debajo un +`WebStack` completo — de modo que el BFF hereda todas las baterías web del +[capítulo de producción](./20-production.md): CORS, cabeceras de seguridad, +métricas de petición, el log de acceso, la propagación del correlation-id, +idempotencia y la superficie del actuator — y luego apila encima los cinco bloques +de construcción de la capa de experiencia. El `CoreConfig` es el mismo valor de +configuración tipado que ha usado el resto del libro; el starter de experiencia +estampa `starter_name` con `"starter-experience"` cuando lo dejas en su valor por +defecto, de modo que el banner y `/actuator/info` reportan la capa. + +Los cinco campos de la capa de experiencia se asientan encima del `web: WebStack` +embebido: + +| Campo | Tipo | Función | +|-------|------|------| +| `clients` | `DomainClients` | el registro de SDKs de dominio, resuelto por nombre lógico | +| `signals` | `Arc` | las puertas de señal sobre las que aparca un paso de workflow | +| `state` | `WorkflowState` | estado de trayecto persistido compatible con Redis, indexado por correlation id | +| `query` | `Arc` | la superficie de consulta del estado del trayecto | +| `children` | `Arc` | composición de workflows hijos para trayectos anidados | + +`ExperienceStack` hace `Deref` hacia su `WebStack` (que a su vez hace deref hacia +`Core`), de modo que cada método y campo de web + core — `apply_middleware`, +`actuator_router`, `new_application`, `with_security`, y los campos `cache`, `bus`, +`scheduler` — es alcanzable directamente sobre el valor `bff`. `Bff` es un alias de +tipo de `ExperienceStack`, así que puedes escribir el tipo de la forma que se lea +mejor en el punto de llamada. + +> **Note** Hay dos formas más de escribir el mismo cableado, conservadas para los +> servicios que migran desde otros ports de Firefly. +> `register_experience_stack(cfg)` es un alias de `ExperienceStack::new(cfg)`, y +> `enable_experience_stack(cfg)` toma un `CoreConfig`, le estampa los valores por +> defecto de la capa (heredando las baterías web) y te lo devuelve — luego se lo +> pasas a `ExperienceStack::new`. Echa mano de la que se lea con más naturalidad; +> ambas cablean el stack idéntico. + +> **Design note.** En un servicio de dominio simple como Lumen, +> `FireflyApplication::new(name).run().await` es la vía llave en mano — hace +> component-scan de beans, automonta controladores, drena los handlers y listeners +> registrados por inventory, aplica seguridad y middleware, autoaloja el panel de +> administración y sirve ambos puertos. Un BFF echa mano directamente de los bloques +> de construcción de más bajo nivel (`apply_middleware`, `with_security`, +> `new_application`) porque su router se ensambla a mano a partir de controladores +> de trayecto regulados por señales, en lugar de automontarse. Esos métodos son los +> mismos que `FireflyApplication` conduce por ti entre bastidores, y siguen estando +> plenamente soportados. + +> **Tip** **Punto de control.** `ExperienceStack::new(...)` devuelve un valor sobre +> el que `bff.app_name` reporta el nombre de tu aplicación y `bff.starter_name` es +> `"starter-experience"`. `bff.clients.is_empty()`, `bff.signals.list_active()` y +> `bff.query.active()` están todos vacíos — los bloques de construcción están +> cableados y a la espera. + +## Paso 3 — Registrar Lumen como un SDK de dominio + +Un BFF alcanza cada servicio de dominio aguas abajo a través de un cliente REST con +nombre — uno por dependencia. `DomainClients` es el registro: registras Lumen bajo +un nombre lógico y luego lo resuelves por ese nombre desde cualquier handler o paso +de workflow, sin enhebrar un builder a través de cada punto de llamada. + +> **Note** **Término clave — `RestClient`.** El `RestClient` es el cliente HTTP de +> `firefly-client`. Lleva propagación del correlation-id, un códec JSON, +> decodificación de errores RFC 9457 `application/problem+json` (una respuesta no-2xx +> se convierte en un error tipado) y reintento/backoff — el mismo cliente que cubrió +> el [capítulo de clientes HTTP](./13-http-clients.md). `register` te devuelve un +> `Arc` para uso inmediato. + +```rust,ignore +// Experience -> Domain only. Register Lumen as a downstream domain SDK. +bff.clients.register("wallets", "https://lumen.internal"); + +// Later, in a handler or workflow step, resolve it by its logical name: +let wallets = bff.clients.get("wallets").expect("wallets SDK"); +// `wallets` is an Arc with correlation-id propagation, a JSON codec, +// RFC 9457 problem decoding, and retry/backoff — all inherited from firefly-client. +``` + +Lo que acaba de ocurrir: `register(name, base_url)` construye un `RestClient` por +defecto para esa URL base, lo almacena bajo el nombre lógico y devuelve el +`Arc` que construyó. `get(name)` lo resuelve más tarde, devolviendo +`None` cuando no hay nada registrado bajo ese nombre. Como cada cliente registrado +apunta a un servicio de dominio, este registro es exactamente donde vive la regla +"experience → domain únicamente". + +Una vez que tienes un cliente, llamas a la API aguas abajo a través de `request`: + +```rust,ignore +use http::Method; +use serde_json::json; + +// Call Lumen's public API as any external client would. A non-2xx response +// decodes into a typed ClientError (RFC 9457 problem document). +let _: serde_json::Value = wallets + .request(Method::POST, "/wallets/w-1/reserve", Some(&json!({ "amount": 5000 }))) + .await?; +``` + +El registro tiene una superficie pequeña y predecible: + +- `register(name, base_url)` construye y almacena un cliente por defecto (gana la + última escritura si el nombre ya existe). +- `register_client(name, client)` almacena un `RestClient` preajustado — úsalo + cuando un SDK de dominio necesita un timeout personalizado, cabeceras por defecto + o una política de reintento. +- `get(name)` resuelve un cliente, o `None`. +- `names()` lista todos los SDKs registrados (ordenados), y `len()` / `is_empty()` + reportan el tamaño del registro. + +> **Design note.** Resolver un cliente por nombre lógico — `"wallets"` en lugar de +> una URL codificada a fuego — es lo que mantiene el código de trayecto del BFF +> desacoplado de dónde se ejecuta realmente Lumen. Apunta el nombre a +> `https://lumen.internal` en producción y a `http://localhost:8080` en un test, y +> ni una sola línea del workflow cambia. + +> **Tip** **Punto de control.** Tras `bff.clients.register("wallets", ...)`, +> `bff.clients.get("wallets")` devuelve `Some(_)`, `bff.clients.names()` es +> `["wallets"]` y `bff.clients.len()` es `1`. Resolver un nombre no registrado +> devuelve `None`, nunca un panic. + +## Paso 4 — Modelar el trayecto como un workflow regulado por señales + +Un trayecto de BFF rara vez es una sola petición. "Financia una cartera, espera a +que el cliente confirme el importe y luego confirma la transferencia" son tres +interacciones repartidas en el tiempo. Un **workflow** con una **puerta de señal** +modela exactamente eso: pasos que llaman a SDKs de dominio, y una puerta que aparca +hasta que un llamador externo entrega una señal con nombre. + +> **Note** **Término clave — workflow y nodo.** Un `Workflow` es un grafo dirigido +> de `Node`s, cada uno un paso asíncrono, ejecutados en orden de dependencias. Es el +> mismo motor de DAG-con-compensación del [capítulo de sagas](./12-sagas.md) +> (`firefly-orchestration`) — aquí dirigido por señales en lugar de ejecutado hasta +> completarse en una sola llamada. `Node::new(name, action)` define un paso; +> `.depends_on([...])` declara qué pasos deben terminar primero. + +El trayecto es un `Workflow` de `Node`s; `Node::wait_for_signal` construye el nodo +puerta, aparcando sobre el `SignalService` del stack hasta que llega la señal con +nombre: + +```rust,ignore +use std::sync::Arc; +use firefly_starter_experience::{Node, Workflow}; + +let signals = Arc::clone(&bff.signals); +let journey_id = "j-1".to_string(); + +let workflow = Workflow::new("fund-and-confirm") + // 1. reserve: call the Lumen "wallets" SDK to open/lock the funds. + .node(Node::new("reserve", || async { Ok(()) })) + // 2. await-confirm: park until POST /journeys/{id}/data delivers "confirmed". + .node( + Node::wait_for_signal("await-confirm", &signals, journey_id.clone(), "confirmed") + .depends_on(["reserve"]), + ) + // 3. commit: call the Lumen "wallets" SDK to run the transfer. + .node(Node::new("commit", || async { Ok(()) }).depends_on(["await-confirm"])); +``` + +Lo que acaba de ocurrir, nodo a nodo: + +- `Node::new("reserve", || async { Ok(()) })` es el primer paso. En un BFF real su + cuerpo resuelve `bff.clients.get("wallets")` y llama al endpoint de reserva de + Lumen; aquí el cuerpo es un stub que devuelve `Ok(())` para que la forma quede + clara. La acción de un nodo devuelve `Result<(), BoxError>`. +- `Node::wait_for_signal("await-confirm", &signals, journey_id.clone(), + "confirmed")` construye el nodo **puerta**. Toma el nombre del nodo, una + referencia al `Arc` del stack, el correlation id del trayecto y el + nombre de la señal que se espera. `.depends_on(["reserve"])` hace que se ejecute + después de `reserve`. +- `Node::new("commit", ...).depends_on(["await-confirm"])` es el paso final, que se + ejecuta solo una vez que la puerta se libera. + +`workflow.run().await` ejecuta los nodos en orden de dependencias y devuelve +`Result<(), WorkflowError>`. Cuando la ejecución alcanza `await-confirm` se +**aparca** — el future se suspende dentro del nodo puerta y no avanza. Normalmente +lanzas la ejecución en una task para que el handler HTTP que la inició pueda +retornar de inmediato: + +```rust,ignore +// Run the journey on a task; it parks on the `await-confirm` gate. +tokio::spawn(async move { + let _ = workflow.run().await; +}); +``` + +Más tarde, un endpoint atómico entrega la señal y el nodo aparcado se reanuda: + +```rust,ignore +// From a later request (POST /journeys/{id}/data): +bff.signals.deliver(&journey_id, "confirmed", serde_json::json!({ "ok": true })); +``` + +> **Note** **Término clave — entrega de señal (con buffer).** `signals.deliver(id, +> signal, payload)` despierta la puerta aparcada. La entrega tiene **buffer**: si la +> señal llega *antes* de que la puerta haya aparcado, el payload se retiene y el +> siguiente `wait_for_signal` para ese par se resuelve de inmediato — de modo que no +> hay despertar perdido en una condición de carrera. `deliver` devuelve `true` +> cuando un waiter vivo consumió la señal y `false` cuando se almacenó en buffer (no +> trates `false` como un error). `signals.list_active()` lista todos los trayectos +> actualmente aparcados sobre una puerta, o que retienen una señal con buffer para +> ella. + +> **Tip** **Punto de control.** Lanza `workflow.run()`, luego sondea +> `bff.signals.list_active()` — una vez que la ejecución alcanza la puerta, contiene +> `journey_id`. Llama a `bff.signals.deliver(&journey_id, "confirmed", payload)` y el +> workflow se completa; `list_active()` ya no lista el id. + +## Paso 5 — Persistir el trayecto con `WorkflowState` + +Un trayecto abarca varias peticiones, así que su estado debe sobrevivir a cualquier +petición individual. Si el cliente cierra la pestaña entre "reserve" y "confirm", un +waiter puramente en memoria se perdería. `WorkflowState` resuelve esto haciendo +round-trip de la instantánea de `StepContext` de una ejecución de workflow a través +del `Adapter` de caché del stack, indexada por correlation id. + +> **Note** **Término clave — `StepContext`.** Un `StepContext` es la bolsa de datos +> por ejecución que un workflow transporta: su correlation id, las entradas, el +> resultado de cada paso y variables de forma libre. Se serializa hacia y desde una +> instantánea JSON, que es lo que almacena `WorkflowState`. Vive en +> `firefly-orchestration`, así que lo importas desde ahí. + +> **Note** **Término clave — `Adapter` de caché.** El `Adapter` de caché es el +> backend clave/valor enchufable de Firefly. El adaptador en memoria es el valor por +> defecto; apúntalo al `RedisAdapter` de `firefly-cache-redis` para durabilidad +> entre reinicios — la convención en torno a la que se construye la capa de +> experiencia. `ExperienceStack` cablea `state` sobre el mismo adaptador que sostiene +> el `Core`, de modo que cambiar a Redis es un cambio de configuración, no un cambio +> de código. + +```rust,ignore +use firefly_orchestration::StepContext; + +// Save when a journey parks: +let ctx = StepContext::new(); +ctx.set_correlation_id("j-1"); +ctx.set_variable("phase", serde_json::json!("AWAITING_CONFIRM")); +bff.state.save(&ctx).await?; + +// Rehydrate from a later request to advance it: +if let Some(ctx) = bff.state.load("j-1").await? { + // ... advance the journey using ctx ... + let _ = ctx; +} + +// Discard when the journey completes: +bff.state.delete("j-1").await?; +``` + +Lo que acaba de ocurrir: + +- `StepContext::new()` crea un contexto vacío; `set_correlation_id` lo indexa (este + es el id de trayecto bajo el que `WorkflowState` almacena), y + `set_variable("phase", …)` registra dónde está el trayecto. Lo lees de vuelta con + `ctx.variable("phase")`. +- `bff.state.save(&ctx).await?` persiste la instantánea bajo el correlation id del + contexto, devolviendo `Result<(), CacheError>`. +- `bff.state.load("j-1").await?` devuelve `Result, + CacheError>`. Un **fallo sobre un trayecto desconocido es `Ok(None)`, no un + error** — de modo que una comprobación de estado sobre un trayecto que nunca + existió se representa como un 404 limpio, nunca un 500. +- `bff.state.delete("j-1").await?` desaloja el estado cuando el trayecto finaliza, + de modo que las ejecuciones completadas no se quedan rondando. + +> **Design note.** Esta es la costura que hace a un BFF resiliente a las +> desconexiones del cliente. Un trayecto aparcado guarda su `StepContext` antes de +> suspenderse; una petición posterior — posiblemente desde una sesión de navegador +> nueva, posiblemente después de que el BFF se reiniciara (con el adaptador Redis) — +> lo carga de vuelta y lo reanuda. El waiter en memoria por sí solo no podría +> sobrevivir a ese hueco; el estado persistido sí. + +> **Tip** **Punto de control.** Haz `save` de un `StepContext` que lleve una +> variable `phase`, luego haz `load` desde un handle nuevo: la variable sobrevive al +> round-trip. Haz `delete`, y `load` devuelve `Ok(None)`. + +## Paso 6 — Responder a los sondeos de estado con `WorkflowQueryService` + +Mientras el cliente espera en la pantalla de confirmación, el frontend sondea +"¿dónde está mi trayecto?". `WorkflowQueryService` sostiene el `StepContext` vivo +por ejecución y responde consultas *con nombre* contra él — el principal mecanismo +de recuperación cuando un cliente se reconecta. + +```rust,ignore +let journey_id = "j-1".to_string(); + +// On start: register the run's live context. +bff.query.register(&journey_id, ctx.clone()); + +// Register a named query that projects a value out of the context. +bff.query.register_query(&journey_id, "phase", |ctx| { + ctx.variable("phase").unwrap_or(serde_json::json!("UNKNOWN")) +}); + +// On GET /journeys/{id}: run the named query. +let phase = bff.query.query(&journey_id, "phase")?; + +// On completion: drop the run. +bff.query.unregister(&journey_id); +``` + +Lo que acaba de ocurrir: `register(id, ctx)` inscribe una ejecución por correlation +id con su `StepContext` vivo. `register_query(id, name, |ctx| value)` adjunta una +proyección con nombre — un closure que deriva un valor JSON del contexto (aquí, la +variable `phase`). `query(id, name)` ejecuta esa proyección y devuelve +`Result` — una ejecución desconocida o un nombre de +consulta desconocido es un error tipado, que el controlador mapea a un 404. +`unregister(id)` elimina la ejecución cuando el trayecto termina; `active()` lista +todas las ejecuciones registradas. + +> **Design note.** Dos superficies responden "¿dónde está mi trayecto?" y se +> complementan. `WorkflowState` (Paso 5) es el registro *duradero* — sobrevive a un +> reinicio y respalda la decisión de 404-o-fase. `WorkflowQueryService` es la +> proyección *viva* sobre la ejecución en proceso — más rica, más barata de +> consultar y el lugar natural para derivar un DTO de "siguiente paso" mientras el +> proceso está en marcha. Un endpoint de estado de producción lee la consulta viva +> cuando la ejecución está en memoria y recurre al estado persistido en caso +> contrario. + +> **Tip** **Punto de control.** Haz `register` de una ejecución, `register_query` de +> una proyección `"phase"`, y `query(id, "phase")` devuelve el valor de la fase. +> Consultar un id no registrado o un nombre de consulta desconocido devuelve un +> `Err`, no un panic. + +## Paso 7 — Ensamblar el controlador de endpoints atómicos + +Junta las piezas y un controlador de experiencia expone una petición HTTP por cada +fase del trayecto — la forma **REST atómico**. + +> **Note** **Término clave — endpoint atómico.** Un *endpoint atómico* ejecuta +> exactamente una fase de un trayecto y retorna. El estado vive en la caché +> (compatible con Redis) entre llamadas, de modo que el cliente conduce el trayecto +> una petición cada vez y puede reanudar tras una desconexión — en lugar de mantener +> una única conexión de larga duración abierta a lo largo de todo el flujo. + +| Método y ruta | Hace | +|---------------|------| +| `POST /journeys` | inicia el workflow (llama al SDK `"wallets"` para reservar), persiste `WorkflowState`, aparca en la puerta, devuelve el id del trayecto | +| `POST /journeys/:id/data` | entrega la señal `confirmed` — el workflow aparcado se reanuda y confirma la transferencia vía el SDK `"wallets"` | +| `GET /journeys/:id` | reporta la fase persistida (o 404 si el trayecto es desconocido) | + +Construyes estas como rutas `axum` ordinarias, y luego pasas el router por el +middleware heredado del BFF para que cada respuesta lleve las baterías web: + +```rust,ignore +use axum::routing::{get, post}; +use axum::Router; + +// `routes` is an axum Router with the three handlers and the BFF as state. +let routes = Router::new() + .route("/journeys", post(start_journey)) + .route("/journeys/:id/data", post(deliver_journey_signal)) + .route("/journeys/:id", get(journey_status)) + .with_state(app_state); + +// Inherit the web batteries: CORS, security headers, correlation, metrics, … +let api = bff.apply_middleware(routes); +``` + +Lo que acaba de ocurrir: cada fase es una petición HTTP, y el estado del workflow +vive en la caché entre ellas, de modo que un cliente puede reanudar el trayecto tras +una desconexión. Como el router pasa por `bff.apply_middleware(routes)`, cada +respuesta hereda las baterías web — la respuesta de inicio lleva +`X-Frame-Options: DENY` y un `X-Correlation-Id` igual que las propias respuestas de +Lumen — y la misma cadena de filtros `ExperienceStack::with_security(chain)` del +[capítulo de seguridad](./14-security.md) protege las rutas mutadoras. + +Este es exactamente el contrato que el propio test de arranque del crate demuestra +de extremo a extremo. (Su trayecto de checkout usa una fase `AWAITING_PAYMENT` en +lugar del `AWAITING_CONFIRM` de Lumen, pero la forma es idéntica.) Dos SDKs de +dominio simulados (mock) se componen a través de un workflow regulado por señales y +se conducen con `tower::oneshot`: iniciar el trayecto reserva a través del primer +SDK y aparca en la puerta; el endpoint de estado reporta la fase persistida; +entregar la señal hace avanzar el workflow fuera de la task y lo envía a través del +segundo SDK; y el estado final pasa a `COMPLETED`. + +> **Tip** **Punto de control.** Conducir el router con tres llamadas — `POST +> /journeys`, luego `GET /journeys/:id` (reporta `AWAITING_CONFIRM`), luego `POST +> /journeys/:id/data`, luego `GET /journeys/:id` de nuevo (reporta `COMPLETED`) — +> recorre el trayecto completo. La primera respuesta lleva la cabecera heredada +> `X-Frame-Options: DENY`. + +## Paso 8 — Ver dónde encaja Lumen + +Dibujado como la instalación completa, Lumen es uno de los servicios de dominio que +compone un BFF. La capa de canal (una app web o móvil) llama a la capa de +experiencia (`lumen-bff`), que llama a la capa de dominio (`lumen`), que llama a la +capa core (`accounts`, que posee la base de datos). Cada flecha apunta +estrictamente hacia abajo, y la capa de experiencia solo alcanza siempre a la capa +de dominio: + +```text + web / mobile app (channel tier) + │ + ▼ Experience → Domain only + lumen-bff (experience: DomainClients + signals + state) + │ + ▼ + lumen (domain: ledger, CQRS, transfer saga) + │ + ▼ + accounts (core: owns the database) +``` + +Lo que acaba de ocurrir: has situado tu BFF en la instalación. El BFF nunca accede +al event store de Lumen ni a su bus CQRS — habla con la API HTTP pública de Lumen a +través del SDK `"wallets"` registrado, exactamente igual que lo haría cualquier +cliente externo, y añade únicamente la orquestación de trayecto que necesita un +frontend. Lumen, el servicio de dominio, posee su propia lógica; el servicio core +por debajo de él posee la base de datos. + +## Resumen — qué construyó este capítulo + +No cambiaste Lumen — es un servicio de *dominio*, y este capítulo trata sobre la +capa *superior* a él. Lo que construiste es el modelo mental y el cableado para un +BFF de cara al frontend: + +- El modelo de capas `channel → experience → domain → core`, y por qué la capa de + experiencia compone SDKs de *dominio* únicamente — nunca una base de datos, un + servicio core ni un BFF hermano. +- Un `ExperienceStack` (`Bff`) que hereda las baterías web de Lumen y añade cinco + bloques de construcción: `clients`, `signals`, `state`, `query` y `children`. +- `DomainClients` registrando Lumen como el SDK `"wallets"` y resolviéndolo por + nombre lógico, sobre un `RestClient` con propagación de correlación y + decodificación de problemas RFC 9457. +- Un `Workflow` regulado por señales cuya puerta `Node::wait_for_signal` aparca el + trayecto "financiar y confirmar" hasta que llega `signals.deliver(...)` — con + entrega con buffer para que no haya despertar perdido. +- `WorkflowState` persistiendo ese trayecto a través de un adaptador de caché + compatible con Redis (un fallo es `Ok(None)`, no un error), de modo que un cliente + pueda reanudar tras una desconexión. +- `WorkflowQueryService` respondiendo a los sondeos de estado "¿dónde está mi + trayecto?" desde el `StepContext` vivo. +- El controlador REST atómico de tres endpoints, pasado por + `bff.apply_middleware(...)` para que cada respuesta herede las baterías web. + +Cada API aquí procede de la superficie real de `firefly-starter-experience` — la +misma que su test de arranque ejercita de extremo a extremo. + +## Ejercicios + +1. **Registra Lumen como un SDK de dominio.** Construye un `ExperienceStack`, llama + a `bff.clients.register("wallets", "http://localhost:8080")` y confirma que + `bff.clients.get("wallets")` devuelve `Some(_)` y que `bff.clients.names()` lista + `"wallets"`. Luego vuelve a registrar el mismo nombre con una URL diferente y + confirma que `bff.clients.len()` se mantiene en `1` (gana la última escritura). +2. **Aparcar y reanudar.** Construye un `Workflow` de dos nodos cuyo segundo nodo + sea una puerta `Node::wait_for_signal`. Lanza `workflow.run()` en una task, + sondea hasta que `bff.signals.list_active()` contenga el id del trayecto, luego + `bff.signals.deliver(&id, "confirmed", json!({}))` y confirma que el workflow se + completa y que `list_active()` ya no lista el id. +3. **Carrera con la puerta.** Repite el ejercicio 2 pero llama a `deliver` *antes* + de lanzar la ejecución. Confirma que el workflow se completa igualmente — la + entrega con buffer significa que la señal no se pierde cuando se adelanta a la + puerta. +4. **Persistir un trayecto.** Haz `save` de un `StepContext` que lleve una variable + `phase` vía `bff.state.save`, hazle `load` de vuelta desde un handle nuevo y + confirma que la variable sobrevive. Luego hazle `delete` y confirma que + `bff.state.load(...)` devuelve `Ok(None)`. +5. **Endpoints atómicos.** Cablea el controlador de tres rutas del Paso 7 con + `bff.apply_middleware(routes)` y condúcelo con `tower::oneshot`: inicia, sondea el + estado (`AWAITING_CONFIRM`), entrega la señal, sondea de nuevo (`COMPLETED`). + Asegura que la respuesta de inicio lleva la cabecera heredada + `X-Frame-Options: DENY` y un `X-Correlation-Id`. + +## Adónde ir después + +- Repasa cómo se lleva a producción un servicio de dominio como Lumen — Postgres + real, Kafka y la superficie de gestión — en + **[Producción y despliegue](./20-production.md)**. +- Mira cómo el motor de workflows sobre el que cabalga el trayecto del BFF también + impulsa las transferencias compensatorias propias de Lumen en + **[Sagas, workflows y TCC](./12-sagas.md)**. +- Con el lugar de Lumen en la instalación por capas ya claro, el capítulo final + vuelve a leer todo el servicio a través de la lente de las macros declarativas. + Continúa en **[Servicios declarativos con macros](./21-declarative-macros.md)**. diff --git a/docs/book/src-es/21-declarative-macros.md b/docs/book/src-es/21-declarative-macros.md new file mode 100644 index 00000000..01a64627 --- /dev/null +++ b/docs/book/src-es/21-declarative-macros.md @@ -0,0 +1,1400 @@ +# Servicios declarativos con macros + +Lumen está terminado. A lo largo de más de veinte capítulos creció desde un +andamiaje vacío hasta un servicio CQRS con event sourcing, seguro y observable, +con una saga de transferencia, un flujo de cumplimiento, una transferencia en dos +fases, un latido programado y un endpoint de streaming opcional — y depende de +exactamente **un** crate de Firefly. Este capítulo final vuelve a leer todo el +servicio a través de una única lente: las **macros declarativas**. Al terminar +serás capaz de señalar cada `#[derive(...)]` y `#[...]` de `samples/lumen` y +decir con precisión en qué cableado se colapsó al convertirse en una declaración +junto al código. Esa es la tesis que el crate en ejecución demuestra: *una fachada +más macros equivale al framework, sin el código repetitivo.* + +Este capítulo no introduce una funcionalidad nueva. Es un recorrido guiado por la +capa declarativa que has estado usando todo el tiempo, ralentizado para que cada +macro se explique desde primeros principios antes de leerla en contexto. Donde una +macro se ejercita en `samples/lumen` leemos el código de Lumen tal cual; donde es +una parte de primera clase del framework que Lumen simplemente no usa, leemos un +ejemplo independiente y enfocado, y lo indicamos. + +Al terminar este capítulo, serás capaz de: + +- Explicar qué es una *macro declarativa* en Firefly, y por qué el cableado + generado lo comprueba el compilador en lugar de descubrirlo mediante reflexión + en tiempo de ejecución. +- Rastrear cada macro que Lumen usa — `#[derive(Command/Query)]`, `#[handlers]`, + `#[derive(DomainEvent/AggregateRoot)]`, `#[event_listener]`, + `#[rest_controller]`, `#[scheduled]`, `#[firefly::saga/workflow/tcc]` — hasta el + `impl`, router o registro exacto que emite. +- Nombrar el conjunto declarativo de apoyo que Lumen no usa — + `#[derive(Builder)]`, `#[derive(Mapper)]`, `#[derive(Entity)]` / + `#[derive(SqlxRepository)]` / `#[firefly::repository]` / + `#[firefly::transactional]`, los decoradores de seguridad de método y de + resiliencia, `#[cacheable]` y el resto — y leer un ejemplo correcto de cada uno. +- Describir la ruta oculta del contrato `__rt` que permite a un servicio de un + solo crate compilar todo lo que una macro expande. +- Explicar el *drenaje* de `inventory`: cómo un bean, listener, tarea o + controlador *declarado* pasa a estar *cableado* en el arranque sin ninguna + llamada de registro escrita a mano. +- Verificar que todo el crate compila, pasa los tests y supera el linter de forma + limpia desde la raíz del workspace. + +## Conceptos que conocerás + +Antes del catálogo, aquí tienes las cuatro ideas en las que se apoya este +capítulo. Cada una se reintroduce en contexto donde se usa por primera vez; esta +es la versión breve. + +> **Note** **Término clave — macro declarativa.** Una *macro declarativa* es un +> atributo (`#[...]`) o un derive (`#[derive(...)]`) que un `proc-macro` expande +> en **tiempo de compilación** a los `impl`, routers y funciones auxiliares que de +> otro modo escribirías a mano. La declaración se sitúa junto al código que +> describe; el compilador comprueba el código generado como cualquier otro +> fuente. El equivalente en Spring es una anotación (`@RestController`, +> `@Component`) — salvo que Spring descubre y procesa las anotaciones por +> reflexión al arrancar, mientras que Firefly las resuelve en tiempo de +> compilación. + +> **Note** **Término clave — fachada y preludio.** La *fachada* es el único crate +> `firefly` que reexporta todo el framework y cada macro; el *preludio* es +> `firefly::prelude`, un módulo con los tipos de alta frecuencia que importas en +> bloque con `use firefly::prelude::*;`. Depender de una fachada e importar un +> preludio es toda la historia de "una dependencia, una importación". El +> equivalente en Spring es un único starter de Spring Boot más los tipos del +> framework autoimportados. + +> **Note** **Término clave — bean.** Un *bean* es un objeto que el framework +> construye, gestiona y entrega a quien declare que lo necesita (con +> `#[autowired]`). Tú declaras los beans; el framework los descubre al arrancar y +> los conecta entre sí. Esto es exactamente la noción de Spring de un bean +> gestionado por el contexto de aplicación. + +> **Note** **Término clave — registro de inventory.** `inventory` es un crate de +> Rust que permite a una macro registrar un valor en una tabla global del proceso +> **en tiempo de enlazado** — antes de que se ejecute `main`. Cada macro +> declarativa que produce un handler, listener, tarea o controlador envía un +> *registro* a una de estas tablas; `FireflyApplication` **drena** las tablas en +> el arranque e instala cada entrada. El efecto refleja el escaneo de componentes +> del classpath de Spring, pero el inventario lo construye el enlazador, no el +> recorrido del classpath en tiempo de ejecución. + +## Paso 1 — Una dependencia, un preludio + +Cada capítulo empezó del mismo modo, así que empieza ahí. Abre el `Cargo.toml` de +Lumen. Lista exactamente un crate de Firefly; todo lo declarativo llega a través +de él. + +```toml +# samples/lumen/Cargo.toml +[dependencies] +# The one-dependency story: the `firefly` facade re-exports the whole framework +# AND every `#[derive(...)]` / `#[...]` macro. Generated code resolves runtime +# types through the facade, so Lumen never lists the underlying `firefly-*` +# crates. The `admin` feature pulls in the self-hosted admin dashboard. +firefly = { version = "26.6.28", features = ["admin"] } + +# The two ecosystem crates a Firefly service still writes against directly: +# axum (you author the controller handlers) and serde (your messages and event +# payloads are Serialize/Deserialize). `serde_json` encodes the event payloads. +axum = "0.7" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Async runtime for `#[tokio::main]`, and the id/clock crates the domain uses. +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] } +uuid = { version = "1", features = ["v4"] } +chrono = "0.4" +async-trait = "0.1" + +[features] +# The reactive streaming endpoint is feature-gated so the teaching baseline +# stays lean; the production chapter turns it on. It needs nothing beyond the +# `firefly` facade. +default = [] +streaming = [] +``` + +Y cada módulo se abre con una única importación en bloque: + +```rust,ignore +use firefly::prelude::*; +``` + +Lo que acaba de pasar: ese glob trae al ámbito toda la superficie de alta +frecuencia — `Bus`, `Container`, `Scheduler`, `Saga` / `Step`, `Application` / +`ShutdownHandle`, `Core` / `CoreConfig`, `WebResult` / `WebError`, `FireflyError` +/ `FireflyResult`, `Mono` / `Flux` — **y** cada macro. Para los tipos que nombras +explícitamente hay alias por crate (`firefly::cqrs::Bus`, +`firefly::eventsourcing::EventStore`, `firefly::security::JwtService`, …), por lo +que varios módulos de Lumen también escriben `use firefly::cqrs::QueryCache;` o +`use firefly::eda::{Broker, Event};` junto al glob del preludio. `axum` y `serde` +son los únicos dos crates del ecosistema contra los que Lumen escribe +directamente. + +> **Note** Un crate `proc-macro` no puede reexportar por sí mismo tipos de tiempo +> de ejecución, así que el código generado por la macro referencia cada tipo de +> tiempo de ejecución a través de la ruta oculta del contrato `__rt` de la fachada +> — por ejemplo `::firefly::__rt::firefly_cqrs::Bus`. Por eso Lumen, dependiendo +> solo de `firefly`, compila todo lo que una macro expanda sin listar nunca los +> crates `firefly-*` subyacentes. Nunca escribes `__rt` tú mismo; si renombras o +> intercalas un shim sobre la fachada, pasa `#[firefly(crate = "my_firefly")]` a +> cualquier macro para sobrescribir el segmento inicial. Volvemos a este contrato +> en el [Paso 11](#step-11--how-the-wiring-actually-lands-the-__rt-contract-and-the-inventory-drain). + +### Mantenerse ligero + +La compilación por defecto de `firefly` incorpora solo los crates de *port* +ligeros del framework — sin drivers de terceros pesados. Lumen no necesita +ninguno, así que su compilación es mínima. Los adaptadores pesados son features +opt-in de cargo (la ruta de intercambio a la que apuntó cada capítulo): + +| Feature | Incorpora | +|---------|-----------| +| `data-sqlx` | adaptador de repositorio relacional (Postgres / MySQL / SQLite) | +| `data-mongodb` | adaptador de repositorio documental (MongoDB) | +| `eda-kafka` / `eda-rabbitmq` / `eda-redis` / `eda-postgres` | transportes de broker de eventos | +| `cache-redis` / `cache-postgres` | backends de caché | +| `admin` | el panel de administración autoalojado | +| `full` | todo lo anterior | + +> **Tip** **Punto de control.** Abre `samples/lumen/Cargo.toml` y confirma que hay +> exactamente una línea `firefly = { … }` bajo `[dependencies]`, y que cada +> archivo fuente bajo `samples/lumen/src/` se abre con `use firefly::prelude::*;`. +> Esa única dependencia y esa única importación son toda la premisa que este +> capítulo desgrana. + +## Paso 2 — El catálogo de macros, mapeado a los archivos de Lumen + +Lumen ejercita el conjunto declarativo central. Antes de leer cualquier macro en +profundidad, aquí está el mapa: cada macro, el archivo exacto de Lumen en el que +aterriza y lo que genera. + +| Macro | Archivo de Lumen | Genera | +|-------|-----------|-----------| +| `#[derive(Command)]` / `#[derive(Query)]` | `commands.rs` | el `impl` de `Message` (`#[firefly(validate)]`, `#[firefly(cache_ttl = "…")]`) | +| `#[derive(Schema)]` | `commands.rs`, `domain.rs`, `web.rs`, … | un esquema OpenAPI para el tipo, de modo que aparece en `/v3/api-docs` | +| `#[handlers]` (sobre un `impl` de bean-handler) | `commands.rs`, `ledger.rs` | registra en el bus / broker cada método `#[command_handler]` / `#[query_handler]` / `#[event_listener]` de un bean de DI | +| `#[command_handler]` / `#[query_handler]` (marcadores de método) | `commands.rs` | marcan un método handler de CQRS dentro de un `impl` con `#[handlers]` | +| `#[derive(DomainEvent)]` | `domain.rs` | discriminador `EVENT_TYPE` + conversión `to_domain_event` | +| `#[derive(AggregateRoot)]` | `domain.rs` | `AGGREGATE_TYPE` + `aggregate()` / `aggregate_mut()` | +| `#[derive(Service)]` / `#[derive(Repository)]` | `commands.rs`, `ledger.rs` | un bean `@Component` / `@Repository` escaneado con campos `#[autowired]` | +| `#[event_listener(topic = "…")]` (marcador de método) | `ledger.rs` | marca un método listener de EDA dentro de un `impl` con `#[handlers]` (el bean de proyección) | +| `#[derive(Configuration)]` + `#[bean]` | `web.rs` | un contenedor `@Configuration` cuyas factorías `#[bean]` declaran beans de infraestructura | +| `#[derive(Controller)]` + `#[rest_controller]` + `#[get/post]` | `web.rs` | un bean controlador autowired y su `WalletApi::routes(state) -> axum::Router` | +| `#[scheduled(fixed_rate = "…")]` | `housekeeping.rs` | un auxiliar `schedule_(scheduler)` más un registro drenado | +| `#[firefly::saga]` + `#[saga_step]` | `transfer.rs` | `TransferSaga::run` / `::saga()` — un grafo de pasos con compensación | +| `#[firefly::workflow]` + `#[workflow_step]` | `compliance.rs` | un `run` de workflow sobre el DAG de pasos | +| `#[firefly::tcc]` + `#[participant]` | `tcc_transfer.rs` | un `run` de TCC que conduce el try / confirm / cancel de cada participante | + +
+ +you write +the macro generates +#[derive(Command)] + +the Message impl (kind, validate, cache_ttl) +#[derive(Schema)] + +an OpenAPI schema (appears in /v3/api-docs) +#[derive(DomainEvent)] + +EVENT_TYPE + to_domain_event +#[rest_controller] + +a Controller bean + WalletApi::routes(state) +#[derive(Service)] + +a scanned bean with #[autowired] fields + +
Declarativo, en tiempo de compilación. Cada atributo o derive se expande al cableado que de otro modo escribirías a mano: un impl de Message, un esquema OpenAPI, un discriminador de evento, un constructor routes() de controlador o un bean escaneado con campos autowired — generado por firefly-macros, no por un paso de codegen que ejecutes.
+
+ +Los siguientes pasos leen cada uno de estos en su archivo de Lumen, en el orden en +que el propio crate está estratificado. Después de eso, el +[Paso 10](#step-10--the-rest-of-the-declarative-set-not-used-by-lumen) cataloga las +macros que Lumen *no* ejercita — porque usa event sourcing y gestiona sus +preocupaciones transversales por otros medios — cada una con un ejemplo +independiente y correcto. + +> **Tip** **Punto de control.** Mantén esta tabla abierta en un segundo panel. A +> medida que leas cada paso, encuentra la fila que le corresponde y confirma que la +> columna "Genera" coincide con la explicación. La tabla es el esqueleto; los pasos +> son el músculo. + +## Paso 3 — Los mensajes de CQRS y su bean handler (`commands.rs`) + +> **Note** **Término clave — CQRS.** *Command/Query Responsibility Segregation* es +> un patrón que enruta los **comandos** que cambian el estado y las **consultas** +> de solo lectura a través de handlers separados en un *bus* compartido. Un comando +> muta; una consulta lee; nunca comparten un handler. El equivalente en Spring es +> un gateway de comandos/consultas sobre métodos anotados con `@CommandHandler` / +> `@QueryHandler`. + +`#[derive(Command)]` y `#[derive(Query)]` generan el `impl` de `Message` que +permite al bus enrutar una struct. `#[firefly(validate)]` sobre un campo hace que +un valor vacío o cero falle la validación *antes* de que el handler se ejecute; +`#[firefly(cache_ttl = "…")]` sobre una consulta alimenta la caché de lectura. +Aquí está la declaración de Lumen tal cual, incluyendo el `#[derive(Builder)]` y el +`#[derive(Schema)]` que también lleva: + +```rust,ignore +// samples/lumen/src/commands.rs +#[derive(Debug, Clone, Default, Serialize, Deserialize, Command, Builder, Schema)] +#[serde(default)] +pub struct OpenWallet { + #[firefly(validate)] + #[builder(into)] // accept &str, String, … + pub owner: String, + #[serde(rename = "openingBalance")] + #[builder(default)] // unset → 0 + pub opening_balance: i64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, Query)] +#[firefly(cache_ttl = "30s")] +pub struct GetWallet { + pub id: String, +} +``` + +Lo que acaba de pasar, derive a derive: + +- `Command` / `Query` emiten el `impl` de `firefly::cqrs::Message` — el trait sobre + el que despacha el bus. `#[firefly(validate)]` registra `owner` como un campo + obligatorio, así que `OpenWallet::default().validate()` es un `Err`. + `#[firefly(cache_ttl = "30s")]` lo lee la caché de consultas a través del + `Message::cache_ttl` generado. +- `Schema` emite un esquema OpenAPI para el tipo, de modo que `OpenWallet` aparece + en la especificación servida en `/v3/api-docs` en el puerto de gestión — sin + esquema escrito a mano. +- `Builder` (el equivalente de `@Builder` de Lombok) se trata en el + [Paso 10](#construction--the-fluent-builder-derivebuilder); ignóralo por ahora. + +> **Note** **Término clave — bean handler.** Un *bean handler* es un componente de +> DI cuyos métodos son los handlers de comandos/consultas. Sus colaboradores se +> obtienen mediante `#[autowired]` desde el contenedor, así que cada handler los +> alcanza a través de `self` — no hay variable global de proceso ni raíz de +> composición. El equivalente en Spring es un `@Component` cuyos métodos +> `@CommandHandler` / `@QueryHandler` se escanean y registran. + +En Lumen los handlers viven en uno de esos beans — `WalletHandlers`, un +`#[derive(Service)]` cuyo `Ledger` del lado de escritura y `ReadModel` del lado de +lectura son `#[autowired]` — y `#[handlers]` registra cada método en el bus. Esto +es Lumen tal cual: + +```rust,ignore +// samples/lumen/src/commands.rs +#[derive(Service)] +struct WalletHandlers { + #[autowired] + ledger: Arc, + #[autowired] + read_model: Arc, +} + +#[handlers] +impl WalletHandlers { + #[command_handler] + async fn open_wallet(&self, cmd: OpenWallet) -> Result { + if cmd.opening_balance < 0 { + return Err(CqrsError::validation("openingBalance must be >= 0")); + } + self.ledger + .open(&cmd.owner, Money::cents(cmd.opening_balance)) + .await + .map_err(to_cqrs) + } + + #[query_handler] + async fn get_wallet(&self, q: GetWallet) -> Result { + if let Some(view) = self.read_model.find(&q.id) { + return Ok(view); + } + let events = self.ledger.load_events(&q.id).await.map_err(to_cqrs)?; + Ok(Wallet::rehydrate(&q.id, &events).view()) + } + // … deposit / withdraw +} +``` + +Lo que acaba de pasar: `#[handlers]` es un atributo a **nivel de impl** (como +`#[rest_controller]`) aplicado al bloque `impl` de un bean registrado. Cada método +marcado con `#[command_handler]` / `#[query_handler]` recibe `&self` más un +argumento de mensaje y devuelve `Result`. Por cada marcador la macro +envía un `BeanHandlerRegistration` a un registro de inventory de tiempo de +compilación que, en el arranque, resuelve el bean desde el contenedor e instala un +cierre que lo captura. `FireflyApplication` drena esos registros durante +`register_discovered_handlers`. Así Lumen instala los cuatro handlers *declarando* +el bean y sus métodos — no hay ninguna llamada `register(&bus)` escrita a mano ni +ningún cableado de estado publicado con `OnceLock`. + +Por qué importa: el handler alcanza `self.ledger` y `self.read_model` a través de +la inyección del contenedor, así que el mismo handler que conduce un test HTTP es +el mismo handler al que despacha el bus en vivo — un cableado, ejercitado de dos +formas. + +> **Note** `#[command_handler]` / `#[query_handler]` también funcionan sobre una +> `async fn(Msg) -> Result` **libre**, en cuyo caso la macro genera +> un auxiliar `register_(bus)` para un handler simple y sin colaboradores — la +> forma que usa el sample `macro-quickstart`. `#[handlers]` es la forma de **bean** +> para un handler que conecta colaboradores por autowiring, que es el cableado real +> de Lumen. Mismos marcadores, dos formas; Lumen usa la forma de bean. + +> **Tip** **Punto de control.** Busca `get_wallet_carries_cache_ttl` en +> `samples/lumen/src/commands.rs`. Afirma que `GetWallet::default().cache_ttl()` +> es `Some(_)` — prueba directa de que `#[firefly(cache_ttl = "30s")]` llegó al +> `Message::cache_ttl` generado. Ejecuta `cargo test -p firefly-sample-lumen +> get_wallet_carries_cache_ttl` y míralo pasar. + +## Paso 4 — Los eventos de dominio y el agregado (`domain.rs`) + +> **Note** **Término clave — event sourcing.** En *event sourcing* el estado de un +> agregado no se almacena como una fila; es el plegado de un flujo ordenado de +> **eventos de dominio** inmutables. Para cargar una wallet reproduces sus eventos; +> para cambiarla añades un evento nuevo. Cada evento necesita una identidad estable +> para que un flujo persistido siga siendo legible a medida que el esquema +> evoluciona. El equivalente en Spring es un agregado `@EventSourcingHandler` de +> Axon. + +`#[derive(DomainEvent)]` estampa cada struct de payload con un discriminador +`EVENT_TYPE` estable (el nombre de su struct) y una conversión `to_domain_event` al +evento de cable del framework. `#[derive(AggregateRoot)]` encuentra el campo +`AggregateRoot` incrustado y genera `Wallet::AGGREGATE_TYPE` más los accesores +`aggregate()` / `aggregate_mut()`: + +```rust,ignore +// samples/lumen/src/domain.rs +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DomainEvent)] +pub struct WalletOpened { + pub wallet_id: String, + pub owner: String, + pub opening_balance: i64, +} + +#[derive(Debug, Clone, AggregateRoot)] +#[firefly(aggregate_type = "Wallet")] +pub struct Wallet { + pub root: AggregateRoot, // the framework root — uncommitted-event buffer + version + pub owner: String, + pub balance: Money, + pub opened: bool, +} +``` + +Lo que acaba de pasar: el único cableado de event sourcing que Lumen escribe a mano +es el plegado `apply` que proyecta un evento en el estado en memoria. Los +discriminadores (`WalletOpened::EVENT_TYPE`, usado cuando el agregado emite (`raise`) +un evento) y la conversión de cable se generan. El argumento +`#[firefly(aggregate_type = "Wallet")]` fija la cadena de tipo del agregado, que la +const generada `Wallet::AGGREGATE_TYPE` expone y que el event store estampa en cada +evento persistido. + +Por qué importa: el discriminador da a cada evento una identidad JSON estable y +versionada, así que un flujo persistido hoy sigue siendo decodificable después de +que la struct de payload crezca con campos nuevos mañana — la propiedad de la que +depende el event sourcing. + +> **Tip** **Punto de control.** En `domain.rs`, `rehydrate_folds_the_full_stream` +> afirma que `Wallet::AGGREGATE_TYPE == "Wallet"` y pliega un flujo de +> apertura + depósito + retirada de vuelta al balance y la versión correctos. Ese +> único test ejercita ambos derives a la vez. + +## Paso 5 — El listener de proyección (`ledger.rs`) + +> **Note** **Término clave — proyección.** Una *proyección* es un constructor de +> modelo de lectura: consume eventos de dominio publicados y escribe una vista +> optimizada para consultas. Como reconstruye la vista a partir del flujo de +> eventos (en lugar de mutar una fila a partir de una única entrega), es +> **idempotente** — una reentrega at-least-once converge en la misma vista. El +> equivalente en Spring es un `@Component @EventListener` que actualiza una tabla +> de lectura. + +Primero, el propio modelo de lectura es un bean. El `ReadModel` de Lumen es un +componente de acceso a datos `#[derive(Repository)]` (el `@Repository` de Spring) — +un mapa en memoria de id de wallet a `WalletView`, mantenido sin dependencias para +la base de enseñanza: + +```rust,ignore +// samples/lumen/src/ledger.rs +#[derive(Debug, Default, Repository)] +pub struct ReadModel { + rows: Mutex>, +} +``` + +`container.scan()` lo registra como un bean singleton, de modo que puede conectarse +por autowiring (como `Arc`) en los beans handler y de proyección. Un +servicio en producción respaldaría esto con el repositorio reactivo de `firefly` +sobre Postgres; el mapa en memoria mantiene la base sin infraestructura. + +La proyección es un **bean listener de EDA** — `WalletProjection`, un +`#[derive(Service)]` que conecta por `#[autowired]` el `Ledger` (para el event +store que reproduce) y el `ReadModel` que alimenta. Dentro de un `impl` con +`#[handlers]`, un método `#[event_listener(topic = "…")]` marca la proyección — +exactamente como el bean de CQRS de arriba, pero el marcador suscribe el método a +un topic de EDA en lugar de al bus: + +```rust,ignore +// samples/lumen/src/ledger.rs +#[derive(Service)] +struct WalletProjection { + #[autowired] + ledger: Arc, + #[autowired] + read_model: Arc, +} + +#[handlers] +impl WalletProjection { + #[event_listener(topic = "wallets.events")] + async fn project(&self, ev: Event) -> FireflyResult<()> { + let Some(wallet_id) = ev.headers.get("aggregateId") else { + return Ok(()); + }; + // reload the wallet's stream, fold to a WalletView, upsert — idempotent. + if let Ok(events) = self.ledger.store().load(wallet_id).await { + let view = Wallet::rehydrate(wallet_id, &events).view(); + self.read_model.upsert(view); + } + Ok(()) + } +} +``` + +Lo que acaba de pasar: `#[handlers]` envía un `BeanListenerRegistration` a inventory +que, en el arranque, resuelve el bean desde el contenedor y suscribe su método a +`wallets.events` en el mismo broker al que publica el ledger. `FireflyApplication` +lo drena durante `subscribe_discovered_listeners`. La suscripción que cierra el +bucle de CQRS — el lado de escritura añade y publica, el lado de lectura proyecta — +queda por tanto cableada enteramente a través del contenedor de DI, sin ninguna +llamada `subscribe(&broker)` en ninguna raíz de composición. + +> **Note** Como los marcadores de CQRS, `#[event_listener(topic = "…")]` también +> funciona sobre una `async fn(Event) -> FireflyResult<()>` **libre**, generando un +> auxiliar `subscribe_(broker)` para un listener simple y sin colaboradores. +> `#[handlers]` es la forma de **bean** para una proyección que conecta +> colaboradores por autowiring — el cableado real de Lumen. + +> **Tip** **Punto de control.** El test HTTP `open_then_get_round_trips_through_cqrs` +> (en `http_test.rs`) abre una wallet por `POST /api/v1/wallets`, luego la lee por +> `GET /api/v1/wallets/:id` y ve el balance proyectado — prueba de que el bean +> listener se suscribió y el bucle se cerró. Arranca el `FireflyApplication` +> completo, así que ejercita el drenaje del inventory de extremo a extremo. + +## Paso 6 — El controlador (`web.rs`) + +> **Note** **Término clave — controlador REST.** Un *controlador REST* es un bean +> cuyos métodos mapean verbos y rutas HTTP a funciones handler. En Firefly los +> cuerpos de los handlers son handlers de `axum` ordinarios; la macro genera el +> router y lo monta. El equivalente en Spring es `@RestController` con +> `@GetMapping` / `@PostMapping`. + +`#[rest_controller(path = "…")]` convierte un bloque `impl` en un +`WalletApi::routes(state) -> axum::Router` generado. El propio tipo del controlador +es un bean `#[derive(Controller)]` cuyos colaboradores son `#[autowired]`: + +```rust,ignore +// samples/lumen/src/web.rs +#[derive(Clone, Controller)] +pub struct WalletApi { + #[autowired] + pub bus: Arc, + #[autowired] + pub ledger: Arc, + #[autowired] + pub query_cache: Arc, +} +``` + +Cada método lleva un mapeo de un verbo, usa extractores ordinarios de axum y +devuelve `WebResult` para que un error del handler se renderice como +`application/problem+json` según RFC 9457. Los atributos de verbo también llevan +los metadatos OpenAPI (`summary`, `description`, `status`, `tags`) que lee el +generador de documentación: + +```rust,ignore +// samples/lumen/src/web.rs +#[rest_controller(path = "/api/v1", tag = "Wallets")] +impl WalletApi { + #[post( + "/wallets", + summary = "Open a wallet", + description = "Opens a new wallet for an owner with an optional opening balance.", + status = 201 + )] + async fn open( + State(api): State, + Json(body): Json, + ) -> WebResult<(axum::http::StatusCode, Json)> { + let view: WalletView = api.bus.send(body).await.map_err(cqrs_to_web)?; + Ok((axum::http::StatusCode::CREATED, Json(view))) + } + + #[get("/wallets/:id", summary = "Fetch a wallet")] + async fn get( + State(api): State, + Path(id): Path, + ) -> WebResult> { + let view: WalletView = api.bus.query(GetWallet { id }).await.map_err(cqrs_to_web)?; + Ok(Json(view)) + } + // … deposit / withdraw / transfer / compliance / 2pc +} +// generated: WalletApi::routes(state) -> axum::Router +``` + +Lo que acaba de pasar: la macro emite `WalletApi::routes(state)` **y** envía un +`ControllerMount` más un descriptor por ruta a tablas de tiempo de enlazado. Así +`FireflyApplication` **monta automáticamente** el controlador (resolviendo su +estado autowired desde el contenedor a través de +`firefly::web::mount_controllers`), y el generador de OpenAPI y el endpoint +`/mappings` del actuator pueden enumerar las rutas de Lumen sin volver a parsear el +fuente. Lumen nunca entrega `WalletApi::routes(state)` a la pila web — declarar el +bean controlador es todo el cableado. + +Por qué importa: `WebResult` renderiza cualquier error del handler como un cuerpo +de problema RFC 9457 de manera uniforme, así que el moldeado de errores es el mismo +en cada endpoint sin código por handler, y el parámetro de ruta `:id` es el extractor +`Path` ordinario de axum — Firefly no inventa su propio enrutamiento. + +> **Tip** **Punto de control.** Ejecuta Lumen (`cargo run -p firefly-sample-lumen`) +> y abre `http://localhost:8081/swagger-ui` en el puerto de **gestión**. El resumen +> "Open a wallet", la respuesta `201` y la etiqueta `Wallets` provienen todos de los +> atributos de verbo de arriba — sin un archivo de especificación aparte. + +## Paso 7 — El latido programado (`housekeeping.rs`) + +> **Note** **Término clave — tarea programada.** Una *tarea programada* es una +> `async fn` sin argumentos que el framework ejecuta con una cadencia — una +> frecuencia fija, un retardo fijo o una expresión cron. El equivalente en Spring +> es `@Scheduled`. + +`#[scheduled(...)]` genera un auxiliar `schedule_(scheduler)` que registra la +función en un `Scheduler`, y también envía un `ScheduledRegistration` que el +framework drena: + +```rust,ignore +// samples/lumen/src/housekeeping.rs +#[scheduled(fixed_rate = "60s", initial_delay = "5s")] +pub async fn ledger_heartbeat() -> Result<(), std::io::Error> { + HEARTBEAT_TICKS.fetch_add(1, Ordering::Relaxed); + Ok(()) +} +// generated: schedule_ledger_heartbeat(&scheduler) +``` + +Lo que acaba de pasar: `#[scheduled]` emite el auxiliar `schedule_(scheduler)` +*y* el registro. En el arranque el framework llama a +`register_discovered_scheduled(&scheduler)`, que drena el inventory e instala cada +tarea `#[scheduled]` — así Lumen nunca llama a `schedule_` a mano. Usa +`fixed_rate = "60s"` para una cadencia fija (con un `initial_delay` opcional), o +`cron = "…"` para una expresión cron. + +> **Tip** **Punto de control.** `scheduled_task_registers` (en `housekeeping.rs`) +> construye un scheduler nuevo, llama a `register_discovered_scheduled` y afirma que +> `scheduler.tasks()` contiene `"ledger_heartbeat"` — prueba de que el registro se +> drenó del inventory sin ninguna llamada manual a `schedule_`. + +## Paso 8 — El trío de orquestación (`transfer.rs`, `compliance.rs`, `tcc_transfer.rs`) + +Tres macros declarativas de orquestación completan el propio fuente de Lumen. Cada +una convierte un bloque `impl` anotado en un coordinador ejecutable. Conocemos +primero los términos clave, luego leemos una declaración de cada una. + +> **Note** **Término clave — saga.** Una *saga* es una transacción distribuida +> compuesta de pasos, cada uno con una **compensación** que lo deshace si un paso +> posterior falla. No hay bloqueo compartido; la consistencia se restaura +> ejecutando las compensaciones en orden inverso. El equivalente en Spring/Axon es +> una `@Saga`. + +Una transferencia *no* es un único comando atómico: debita el origen, luego +acredita el destino, y si el abono falla el débito debe reembolsarse. Ese es el +patrón saga, declarado como métodos anotados en `TransferSaga`: + +```rust,ignore +// samples/lumen/src/transfer.rs +#[firefly::saga(name = "money-transfer")] +impl TransferSaga { + #[saga_step(id = "debit", compensate = "refund_debit")] + async fn debit(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { + self.ledger.withdraw(&req.from, Money::cents(req.amount)).await?; + Ok(()) + } + + async fn refund_debit(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { + self.ledger.deposit(&req.from, Money::cents(req.amount)).await?; + Ok(()) + } + + #[saga_step(id = "credit", depends_on = ["debit"])] + async fn credit(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { + self.ledger.deposit(&req.to, Money::cents(req.amount)).await?; + Ok(()) + } +} +// generated: TransferSaga::run(req) and TransferSaga::saga() +``` + +Lo que acaba de pasar: `#[firefly::saga]` reduce estos métodos sobre el motor `Saga` +de `firefly-orchestration`. `depends_on` ordena los pasos, `compensate` nombra el +método de reversión, y cada parámetro se inyecta desde el contexto de la saga — +aquí la petición, vía `#[input]`. La macro genera `TransferSaga::run`, al que llama +`run_transfer`. Cuando la pata de abono falla, el motor ejecuta `refund_debit`, así +que el flujo del origen muestra un débito real *y* su reembolso compensatorio. + +> **Note** **Término clave — workflow.** Un *workflow* es un grafo acíclico dirigido +> (DAG) de pasos: los pasos independientes se ejecutan en paralelo; un paso con +> `depends_on` se ejecuta solo después de sus prerrequisitos y lee sus resultados +> vía `#[from_step]`. Donde una saga es una cadena lineal con compensación, un +> workflow es un fan-in paralelo. El equivalente en Spring es un proceso basado en +> DAG como el grafo de tareas de Spring Cloud Data Flow. + +La comprobación de cumplimiento de Lumen ejecuta `balance-check` y `limit-check` en +paralelo, luego `approve` tras ambas: + +```rust,ignore +// samples/lumen/src/compliance.rs +#[firefly::workflow(name = "transfer-compliance")] +impl ComplianceCheck { + #[workflow_step(id = "balance-check")] + async fn balance_check(&self, #[input] req: TransferRequest) -> Result { /* … */ } + + #[workflow_step(id = "limit-check")] + async fn limit_check(&self, #[input] req: TransferRequest) -> Result { /* … */ } + + #[workflow_step(id = "approve", depends_on = ["balance-check", "limit-check"])] + async fn approve( + &self, + #[from_step("balance-check")] funds_ok: bool, + #[from_step("limit-check")] within_limit: bool, + ) -> Result<(), ComplianceError> { /* … */ } +} +// generated: ComplianceCheck::run(req) +``` + +> **Note** **Término clave — TCC (Try / Confirm / Cancel).** *TCC* es una +> transacción distribuida en dos fases: cada participante primero **reserva** +> (try); solo cuando todas las reservas tienen éxito el coordinador las +> **confirma** (confirm); de lo contrario **cancela** (cancel) las ya intentadas. +> Donde una saga deshace una pata ya confirmada, TCC reserva primero y confirma al +> final. El equivalente en Spring/Seata es el modo de transacción TCC. + +La transferencia en dos fases de Lumen retiene el origen, verifica el destino y +luego captura en ambos lados: + +```rust,ignore +// samples/lumen/src/tcc_transfer.rs +#[firefly::tcc(name = "transfer-2pc")] +impl TwoPhaseTransfer { + #[participant(name = "source", confirm = "capture_source", cancel = "release_source")] + async fn hold_source(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { /* withdraw (hold) */ } + async fn capture_source(&self) -> Result<(), DomainError> { Ok(()) } // the debit was the capture + async fn release_source(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { /* deposit (release) */ } + + #[participant(name = "dest", confirm = "capture_dest")] + async fn hold_dest(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { /* verify exists */ } + async fn capture_dest(&self, #[input] req: TransferRequest) -> Result<(), DomainError> { /* deposit (capture) */ } +} +// generated: TwoPhaseTransfer::run(req) +``` + +Lo que acaba de pasar en las tres: cada macro lee sus métodos anotados y genera un +método `run` sobre el motor de orquestación, cableando el grafo de +pasos/participantes, la inyección de parámetros (`#[input]` / `#[from_step]`) y la +ruta de compensación o cancelación. Tú escribes los cuerpos; la macro escribe el +coordinador. + +> **Tip** **Punto de control.** Los tests HTTP +> `transfer_saga_overdraft_compensates_and_is_422`, +> `compliance_workflow_rejects_overdraft_with_422` y +> `tcc_transfer_overdraft_releases_the_hold_and_is_422` (en `http_test.rs`) +> ejercitan la ruta de fallo de cada macro de extremo a extremo. Ejecuta +> `cargo test -p firefly-sample-lumen overdraft` y mira pasar las tres. + +## Paso 9 — El contenedor de configuración y el contribuidor de streaming (`web.rs`) + +Lumen *sí* usa el contenedor de DI directamente. `web.rs` lleva un contenedor +`#[derive(Configuration)]` cuyos métodos factoría `#[bean]` **declaran** los beans +de infraestructura: + +> **Note** **Término clave — contenedor de configuración y factoría de bean.** Un +> *contenedor de configuración* es un tipo `#[derive(Configuration)]` cuyos métodos +> `#[bean]` son *factorías*: cada uno devuelve un valor construido que el contenedor +> registra como un bean y puede conectar por autowiring en otra parte. El +> equivalente en Spring es una clase `@Configuration` cuyos métodos `@Bean` +> producen beans. + +```rust,ignore +// samples/lumen/src/web.rs +#[derive(Configuration)] +struct LumenBeans; + +#[bean] +impl LumenBeans { + #[bean] + fn event_store(&self) -> MemoryEventStore { MemoryEventStore::new() } + + #[bean] + fn query_cache(&self) -> QueryCache { QueryCache::new() } + + #[bean] + fn jwt_service(&self) -> JwtService { JwtService::new(crate::security::DEMO_SIGNING_KEY) } + + #[bean] + fn security_filter_chain(&self) -> FilterChain { crate::security::security_layers().1 } + + #[bean] + fn bearer_layer(&self) -> BearerLayer { crate::security::security_layers().0 } + + #[bean] + fn ledger(&self, store: Arc, broker: Arc) -> Ledger { + let store: Arc = store; + Ledger::new(store, broker) + } +} +``` + +Lo que acaba de pasar: `container.scan()` descubre y registra cada método `#[bean]`, +así que `build_app` no llama a ningún `register_arc` — el **framework** hace el +registro. La factoría `ledger` incluso *conecta sus propios argumentos por +autowiring* (`store` y el port `Broker` provisto por el framework), así que una +factoría de bean es en sí misma un punto de cableado. Los beans +`security_filter_chain` y `bearer_layer` se descubren automáticamente y se +estratifican sobre la API sin ninguna llamada `.security(...)` — el patrón +`SecurityFilterChain` de Spring. Toda la mecánica de DI está en el +[análisis profundo de inyección de dependencias](./04a-dependency-injection.md). + +El endpoint de streaming opcional muestra una costura declarativa más — un bean +`RouteContributor`: + +> **Note** **Término clave — contribuidor de rutas.** Un *contribuidor de rutas* es +> un bean que entrega al framework un `axum::Router` extra para fusionarlo en la API +> pública. Es la forma de añadir rutas que no encajan en la forma de +> `#[rest_controller]` (aquí, un stream reactivo con feature gate) *declarando un +> bean* en lugar de tocar una raíz de composición. + +```rust,ignore +// samples/lumen/src/web.rs (feature `streaming`) +#[cfg(feature = "streaming")] +#[derive(Service)] +#[firefly(provides = "dyn firefly::web::RouteContributor")] +struct StreamingRoutes { + #[autowired] + api: Arc, +} + +#[cfg(feature = "streaming")] +impl firefly::web::RouteContributor for StreamingRoutes { + fn routes(&self) -> axum::Router { + streaming_router((*self.api).clone()) + } +} +``` + +Lo que acaba de pasar: `#[firefly(provides = "dyn firefly::web::RouteContributor")]` +indica al contenedor que registre este `#[derive(Service)]` bajo el port +`RouteContributor`. El framework lo descubre y fusiona sus rutas — un endpoint +`GET /api/v1/wallets/:id/events` con feature gate cableado declarando un bean, no +mediante una raíz de composición. + +> **Tip** **Punto de control.** Abre `samples/lumen/src/web.rs` y confirma que +> `build_router()` (la costura de test) es simplemente +> `FireflyApplication::new(APP_NAME).version(VERSION).bootstrap().await… +> .api_router` — sin un constructor escrito a mano. Cada bean de este paso lo +> autorregistra `container.scan()`; el controlador se monta automáticamente; la +> seguridad y el middleware de caché de lectura se descubren automáticamente. + +## Paso 10 — El resto del conjunto declarativo (no usado por Lumen) + +Varias macros más son partes de primera clase del framework que Lumen **no** +ejercita en su propio fuente — usa event sourcing (así que el `#[firefly::repository]` +/ `#[firefly::transactional]` relacionales nunca aparecen) y gestiona las +preocupaciones transversales restantes por otros medios. Cada una se muestra aquí +como un ejemplo independiente, enfocado y correcto, para que el catálogo esté +completo. + +| Macro | Propósito | Genera | +|-------|---------|-----------| +| `#[derive(Builder)]` | un constructor fluido con campos obligatorios/por defecto | `T::builder()` → setters fluidos → `build() -> Result` | +| `#[derive(Mapper)]` | conversión struct-a-struct en tiempo de compilación | un `From` por cada `#[firefly(from = "…")]` | +| `#[derive(Entity)]` | el mapeo `@Entity` a partir de campos de struct anotados | un `impl` de `SqlxEntity` (`@Table` / `@Id` / `@Version` / `@Column`) | +| `#[derive(SqlxRepository)]` | un bean `@Repository` de sqlx totalmente cableado | impls de `ReactiveCrudRepository` **y** `ReactiveSpecificationRepository` más el accesor `repository()` | +| `#[firefly::repository]` | cuerpos de método de consulta derivada y consulta personalizada | cuerpos de método sobre un `impl` de `SqlxReactiveRepository` a partir de nombres de método o `#[query(…)]` | +| `#[firefly::transactional]` | un límite de transacción declarado | un límite commit-en-`Ok` / rollback-en-`Err` alrededor de una `async fn` | +| `#[firefly::pre_authorize]` / `#[firefly::post_authorize]` | control de acceso a nivel de método | una comprobación de acceso antes del cuerpo, o una comprobación de returnObject después | +| `#[derive(Validate)]` (+ `Valid`) | validación de bean JSR-380 | un `impl Validate`; el extractor `Valid` rechaza un fallo de restricción con 422 | +| `#[cacheable]` / `#[cache_put]` / `#[cache_evict]` | caché declarativa | un cuerpo read-through / write-through / evict alrededor del adaptador de caché registrado | +| `#[retry]` / `#[circuit_breaker]` / `#[rate_limit]` / `#[bulkhead]` / `#[timeout]` | decoradores de resiliencia | el cuerpo envuelto en la primitiva `firefly_resilience` correspondiente | +| `#[async_method]` | async fire-and-forget | una `async fn(self: Arc, …) -> R` reescrita a una `fn … -> TaskHandle` no async | +| `#[application_event_listener]` / `#[transactional_event_listener]` | eventos en proceso | un `@EventListener` / `@TransactionalEventListener` descubierto vía inventory | +| `#[aspect]` (+ `#[before]`/`#[after]`/`#[around]`) | consejo orientado a aspectos | `impl firefly_aop::Aspect` + un registro de inventory | + +Los derives de estereotipo de DI restantes completan el conjunto: +`#[derive(Component/Service/Repository/Configuration/AutoConfiguration/Controller)]`, +`#[bean]`, `#[autowired]`, `register_all!` y `#[derive(ConfigProperties)]`. +`#[derive(AutoConfiguration)]` es el contenedor de autoconfiguración cuyos `#[bean]` +se retiran tras un `condition_on_missing_bean`, de modo que una aplicación puede +sobrescribir cualquier valor por defecto declarando su propio bean del mismo tipo; +`Container::scan()` autorregistra cada método `#[bean]`, y +`Container::scan_packages([..])` restringe el descubrimiento a rutas de módulo +nombradas. + +### Construcción — el builder fluido (`#[derive(Builder)]`) + +Los derives de la stdlib de Rust ya cubren el código repetitivo de los objetos de +valor — `Debug`, `Clone`, `PartialEq`, `Default`. El único hueco ergonómico que +dejan es un *builder fluido*, y eso es lo que rellena `#[derive(Builder)]` (el +`@Builder` de Lombok). Genera `T::builder()` que devuelve un `TBuilder` con un +setter por campo y un `build() -> Result`. Por defecto cada campo es +**obligatorio**: `build` devuelve un `Err` nombrando el primer campo sin asignar. +`#[builder(default)]` recurre a `Default::default()`, `#[builder(default = "expr")]` +a una expresión personalizada y `#[builder(into)]` hace que el setter acepte +`impl Into`. El `OpenWallet` de Lumen (del +[Paso 3](#step-3--cqrs-messages-and-their-handler-bean-commandsrs)) lo lleva: + +```rust,ignore +let cmd = OpenWallet::builder() + .owner("ada") // impl Into + .opening_balance(10_000) + .build()?; // Result +``` + +Devolver un `Result` mantiene la gestión de campos faltantes en la ruta normal de +`?` en lugar de un panic. Recurre a `#[derive(Builder)]` cuando una struct tiene +muchos campos opcionales/por defecto; mantén un literal simple cuando todos los +campos son obligatorios y están presentes. + +### Conversión — el mapper de tiempo de compilación (`#[derive(Mapper)]`) + +`#[derive(Mapper)]` genera un `From` de tiempo de compilación, comprobado en +tipos, que mapea una struct origen a un destino campo a campo. Un +`#[firefly(from = "Source")]` produce un `impl` de `From`, y el atributo es +**repetible** para mapear desde varios orígenes. Los atributos por campo ajustan el +mapeo: `#[firefly(rename = "src")]` lee un campo origen con nombre distinto, +`#[firefly(into)]` aplica `.into()`, `#[firefly(with = "fn")]` ejecuta una función +de conversión, y `#[firefly(default)]` / `#[firefly(default_expr = "expr")]` +rellenan un campo destino sin lectura de origen: + +```rust,ignore +#[derive(Debug, Clone, Serialize, Deserialize, Mapper)] +#[firefly(from = "Wallet")] +pub struct WalletView { + #[firefly(rename = "root", with = "aggregate_id")] // read src.root, run aggregate_id(..) + pub id: String, + pub owner: String, // same name on both ends: a plain move + #[firefly(with = "Money::cents_value")] // src.balance: Money -> i64 via a fn + pub balance: i64, + #[firefly(default)] // version set by the projector, not the fold + pub version: i64, +} +// generates: impl From for WalletView { fn from(src: Wallet) -> Self { … } } +``` + +Como el código generado es un `impl` de `From` corriente, cada campo lo comprueba el +compilador sin coste en tiempo de ejecución — esa garantía de tiempo de compilación +es todo el sentido. Contrástalo con el `firefly_data::Mapper` de **tiempo de +ejecución**, que convierte mediante dos pasadas de serde: usa el mapper de tiempo de +ejecución cuando el tipo origen no se conoce hasta tiempo de ejecución (mapeo de JSON +arbitrario), y prefiere `#[derive(Mapper)]` siempre que ambos extremos sean tipos +concretos. + +> **Note** El `WalletView` real de Lumen lo construye un método `Wallet::view` +> escrito a mano (en `domain.rs`) en lugar de `#[derive(Mapper)]`; el listado de +> arriba es la forma declarativa equivalente, mostrada para ilustrar la macro. + +### Persistencia — entidades, repositorios y transacciones (relacional) + +Estas macros se sitúan en la ruta de persistencia relacional. El modelo de lectura +de Lumen es una proyección en memoria sobre un flujo de eventos, así que no usa +ninguna de ellas — pero en un servicio relacional son las herramientas del día a +día. La referencia completa es el [capítulo de Persistencia](./07-persistence.md). + +`#[derive(Entity)]` genera el mapeo `SqlxEntity` (`@Table` / `@Id` / `@Version` / +`@Column`) a partir de campos anotados. Los campos escalares se mapean +automáticamente; un campo no escalar usa +`#[firefly(with(read = "path", write = "path"))]`: + +```rust,ignore +#[derive(Debug, Clone, Entity)] +#[firefly(table = "accounts")] +pub struct Account { + #[firefly(id)] + pub id: String, + pub owner: String, + pub status: String, + #[firefly(version)] + pub version: i64, +} +``` + +`#[derive(SqlxRepository)]` construye un bean `@Repository` totalmente cableado a +partir del datasource `Db` inyectado (vía `repository_for`). Implementa tanto +`ReactiveCrudRepository` (la superficie `save` / `find_by_id` / `delete_by_id` / +`count`) **como** `ReactiveSpecificationRepository` (`find_by_spec`, el equivalente +de `JpaSpecificationExecutor`) por delegación, y expone el accesor `repository()` +sobre el que se construye `#[firefly::repository]`: + +```rust,ignore +#[derive(SqlxRepository)] +#[firefly(entity = "Account")] +pub struct AccountRepo { + db: Arc, +} +``` + +`#[firefly::repository]` convierte un nombre de método `find_by_…` / `count_by_…` / +`exists_by_…` / `delete_by_…` en un cuerpo de consulta funcional. El método de +tiempo de ejecución se elige a partir del **tipo de retorno** (`Vec` / `Option` +→ find, `i64` → count, `bool` → exists, `u64` → delete); los cuerpos placeholder +`unimplemented!()` se descartan: + +```rust,ignore +#[firefly::repository] +impl AccountRepo { + async fn find_by_status(&self, status: &str) -> Result, DataError> { unimplemented!() } + async fn find_by_iban(&self, iban: &str) -> Result, DataError> { unimplemented!() } + async fn count_by_owner(&self, owner: &str) -> Result { unimplemented!() } + async fn exists_by_email(&self, email: &str) -> Result { unimplemented!() } +} +``` + +Da a un método `find_by_…` un argumento `Pageable` final (y un retorno +`Result, DataError>`) y el cuerpo generado añade la ordenación y la ventana +de la página, delegando en `find_by_derived_paged`. Ten en cuenta que `Pageable::of` +devuelve un `Result`: + +```rust,ignore +#[firefly::repository] +impl AccountRepo { + async fn find_by_owner(&self, owner: &str, page: Pageable) + -> Result, DataError> { unimplemented!() } +} + +// Build the page (1-based index) with sort + window — `of` returns a Result: +let page = Pageable::of(1, 20, RequestSort::of([Order::desc("id")])).unwrap(); +let rows = repo.find_by_owner("ada", page).await?; +``` + +Cuando una consulta derivada del nombre no basta, anota un stub con `#[query(...)]` y +escribe la sentencia directamente. El SQL nativo enlaza cada placeholder `:name` al +argumento llamado `name`; el **tipo de retorno** sigue seleccionando la operación — +`Vec` / `Option` es una lista, `i64` un conteo, `bool` una comprobación de +existencia y `u64` una sentencia modificadora (que devuelve las filas afectadas): + +```rust,ignore +#[firefly::repository] +impl AccountRepo { + #[query("SELECT id, owner FROM accounts WHERE status = :status ORDER BY id DESC")] + async fn active_by_status(&self, status: &str) -> Result, DataError> { unimplemented!() } + + #[query("UPDATE accounts SET status = :status WHERE id = :id")] + async fn set_status(&self, id: &str, status: &str) -> Result { unimplemented!() } +} +``` + +`#[query(sql = "…")]` es la grafía explícita de la forma nativa, y +`#[query(jpql = "…", entity = "Account")]` escribe la sentencia contra nombres de +entidad. + +> **Note** **Término clave — límite de transacción.** Un *límite de transacción* es +> una región de código cuyo trabajo de base de datos se confirma junto o revierte +> junto. `#[firefly::transactional]` convierte ese límite en una declaración sobre +> una `async fn`. El equivalente en Spring es `@Transactional`. + +`#[firefly::transactional]` envuelve el cuerpo de una `async fn` en una transacción +gobernada por el `TransactionManager` registrado — commit en `Ok`, rollback en +`Err`. La función debe ser `async`, debe devolver `Result`, y su tipo de error +debe implementar `From` para que los fallos de +begin/commit afloren a través de `?`. Pelada, o con opciones: + +```rust,ignore +#[firefly::transactional] +async fn open_account(repo: &AccountRepo, acct: Account) -> Result<(), DataError> { + repo.insert(&acct).await?; // committed together on Ok, + repo.insert_audit(&acct).await?; // rolled back together on Err + Ok(()) +} + +#[firefly::transactional(propagation = "requires_new", isolation = "serializable", read_only = false, timeout_ms = 5000)] +async fn reconcile(repo: &LedgerRepo) -> Result<(), DataError> { /* … */ } +``` + +Por defecto el límite se ejecuta a través del `TransactionManager` registrado +**global del proceso**. `manager = ""` (el `@Transactional("txManager")` de +Spring) lo enlaza en cambio a un manager **explícito** que el servicio posee — la +expresión produce un valor `m` con `&m: &Arc`. Úsalo para un +servicio multi-datasource, o para mantener el aislamiento por instancia/por test. El +caso de uso `transfer` del sample `lumen-ledger` está cableado exactamente así: + +```rust,ignore +// samples/lumen-ledger — core/src/services/wallet/v1/wallet_service_impl.rs +#[firefly::transactional(manager = "self.tx_manager()")] // self owns the manager +async fn transfer_tx(&self, from: Uuid, to: Uuid, amount: i64) -> Result { + let mut src = self.load_active(from).await?; // debit + credit commit + let mut dst = self.load_active(to).await?; // together, or roll back + src.balance -= amount; let saved = self.persist(src).await?; + dst.balance += amount; self.persist(dst).await?; + Ok(saved) +} +``` + +Dos opciones adicionales controlan qué errores provocan rollback, ambas +deliberadamente *no* llamadas `rollback_for` (el `rollbackFor` de Spring es una +trampa porque su caso límite de ya-marcado-como-rollback-only sorprende a la gente): + +- `no_rollback_for = ""` — el `@Transactional(noRollbackFor = …)` de Spring: + cuando el `Err` coincide con el patrón, el límite **confirma** en lugar de + revertir. +- `rollback_only_for = ""` — revierte **solo** cuando el `Err` coincide con el + patrón, confirmando ante cualquier otro error. El patrón es un patrón estilo match + sobre el tipo de error de la función, con alternativas permitidas: + `no_rollback_for = "Error::A | Error::B"`. Con ambos, `no_rollback_for` gana en + caso de solapamiento. + +```rust,ignore +#[firefly::transactional(no_rollback_for = "DataError::NotFound(_)")] +async fn upsert(repo: &AccountRepo, acct: Account) -> Result<(), DataError> { /* … */ } +``` + +Estas dos macros relacionales son la contraparte de cómo Lumen logra la consistencia +*sin* un gestor de transacciones: añade eventos al `EventStore` bajo concurrencia +optimista y los proyecta, en lugar de mutar filas dentro de un límite +`#[transactional]`. Mismo objetivo — escrituras atómicas y consistentes — alcanzado +mediante dos arquitecturas diferentes. + +### Seguridad de método — `#[pre_authorize]` / `#[post_authorize]` + +Dos macros imponen el control de acceso en el límite del método, leyendo la +identidad del llamante del contexto de seguridad ambiental en lugar de de una +`Request`. El tratamiento completo está en el +[capítulo de Seguridad](./14-security.md). + +`#[firefly::pre_authorize(...)]` ejecuta una comprobación de acceso **antes** del +cuerpo. Aplícala a una `fn` que devuelva `Result` cuyo error implemente +`From`, de modo que una denegación viaje por la ruta +de `?`: + +```rust,ignore +#[firefly::pre_authorize] // `authenticated` — any caller in scope +async fn whoami() -> Result { /* … */ } + +#[firefly::pre_authorize(role = "ADMIN")] // a single role +async fn close_books(&self) -> Result<(), AppError> { /* … */ } + +#[firefly::pre_authorize(any_role = ["TELLER", "ADMIN"])] +async fn open_account(&self, req: OpenAccount) -> Result { /* … */ } + +#[firefly::pre_authorize(authority = "wallet:write")] // a single fine-grained authority +async fn deposit(&self, id: &str, cents: i64) -> Result<(), AppError> { /* … */ } +``` + +Cuando no hay ningún llamante en el ámbito el cuerpo se omite y la macro devuelve +`Err(SecurityError::Unauthenticated.into())`; cuando hay un llamante presente pero le +falta el rol/autoridad requerido devuelve `Err(SecurityError::Forbidden.into())`. + +`#[firefly::post_authorize()]` se ejecuta **después** de que una +`async fn` retorne y filtra el valor según una expresión booleana que ve `result` +(una `&T` al valor devuelto) y `auth` (una `&Authentication`); si es `false` el +valor se descarta y la llamada devuelve `Forbidden`: + +```rust,ignore +// Only return the wallet if the caller owns it. +#[firefly::post_authorize(result.owner == auth.subject())] +async fn get_wallet(&self, id: &str) -> Result { /* … */ } +``` + +Como `BearerLayer` delimita el ámbito de la autenticación para toda la llamada +descendente, estas comprobaciones funcionan sobre un método de servicio que nunca ve +la `Request` — la macro lee del ámbito, no de un argumento del handler. + +### Validación — `#[derive(Validate)]` y `Valid` + +`#[derive(Validate)]` genera un `impl Validate` que ejecuta la restricción +`#[validate(email/url/not_empty/length/range/pattern/custom)]` de cada campo, y el +extractor web `Valid` rechaza un fallo de restricción con `422`: + +```rust,ignore +#[derive(Debug, Deserialize, Validate)] +struct CreateUser { + #[validate(not_empty, length(min = 2, max = 64))] + name: String, + #[validate(email)] + email: String, +} + +// In a controller, `Valid` returns 422 if any constraint fails: +async fn create(Valid(body): Valid) -> WebResult> { /* … */ } +``` + +### Caché, async, eventos en proceso y aspectos + +`#[cacheable]` / `#[cache_put]` / `#[cache_evict]` envuelven el cuerpo de un método +en una ruta read-through / write-through / evict alrededor del adaptador de caché +registrado en el proceso. `#[cacheable]` también acepta `condition = ""` +(saltar la caché cuando la expresión del parámetro es `false`) y +`unless = ""` (no almacenar cuando la expresión del resultado — enlazada +como `result: &V` — es `true`): + +```rust,ignore +#[cacheable(key = "format!(\"order:{}\", id)", unless = "result.is_empty()")] +async fn load_order(&self, id: &str) -> Result { /* … */ } +``` + +`#[async_method]` reescribe una `async fn(self: Arc, …) -> R` en una +`fn … -> TaskHandle` no async que lanza el cuerpo sobre el ejecutor registrado — +fire-and-forget, con un handle para esperarlo después. + +`#[application_event_listener]` / `#[transactional_event_listener]` son los listeners +de eventos en proceso (el `@EventListener` / `@TransactionalEventListener` de +Spring): cada uno se descubre vía inventory y lo dispara `publish_event`, el +transaccional enlazado a una fase de commit. + +`#[aspect]` (con consejos `#[before]` / `#[after]` / `#[around]`) genera un +`impl firefly_aop::Aspect` más un registro de inventory; el consejo se ejecuta +alrededor del punto de tejido explícito `advised(…)`. + +### Decoradores de resiliencia + +Donde las primitivas de `firefly_resilience` son la superficie de construirlo-tú-mismo +(`Retry::new().max_attempts(3).execute(op)`), cinco macros **decoradoras** ponen las +mismas guardas sobre un método — los equivalentes de Resilience4j / Spring-Retry: + +```rust,ignore +#[firefly::retry(max_attempts = 4, delay = "100ms", backoff = 2.0, max_delay = "2s")] +async fn fetch_quote(&self) -> Result { /* … */ } + +#[firefly::circuit_breaker(failure_threshold = 5, open_duration = "30s")] +async fn call_upstream(&self) -> Result { /* … */ } + +#[firefly::rate_limit(rate = 100.0, burst = 20)] // 100/s, bucket of 20 +async fn search(&self, q: &str) -> Result { /* … */ } + +#[firefly::bulkhead(20)] // ≤ 20 calls in flight +async fn render(&self, doc: &Doc) -> Result { /* … */ } + +#[firefly::timeout("2s")] +async fn slow_report(&self) -> Result { /* … */ } +``` + +Aplícalas a una `async fn` que devuelva `Result` cuyo error implemente +`std::error::Error + Send + Sync + 'static + From`. +El decorador conduce el propio fallo del cuerpo a través de la primitiva y recupera +la **`E` original** a la salida, mientras que el cortocircuito propio de una guarda +(un timeout, un circuito abierto, un rechazo) aflora a través de +`E::from(ResilienceError)`. Los atributos se **apilan**, el más externo primero: + +```rust,ignore +#[firefly::retry(max_attempts = 3, delay = "50ms")] // outer: re-runs the call +#[firefly::circuit_breaker(failure_threshold = 5)] // inner: trips on a failing dep +async fn call_upstream(&self) -> Result { /* … */ } +``` + +Las guardas con estado (`#[circuit_breaker]`, `#[rate_limit]`, `#[bulkhead]`) +mantienen su estado en un `static` por método, compartido entre cada llamada — la +semántica de bean de registro de Resilience4j; `#[retry]` y `#[timeout]` son sin +estado y se reconstruyen en cada llamada. Las duraciones aceptan una cadena con +sufijo de unidad (`"100ms"`, `"2s"`, `"1m"`) o un entero pelado de milisegundos. + +### El cliente HTTP saliente — `#[http_client]` + +`#[http_client]` es el cliente declarativo de interfaz HTTP (el `@HttpExchange` de +Spring). Aplicado a un `trait`, emite el trait tal cual **y** una struct +`Impl` que envuelve un `WebClient` e implementa el trait traduciendo el +atributo de verbo de cada método y la ruta estilo `:id` en una llamada. La forma +esperada `async fn -> Result` decodifica el cuerpo, aflora un 404 +como `ClientError::Problem` y admite un error personalizado vía +`E: From`; los retornos no esperados `Mono` / `Flux` afloran el +`ClientError` crudo sin cambios: + +```rust,ignore +#[http_client(path = "/api/v1/orders")] +trait OrderClient { + #[get("/:id")] + async fn get_order(&self, id: String) -> Result; + + #[get("/")] + async fn list(&self, status: String, page: Option) -> Result, ClientError>; + + #[post("/")] + async fn create(&self, body: NewOrder) -> Result; + + #[get("/opt/:id")] + async fn find_opt(&self, id: String) -> Result, ClientError>; +} +// generated: struct OrderClientImpl { … } impl OrderClient for OrderClientImpl { … } +``` + +> **Tip** **Punto de control.** No ejecutarás ninguno de los ejemplos del Paso 10 +> contra Lumen — son entradas de catálogo, no fuente de Lumen. La prueba de fuego es +> el siguiente paso: confirmar que todas las macros de *Lumen* compilan y pasan. + +## Paso 11 — Cómo aterriza realmente el cableado: el contrato `__rt` y el drenaje del inventory + +Ya has visto cada macro que Lumen usa. La última pieza es *por qué declarar un bean +es todo el cableado*. Dos mecanismos lo hacen funcionar. + +Primero, la **ruta del contrato `__rt`** del +[Paso 1](#step-1--one-dependency-one-prelude). Un crate `proc-macro` no puede +reexportar tipos de tiempo de ejecución, así que el código generado por la macro +nombra cada tipo de tiempo de ejecución a través de `::firefly::__rt::firefly_cqrs::Bus` +y compañía. Esa es la razón por la que un servicio de un solo crate compila todo lo +que una macro expanda sin listar los crates `firefly-*` subyacentes. + +Segundo, el **drenaje del inventory**. La capa declarativa hace más que generar +auxiliares: cada bean handler, bean listener, tarea programada y controlador también +envía un registro a un registro de inventory de tiempo de compilación, y +`FireflyApplication` drena esos registros en el arranque. Así Lumen no llama a +*ninguno* de los cableados a mano: + +- sin `register(&bus)` — drenado por `register_discovered_handlers`, +- sin `subscribe(&broker)` — drenado por `subscribe_discovered_listeners`, +- sin `schedule_(scheduler)` — drenado por `register_discovered_scheduled`, +- sin `WalletApi::routes(state)` entregado a la pila web — drenado por + `mount_controllers`, +- y sin `OnceLock` publicando los colaboradores de los handlers — se conectan por + autowiring desde el contenedor. + +Lumen declara los beans `WalletHandlers` / `WalletProjection`, la tarea de latido, +las factorías `LumenBeans` y el controlador `WalletApi`, y el framework resuelve cada +bean desde el contenedor y lo instala. La forma de `fn` libre de +`#[command_handler]` / `#[query_handler]` / `#[event_listener]` / `#[scheduled]` +sigue generando un auxiliar `register_` / `subscribe_` / `schedule_` +para el caso sin colaboradores; pero como los handlers de Lumen conectan +colaboradores por autowiring, usa la forma de bean, y el servicio en ejecución queda +cableado enteramente por el drenaje del inventory. + +> **Tip** **Punto de control.** Ejecuta Lumen y lee el informe de arranque. La línea +> `:: cqrs handlers: … | event listeners: … | scheduled tasks: … | controllers: +> … ::` es el inventario que el framework drenó — el conteo es exactamente los +> beans, listeners, tareas y controladores que declaraste, sin ninguna llamada de +> registro en ningún sitio del fuente. + +## Paso 12 — Todo el crate, declarativamente + +Leídas de arriba abajo, las macros cuentan la historia de Lumen: + +```text + money.rs (no macros — a pure value object; the no-thiserror promise) + domain.rs #[derive(DomainEvent)] x3 #[derive(AggregateRoot)] #[derive(Schema)] + ledger.rs #[derive(Repository)] ReadModel #[derive(Service)] WalletProjection + #[handlers] + #[event_listener(topic = "wallets.events")] + commands.rs #[derive(Command)] x3 #[derive(Query)] #[derive(Builder/Schema)] + #[derive(Service)] WalletHandlers + #[handlers] + #[command_handler] x3 + #[query_handler] + transfer.rs #[firefly::saga] + #[saga_step] x2 + compliance.rs #[firefly::workflow] + #[workflow_step] x3 + tcc_transfer.rs #[firefly::tcc] + #[participant] x2 + security.rs (JwtService / BearerLayer / FilterChain — runtime APIs) + web.rs #[derive(Configuration)] + #[bean] x6 #[derive(Controller)] + #[rest_controller] + #[get] / #[post] x7 + housekeeping.rs #[scheduled(fixed_rate = "60s", initial_delay = "5s")] +``` + +Lo que *no* es una macro es igual de revelador: la cadena de filtros de seguridad se +construye con un builder de tiempo de ejecución (`FilterChain::new().require(...)`), +porque su forma es datos, no una declaración fija — y Lumen la mantiene explícita +para que el flujo de control quede visible. La saga, el workflow y el TCC *sí* son +macros declarativas; solo la cadena de filtros sigue siendo un builder de tiempo de +ejecución. Declarativo donde colapsa código repetitivo, explícito donde el grafo es +el punto: ese equilibrio es todo el diseño. + +## Paso 13 — Verifica el crate + +Todo lo anterior compila y está probado. Desde la raíz del workspace: + +```bash +cargo build -p firefly-sample-lumen +cargo test -p firefly-sample-lumen # 42 unit + 12 HTTP = 54 tests +cargo test -p firefly-sample-lumen --features streaming # 57 tests (+3 streaming) +cargo clippy -p firefly-sample-lumen --all-targets -- -D warnings +``` + +Los tests HTTP conducen en proceso el router ensamblado por el framework: +`build_router()` arranca un `FireflyApplication` (montando automáticamente el +controlador, drenando los handlers/listener, estratificando la seguridad) y devuelve +su router público, ejercitado a través de `tower::ServiceExt::oneshot` sin ningún +socket enlazado. Demuestran que las rutas automontadas, los handlers de CQRS, la +validación (422), la ruta de no encontrado (404), el límite de autenticación (401), +la saga de transferencia (camino feliz + compensación), el workflow de cumplimiento, +la transferencia TCC y la convergencia de la proyección funcionan todos de extremo a +extremo — cada listado en prosa de este libro es una rebanada de ese crate en +ejecución. + +> **Tip** **Punto de control.** Los tres comandos tienen éxito: la compilación es +> limpia, la ejecución de tests por defecto informa de `54 passed`, la ejecución con +> streaming informa de `57 passed`, y clippy está en silencio bajo `-D warnings`. +> Eso es todo el crate declarativo, verificado. + +## Resumen + +- Una **macro declarativa** en Firefly se expande en tiempo de compilación a los + `impl`, routers y registros que de otro modo escribirías a mano — comprobados por + el compilador, nunca descubiertos por reflexión en tiempo de ejecución. +- Las macros que Lumen usa, archivo por archivo: `#[derive(Command/Query/Schema)]` y + `#[handlers]` (`commands.rs`); `#[derive(DomainEvent/AggregateRoot)]` + (`domain.rs`); `#[derive(Repository/Service)]` + `#[event_listener]` + (`ledger.rs`); `#[derive(Configuration)]` + `#[bean]` y `#[derive(Controller)]` + + `#[rest_controller]` (`web.rs`); `#[scheduled]` (`housekeeping.rs`); y el trío de + orquestación `#[firefly::saga]` / `#[firefly::workflow]` / `#[firefly::tcc]`. +- El conjunto de apoyo que Lumen no usa sigue siendo de primera clase: + `#[derive(Builder/Mapper/Validate)]`, los relacionales + `#[derive(Entity/SqlxRepository)]` / `#[firefly::repository]` / + `#[firefly::transactional]` (con `propagation` / `isolation` / `read_only` / + `timeout_ms` / `manager`, más `no_rollback_for` / `rollback_only_for`), los + decoradores de seguridad de método y de resiliencia, `#[cacheable]`, + `#[async_method]`, los listeners de eventos en proceso, `#[aspect]` y + `#[http_client]`. +- El código generado por las macros nombra los tipos de tiempo de ejecución a través + de la ruta oculta del contrato `__rt`, que es la razón por la que un servicio de un + solo crate compila todo lo que una macro expanda. +- El **drenaje del inventory** es lo que convierte un bean, listener, tarea o + controlador declarado en comportamiento cableado en el arranque — así Lumen no + escribe a mano ninguna llamada `register`, `subscribe`, `schedule` o `routes`. + +Este capítulo no añadió ninguna funcionalidad; releyó Lumen como un catálogo. Cada +macro reemplazó un trozo de cableado escrito a mano con una declaración junto al +código, y todo ello llegó a través de una dependencia y un glob de preludio — la +tesis que el crate en ejecución demuestra. + +## Ejercicios + +1. **Rastrea una macro de extremo a extremo.** Elige `#[derive(Query)]` sobre + `GetWallet`. Encuentra dónde se lee su `cache_ttl()` generado (la invalidación de + `QueryCache` en `web.rs`) y el test que lo afirma (`get_wallet_carries_cache_ttl` + en `commands.rs`). Cambia el TTL a `"5s"` y vuelve a ejecutar + `cargo test -p firefly-sample-lumen get_wallet_carries_cache_ttl`. +2. **Añade un verbo.** Añade un método de lectura estilo + `#[get("/wallets/:id/balance")]` al `impl` con `#[rest_controller]` en `web.rs` + (devuelve el balance como JSON, despachando `GetWallet` a través del bus) y + confirma que el controlador automontado lo sirve sin ningún otro cambio — sin + editar `routes()`, sin llamada de registro. +3. **Añade una tarea programada.** Escribe una segunda función + `#[scheduled(cron = "0 0 * * * *")]` en `housekeeping.rs` y afirma que aparece en + `scheduler.tasks()` junto a `ledger_heartbeat` — el framework drena el nuevo + `ScheduledRegistration` del inventory, así que no añades ninguna llamada de + registro. +4. **Lee el inventario en el informe de arranque.** Ejecuta Lumen y encuentra la + línea `:: cqrs handlers … | event listeners … | scheduled tasks … | controllers + … ::`. Cuenta cada uno frente a los beans que leíste en este capítulo, luego + añade el verbo del ejercicio 2 y mira cómo el conteo de rutas del controlador lo + sigue. +5. **Cuenta el cableado que no escribiste.** Por cada macro de la tabla del + [Paso 2](#step-2--the-macro-catalogue-mapped-to-lumen-files), nombra el auxiliar o + el `impl` que generó (`register_*`, `subscribe_*`, `schedule_*`, `routes`, + `EVENT_TYPE`, `AGGREGATE_TYPE`, el `impl` de `Message`). Esa lista es el código + repetitivo que la capa declarativa escribió por ti. + +## Adónde ir después + +- Compón el crate único de Lumen en un servicio multi-crate y estratificado en + **[Microservicios estratificados](./22-layered-microservices.md)** — donde el + sample `lumen-ledger` (con el caso de uso `#[firefly::transactional]` del Paso 10) + divide dominio, núcleo, web y modelos en crates separados. +- Revisa cómo el framework escanea y conecta los beans que este capítulo declaró en + el **[análisis profundo de inyección de dependencias](./04a-dependency-injection.md)**. +- Los apéndices son referencia: un **[Índice de módulos](./91-appendix-modules.md)** + de cada crate `firefly-*` y un **[Glosario](./92-glossary.md)** de los términos + usados a lo largo del libro. diff --git a/docs/book/src-es/22-layered-microservices.md b/docs/book/src-es/22-layered-microservices.md new file mode 100644 index 00000000..142753f4 --- /dev/null +++ b/docs/book/src-es/22-layered-microservices.md @@ -0,0 +1,974 @@ +# Microservicios por capas + +Hasta ahora, todas las muestras de Lumen han vivido en un *único* crate. Esa es la +forma adecuada mientras aprendes un subsistema a la vez, pero no es como se +construye realmente un servicio de core bancario en producción. Los servicios +reales —como los de la plataforma [firefly-oss](https://github.com/firefly-oss)— +se dividen en **módulos por capas**, cada uno una unidad compilada por separado +con exactamente una responsabilidad: el contrato público puede publicarse sin +arrastrar el código de persistencia, la lógica de negocio puede probarse de forma +unitaria sin la pila web, y un consumidor de un SDK externo solo incorpora los +DTOs y nada más. + +En este capítulo construyes esa forma. `lumen-ledger` (en +[`samples/lumen-ledger/`](https://github.com/fireflyframework/fireflyframework-rust/tree/main/samples/lumen-ledger)) +es un microservicio de monedero/libro mayor organizado como **cinco crates** —el +análogo en Rust de un proyecto Maven multimódulo— dispuesto al estilo Java con +**un tipo público por archivo** bajo una ruta de paquete `/v1`. Reutiliza +todas las ideas del framework que ya conociste (beans de DI, el repositorio +sqlx, transacciones, validación, problems RFC 9457, OpenAPI) y muestra cómo se +componen *a través de la frontera de un crate* solo mediante descubrimiento. + +Al terminar este capítulo, serás capaz de: + +- Disponer un servicio como cinco crates por capas con las flechas de dependencia + apuntando estrictamente hacia adentro, y saber qué estereotipo del framework + pertenece a cada capa. +- Declarar un `@Repository` al estilo de Spring Data sobre una `@Entity` real con + dos derives —sin factory, sin CRUD escrito a mano— construido a partir de un + **bean de datasource asíncrono**. +- Escribir un `@Service` que programa contra el trait `ReactiveCrudRepository` + del repositorio, ejecuta una **transferencia atómica** bajo + `#[transactional]`, y traduce un filtro a una `Specification` en tiempo de + ejecución. +- Cablear todo el grafo con una sola línea `firefly::link!` y protegerlo con + `assert_discovered`, para luego ejecutar y probar el servicio en proceso. +- Entregar un SDK tipado a los llamadores aguas abajo —escrito a mano contra los + DTOs compartidos, o generado a partir del documento OpenAPI en vivo. + +## Conceptos que conocerás + +Antes del primer crate, aquí están las ideas en las que se apoya este capítulo. +Cada una se reintroduce en contexto donde se usa por primera vez; esta es la +versión breve. + +> **Note** **Término clave — módulo por capas.** Un *módulo por capas* es un +> crate compilado por separado que posee exactamente una preocupación +> arquitectónica: el contrato público, el modelo de persistencia, la lógica de +> negocio, la superficie web o un cliente saliente. Dividir un servicio de esta +> forma es el equivalente en Rust de un proyecto Maven multimódulo: cada módulo +> compila, prueba y versiona por su cuenta, y las capas inferiores nunca importan +> las superiores. + +> **Note** **Término clave — estereotipo.** Un *estereotipo* es el rol que un +> bean desempeña en la aplicación: controlador, servicio, repositorio, +> componente, configuración. Firefly marca cada uno con su propio derive +> (`#[derive(Controller)]`, `#[derive(Service)]`, …) exactamente como Spring los +> marca con `@RestController`, `@Service`, `@Repository`, `@Component`, +> `@Configuration`. El framework clasifica cada bean descubierto por su +> estereotipo en el informe `/actuator/beans`. + +> **Note** **Término clave — descubrimiento en tiempo de enlazado.** Firefly +> descubre beans, controladores y esquemas en *tiempo de enlazado* usando el +> crate `inventory`: cada macro registra una entrada que el enlazador recopila en +> el binario final. La trampa es que un enlazador de Rust **elimina por dead-strip** +> cualquier crate que el binario nunca referencie —una dependencia en `Cargo.toml` +> por sí sola no es una referencia. La macro `firefly::link!` aporta esa +> referencia para que los registros de un crate de capa sobrevivan hasta el +> binario. + +## Paso 1 — Disponer los cinco crates + +La primera decisión son las fronteras de los módulos. `lumen-ledger` usa cinco, +uno por preocupación, nombrados según la convención de firefly-oss: + +| Crate | Contiene | Estereotipo que aporta | +|---|---|---| +| `firefly-sample-lumen-ledger-interfaces` | DTOs (`#[derive(Schema, Validate)]`) + el enum `WalletStatus` — el contrato público | — (datos puros) | +| `firefly-sample-lumen-ledger-models` | la `@Entity` `Wallet` + el `WalletRepository` de sqlx + el `@Configuration` del datasource | `@Entity`, `@Repository`, `@Bean` | +| `firefly-sample-lumen-ledger-core` | el `@Service`, el `@Mapper`, un `@Component` | `@Service`, `@Component` | +| `firefly-sample-lumen-ledger-web` | el `@RestController` + el binario `FireflyApplication` | `@RestController` | +| `firefly-sample-lumen-ledger-sdk` | un cliente saliente tipado sobre la API | — (una biblioteca cliente) | + +Cada crate fija un nombre de biblioteca corto para que el código se lea con +claridad a través de la frontera —`lumen_ledger_interfaces`, `lumen_ledger_models`, +`lumen_ledger_core`, `lumen_ledger_sdk`— mientras que el nombre del paquete sigue +siendo plenamente cualificado para la publicación. Por ejemplo, el `Cargo.toml` +de `-interfaces`: + +```toml +[package] +name = "firefly-sample-lumen-ledger-interfaces" + +[lib] +name = "lumen_ledger_interfaces" +path = "src/lib.rs" + +[dependencies] +firefly = { workspace = true } +serde = { workspace = true } +``` + +Las flechas de dependencia apuntan estrictamente **hacia adentro**: + +
+ +-interfacesDTOs · the public contract + +depends on +-models@Entity · @Repository · @Bean + +depends on +-core@Service · @Mapper · @Component + +depends on +-web@RestController · the binary +-sdktyped client + +→ -interfaces + +
Cinco crates compilados por separado. Las dependencias apuntan estrictamente hacia adentro: -web conoce a -core, que conoce a -models, que conoce a -interfaces — y el crate del contrato no conoce a nadie. -sdk depende solo de -interfaces, de modo que un llamador enlaza los DTOs sin el código de persistencia ni el web.
+
+ +Una capa inferior nunca depende de una superior. El crate `-web` conoce el +servicio `-core`; el servicio conoce el repositorio `-models`; el repositorio +conoce el contrato `-interfaces` —y el crate del contrato no conoce a nadie. El +`-sdk` depende solo de `-interfaces`, de modo que un llamador enlaza los DTOs sin +arrastrar jamás el código de persistencia ni el web. En concreto, `-models` +depende de `-interfaces`, `-core` depende de ambos, y `-web` depende de los tres: + +```toml +# firefly-sample-lumen-ledger-web/Cargo.toml +[dependencies] +firefly = { workspace = true, features = ["admin", "data-sqlx"] } +firefly-sample-lumen-ledger-interfaces = { path = "../interfaces" } +firefly-sample-lumen-ledger-models = { path = "../models" } +firefly-sample-lumen-ledger-core = { path = "../core" } +``` + +> **Note** **Término clave — un tipo por archivo.** Cada archivo hoja contiene +> exactamente un `struct` / `trait` / `enum` +> (`dtos/wallet/v1/wallet_response.rs` → `WalletResponse`), igual que la +> convención de una clase por archivo de Java. Los archivos `mod` intermedios +> (`dtos/wallet/v1.rs`) simplemente reexportan sus hojas, y el `lib.rs` de cada +> crate añade reexportaciones planas de conveniencia +> (`pub use services::wallet::v1::WalletService;`) para que un consumidor escriba +> `lumen_ledger_core::WalletService`, no la ruta completa. + +> **Tip** **Punto de control.** Puedes imaginar el árbol antes de escribir una +> línea: cinco directorios bajo `samples/lumen-ledger/`, cada uno con su propio +> `Cargo.toml`, y una ruta de paquete `src//v1/` dentro. Las flechas de +> arriba te dicen qué `Cargo.toml` puede listar cuál —si alguna vez encuentras un +> crate inferior importando uno superior, las capas están mal. + +## Paso 2 — Asignar los estereotipos a las capas + +Antes de cualquier código, fija en tu cabeza qué estereotipo del framework aporta +cada capa. Cada tipo de abajo es un **bean de DI** que el framework descubre +durante `container.scan()` —no hay raíz de composición que los ensamble a mano, +igual que no la había en [Inicio rápido](./02-quickstart.md). + +```text +@RestController (web) → #[rest_controller] + #[derive(Controller)] WalletController + │ autowires +@Service (core) → #[derive(Service)] + #[firefly(provides = "dyn WalletService")] + │ autowires +@Mapper (core) → #[derive(Component)] WalletMapper (DTO ↔ entity) +@Component (core) → #[derive(Component)] WalletNumberGenerator +@Repository (models) → #[derive(SqlxRepository)] WalletRepository (built from the Db @Bean) + │ over +@Entity (models) → #[derive(Entity)] Wallet (generates the SqlxEntity mapping) + │ from +@Bean (DataSource)(models) → #[bean] async fn data_source() -> Db +``` + +> **Note** **Término clave — autowiring entre crates.** El *autowiring* pide al +> contenedor un colaborador por tipo en lugar de construirlo tú mismo (el +> `@Autowired` de Spring). El descubrimiento es en tiempo de enlazado, no por +> crate, de modo que un campo `#[autowired]` en el controlador de `-web` se +> satisface con un bean `@Service` declarado en `-core`, que a su vez hace +> autowiring de un `@Repository` de `-models`. El cableado cruza las fronteras de +> los crates sin ceremonia adicional —una vez que los crates están enlazados +> (Paso 6), el grafo es un solo contenedor. + +El `@Service` programa contra el trait **`ReactiveCrudRepository`** del +repositorio (`save`, `find_by_id`, `delete_by_id`, `count`, … que devuelven +`Mono` / `Flux`) más las consultas derivadas de `#[firefly::repository]` — +`find_by_owner`, `find_by_status(.., Pageable)` (paginada) y `count_by_status`— +la misma superficie de Spring Data, generada a partir de los nombres de los +métodos. Conociste todas ellas en [Persistencia](./07-persistence.md); aquí +simplemente viven un crate más abajo. + +## Paso 3 — Declarar la entidad, al estilo de Spring Data + +Empieza por abajo —el crate `-models`. Un repositorio de Spring Data es una +*declaración*: tú escribes la interfaz, el framework aporta la implementación. +`lumen-ledger` hace lo mismo con dos derives, y el primero está sobre la entidad. + +> **Note** **Término clave — entidad.** Una *entidad* es la forma persistida de un +> objeto de dominio —una fila de una tabla. `#[derive(Entity)]` genera el mapeo +> `@Table` / `@Id` / `@Version` / `@Column` a partir de los campos del struct, la +> experiencia `@Entity` de JPA: las columnas escalares se mapean automáticamente, +> y los campos anotados se adhieren a los roles especiales (clave primaria, +> versión, marcas de tiempo de auditoría). + +Crea `models/src/entities/wallet/v1/wallet.rs`: + +```rust,ignore +use chrono::{DateTime, Utc}; +use lumen_ledger_interfaces::WalletStatus; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, firefly::Entity)] +#[firefly(table = "wallets")] +pub struct Wallet { + #[firefly(id)] + pub id: Uuid, + pub account_number: String, + pub owner: String, + pub balance: i64, // minor units (cents) + pub currency: String, // ISO-4217 code + // The typed enum maps via an explicit converter — the @Enumerated(STRING) boundary. + #[firefly(with(read = "WalletStatus::from_token", write = "WalletStatus::as_str"))] + pub status: WalletStatus, + #[firefly(version)] + pub version: i64, // @Version — bumped by the store on update + pub created_at: DateTime, // @CreatedDate, stamped on insert + pub updated_at: DateTime, // @LastModifiedDate, stamped on every write +} +``` + +Lo que acaba de ocurrir: el derive leyó el struct y produjo el mapeo de la tabla. +Las columnas escalares (`String`, `i64` / `i32`, `bool`, `f64`, `Uuid`, +`DateTime`) se mapean automáticamente, con `Uuid` y `DateTime` +persistidos como texto; `#[firefly(column = "name")]` renombraría una. El enum +`WalletStatus` *no* es escalar, así que lleva un convertidor explícito +`with(read = …, write = …)` —la dirección de lectura (`from_token`) y la de +escritura (`as_str`)— que es la frontera `@Enumerated(STRING)` de JPA hecha +explícita. Los campos `#[firefly(id)]`, `#[firefly(version)]` y los dos campos de +marca de tiempo se adhieren a los roles especiales: el almacén estampa la versión +y las marcas de tiempo por ti, de modo que el servicio nunca las toca. + +> **Note** **Término clave — bloqueo optimista.** El *bloqueo optimista* deja que +> dos lectores carguen la misma fila, y luego hace que el *segundo* escritor +> falle si el primero ya la cambió —detectado comparando la columna `@Version`. +> Ninguna fila se bloquea jamás para lectura; el conflicto se detecta en el +> momento de la escritura. Una escritura obsoleta aflora como la +> `OptimisticLockingFailureException` de Spring, que este servicio convierte en un +> `409`. + +## Paso 4 — Declarar el repositorio con un solo derive + +El repositorio es **una sola anotación** —`#[derive(SqlxRepository)]` sobre un +struct cuyo único campo es el repositorio reactivo del framework— más un bloque +opcional de *consultas derivadas*. + +> **Note** **Término clave — consulta derivada.** Una *consulta derivada* es un +> buscador cuyo SQL genera el framework a partir del *nombre* del método — +> `find_by_owner`, `count_by_status`, `find_by_status(.., Pageable)`— exactamente +> como el `findByOwner` de Spring Data. Tú escribes la firma y dejas el cuerpo sin +> implementar; la macro `#[firefly::repository]` lo reemplaza. + +Crea `models/src/repositories/wallet/v1/wallet_repository.rs`: + +```rust,ignore +use firefly::data::{DataError, Pageable}; +use firefly::data_sqlx::SqlxReactiveRepository; +use uuid::Uuid; + +use crate::entities::wallet::v1::Wallet; + +#[derive(firefly::SqlxRepository)] +pub struct WalletRepository { + repo: SqlxReactiveRepository, +} + +#[firefly::repository] // the derived queries, on top +impl WalletRepository { + /// `SELECT … WHERE owner = ?` + pub async fn find_by_owner(&self, owner: &str) -> Result, DataError> { + unimplemented!() + } + + /// `SELECT COUNT(*) WHERE status = ?` + pub async fn count_by_status(&self, status: &str) -> Result { + unimplemented!() + } + + /// Paged `SELECT … WHERE status = ?` — ORDER BY / LIMIT / OFFSET come from + /// the trailing `Pageable`. + pub async fn find_by_status( + &self, + status: &str, + page: Pageable, + ) -> Result, DataError> { + unimplemented!() + } +} +``` + +Lo que acaba de ocurrir, y por qué importa: ese único derive hace tres cosas a la +vez. **Registra `WalletRepository` como un bean `@Repository`** (descubierto por +el scan, clasificado correctamente en `/actuator/beans`); **construye el +`SqlxReactiveRepository` interno a partir del datasource `Db` cableado por +autowiring** —enlazando el bloqueo optimista `@Version` de la entidad y la +auditoría `@CreatedDate` / `@LastModifiedDate` del mapeo `SqlxEntity` que emitió +el derive de la entidad—; e **implementa `ReactiveCrudRepository` *y* +`ReactiveSpecificationRepository` por delegación**. Ese último punto es lo que +permite que el servicio del Paso 5 llame a `save`, `find_by_id`, `delete_by_id` +y `find_by_spec` sin que escribas ninguno de ellos. + +El bloque `#[firefly::repository]` añade las consultas derivadas encima: el +cuerpo de cada método es `unimplemented!()` en tu fuente, y la macro lo reemplaza +con SQL generado a partir del nombre del método. Sin factory `#[bean]`, sin CRUD +escrito a mano —el único estado del struct es el repositorio interno, y el derive +lo construye. + +> **Tip** **Punto de control.** Dos derives, cero cuerpos de CRUD, y el único +> campo es `repo: SqlxReactiveRepository`. Si te encuentras +> escribiendo un `save` o un `SELECT` a mano aquí, da un paso atrás —el derive ya +> aporta la superficie canónica. + +### La clave es genérica, como en Java + +El `CrudRepository` de Spring Data deja `ID` sin acotar. El repositorio +sqlx de Firefly acepta **cualquier clave `Serialize`** a través del trait +`SqlKey` (implementado de forma general), de modo que el repositorio del monedero +se indexa directamente por `Uuid`: + +```rust,ignore +pub struct WalletRepository { + repo: SqlxReactiveRepository, +} +``` + +`Uuid`, `i64`, `String`, un enum o un struct de clave compuesta funcionan todos +—la clave se enlaza en su forma serde-JSON contra la columna id. Nada en el +repositorio está cableado de forma rígida a UUIDs. + +## Paso 5 — Abrir el datasource como un bean asíncrono + +El repositorio es un bean *síncrono*: construirlo es solo envolver el manejador +`Db`. Lo que realmente realiza E/S al arrancar es el **datasource** —el +`DataSource` autoconfigurado de Spring Boot. En `lumen-ledger` es un **`@Bean` +asíncrono** en el `@Configuration` de `-models`. + +> **Note** **Término clave — bean asíncrono.** Un *bean asíncrono* es un bean +> cuya factory es una `async fn` —debe hacer `await` de trabajo (abrir un pool, +> conectar a un broker) antes de que el bean exista. El framework aparca tal +> factory durante el `container.scan()` síncrono y le hace `await` durante +> `Container::init_async_beans()`, ejecutado por el bootstrap justo después del +> scan. Este es el patrón de Spring Boot de un `@Bean` que realiza E/S en el +> momento del refresco del contexto, salvo que la E/S se espera con `await` en +> lugar de bloquear un hilo. + +Crea `models/src/config/wallet_persistence_config.rs`: + +```rust,ignore +use firefly::data_sqlx::Db; +use firefly::prelude::*; + +#[derive(Configuration, Default)] +pub struct WalletPersistenceConfig; + +#[firefly::bean] +impl WalletPersistenceConfig { + /// The `Db` datasource bean — an async factory that opens the pool and + /// applies the schema with `await`. + #[bean] + async fn data_source(&self) -> Db { + connect_and_migrate().await // open pool + apply schema + } +} +``` + +Lo que acaba de ocurrir: el `#[bean] async fn data_source` se aparca durante el +scan, y luego se le hace `await` durante `init_async_beans()`. Como el datasource +está *listo* antes de que se resuelva cualquier bean síncrono, el repositorio +`#[derive(SqlxRepository)]` (un bean síncrono que hace autowiring de `Db`) +encuentra un pool vivo cuando el framework lo construye. Un error de construcción +aquí aborta el arranque —fail-fast, aflorado a través de +`Container::init_async_beans` como un error `BeanCreation`. + +Por defecto, `connect_and_migrate()` abre una **base de datos SQLite en memoria**, +de modo que la muestra se ejecuta y se prueba sin servidor externo. Establece +`DATABASE_URL=postgres://…` y apuntará a PostgreSQL real en su lugar —la única +dependencia de entorno de toda la muestra, y es opcional. + +> **Design note.** ¿Por qué el servicio posee su gestor de transacciones (Paso 7) +> pero el datasource vive aquí? Porque el registro de gestores de transacciones +> globales al proceso es *gana-el-primero*, y la batería de pruebas de esta +> muestra arranca una base de datos en memoria aislada **por prueba**. Un único +> gestor global las contaminaría entre sí. El bean del datasource se puede +> compartir sin problema —cada consumidor resuelve el mismo `Db`— pero la +> frontera transaccional se enlaza a un gestor por instancia para que cada prueba +> siga siendo hermética. Un servicio de producción con un único datasource podría +> igualmente registrar un gestor al arrancar y usar un `#[firefly::transactional]` +> a secas. + +> **Tip** **Punto de control.** El crate `-models` tiene ahora tres archivos de +> sustancia: la entidad, el repositorio y la configuración. `cargo test -p +> firefly-sample-lumen-ledger-models` ejercita el repositorio directamente contra +> una base de datos en memoria aislada —incluidas las consultas derivadas y un +> conflicto real de bloqueo optimista `@Version` (una escritura obsoleta +> detectada con `firefly::data_sqlx::is_optimistic_lock`). + +## Paso 6 — Escribir el servicio contra el trait del repositorio + +Sube a `-core`. El `@Service` es la capa de negocio: hace autowiring del +repositorio, el mapper y el generador de números, y programa contra la superficie +del *trait* del repositorio —nunca su SQL concreto. + +El servicio se publica como un **puerto** para que el controlador dependa de una +interfaz, no de un struct: + +```rust,ignore +use std::sync::Arc; + +use firefly::prelude::*; +use firefly::data_sqlx::Db; + +#[derive(Service)] +#[firefly(provides = "dyn WalletService")] +pub struct WalletServiceImpl { + #[autowired] repository: Arc, + #[autowired] mapper: Arc, + #[autowired] numbers: Arc, + #[autowired] db: Arc, // for the service's own transaction manager +} +``` + +> **Note** **Término clave — puerto provisto.** `#[firefly(provides = "dyn WalletService")]` +> registra la implementación bajo el tipo de *objeto trait*, de modo que +> cualquiera que haga autowiring de `Arc` (el controlador, una +> prueba) recibe este bean. El trait es el puerto publicado; el struct es un +> adaptador oculto —el "programa contra una interfaz, inyecta la implementación" +> de Spring. + +Las rutas de lectura simples simplemente delegan en el trait +`ReactiveCrudRepository` del repositorio y mapean el resultado a través del +`@Mapper`: + +```rust,ignore +async fn get(&self, id: Uuid) -> Result { + let wallet = self + .repository + .find_by_id(id) + .await + .map_err(|e| ServiceError::Backend(e.to_string()))? + .ok_or(ServiceError::NotFound)?; + Ok(self.mapper.to_response(&wallet)) +} + +async fn list_by_owner(&self, owner: &str) -> Result, ServiceError> { + let wallets = self + .repository + .find_by_owner(owner) // the derived query + .await + .map_err(|e| ServiceError::Backend(e.to_string()))?; + Ok(wallets.iter().map(|w| self.mapper.to_response(w)).collect()) +} +``` + +Lo que acaba de ocurrir: `find_by_id` proviene del trait `ReactiveCrudRepository` +que implementó el derive; `find_by_owner` es la consulta derivada del bloque +`#[firefly::repository]`. El servicio nunca ve SQL —ve un repositorio que ya habla +su dominio. + +> **Note** **Término clave — mapper.** Un *mapper* traduce entre capas —aquí la +> entidad `Wallet` de `-models` y el DTO `WalletResponse` de `-interfaces`. Como +> los dos tipos viven en crates *diferentes*, la regla del huérfano de Rust +> prohíbe `impl From for WalletResponse` en `-core`. Por eso `WalletMapper` +> es un bean `#[derive(Component)]` escrito a mano con un método +> `to_response(&self, &Wallet) -> WalletResponse` —exactamente la forma que genera +> el `@Mapper` de MapStruct. + +### Filtrar con una Specification en tiempo de ejecución + +El caso de uso `search` muestra la `Specification` del framework —el análogo del +`JpaSpecificationExecutor` de Spring Data. El servicio convierte cada campo de +filtro *presente* en un predicado combinado con AND, y luego ejecuta la +specification compuesta: + +```rust,ignore +use firefly::data::{Op, Predicate, ReactiveSpecificationRepository, Specification}; + +async fn search(&self, filter: WalletFilter) -> Result, ServiceError> { + // At least one criterion is required — a no-filter search would be an + // unscoped list-every-wallet enumeration. + if filter.owner.is_none() + && filter.currency.is_none() + && filter.status.is_none() + && filter.min_balance.is_none() + && filter.max_balance.is_none() + { + return Err(ServiceError::Validation("provide at least one filter criterion".into())); + } + + let mut spec = Specification::all(); + if let Some(owner) = filter.owner { + spec = spec.and(Specification::eq("owner", owner)); + } + if let Some(min) = filter.min_balance { + spec = spec.and(Specification::pred(Predicate::new("balance", Op::Gte, min))); + } + // …currency, status, max_balance the same way… + + let wallets = self + .repository + .find_by_spec(spec) // from ReactiveSpecificationRepository + .collect_list() + .block() + .await + .map_err(|e| ServiceError::Backend(e.to_string()))? + .unwrap_or_default(); + Ok(wallets.iter().map(|w| self.mapper.to_response(w)).collect()) +} +``` + +Lo que acaba de ocurrir: `find_by_spec` proviene de +`ReactiveSpecificationRepository` (el *otro* trait que implementó el derive +`SqlxRepository`). Devuelve un `Flux`, así que `.collect_list().block().await` lo +recopila. `block()` devuelve `Result>, _>`, de modo que +`.unwrap_or_default()` convierte el `None` de "sin filas" en un `Vec` vacío. El +framework compila la `Specification` a un `WHERE` consciente del dialecto, de modo +que el mismo código de servicio se ejecuta sin cambios sobre SQLite o PostgreSQL. + +### La transferencia atómica, bajo una sola transacción + +La transferencia es el corazón de un libro mayor: debitar el origen y abonar el +destino, ambos o ninguno. Eso exige una transacción. + +> **Note** **Término clave — frontera transaccional.** `#[firefly::transactional]` +> envuelve un método de modo que toda escritura dentro de él se confirma junta o +> revierte junta —el `@Transactional` de Spring. El argumento +> `manager = "self.tx_manager()"` enlaza la frontera a un gestor que posee el +> *servicio* (evaluado por llamada) en lugar del registro global al proceso. El +> atributo vive en un método inherente (`transfer_tx`), porque un método +> `async-trait` no puede llevarlo de forma limpia; el método del trait +> simplemente delega. + +```rust,ignore +use firefly::data_sqlx::SqlxTransactionManager; +use firefly::transactional::TransactionManager; + +impl WalletServiceImpl { + fn tx_manager(&self) -> Arc { + Arc::new(SqlxTransactionManager::new((*self.db).clone())) + } + + #[firefly::transactional(manager = "self.tx_manager()")] + async fn transfer_tx(&self, from: Uuid, to: Uuid, amount: i64) + -> Result + { + if amount <= 0 { return Err(ServiceError::Validation("transfer amount must be positive".into())); } + if from == to { return Err(ServiceError::Validation("cannot transfer to the same wallet".into())); } + + let mut source = self.load_active(from).await?; // 404 if absent, 422 if not active + let mut dest = self.load_active(to).await?; + if source.currency != dest.currency { + return Err(ServiceError::Validation("currency mismatch".into())); + } + if source.balance < amount { + return Err(ServiceError::Validation("insufficient funds".into())); + } + + // Every precondition is checked BEFORE the source is debited, so a + // rejected transfer moves no money. If the credit fails after the debit, + // the transaction rolls the debit back. + source.balance = source.balance.checked_sub(amount) + .ok_or_else(|| ServiceError::Validation("balance underflow".into()))?; + let saved_source = self.persist(source).await?; + dest.balance = dest.balance.checked_add(amount) + .ok_or_else(|| ServiceError::Validation("balance overflow".into()))?; + self.persist(dest).await?; + Ok(saved_source) // the updated source + } +} +``` + +Lo que acaba de ocurrir, y por qué importa: `transfer_tx` se ejecuta dentro de una +única transacción enlazada a `self.tx_manager()`. Cada guarda (importe positivo, +monederos activos distintos, moneda coincidente, fondos suficientes) se dispara +*antes* de la primera escritura, de modo que una transferencia rechazada nunca +toca un saldo. La aritmética es `checked_*`, así que un desbordamiento del libro +mayor es un error de dominio, no un wrap silencioso. Y si el abono fallara alguna +vez tras el débito, la frontera revierte el débito —la garantía de ambos-o-ninguno +de la que vive un libro mayor. + +> **Note** Como `#[transactional]` exige que el tipo de error sea +> `From`, `ServiceError` implementa esa conversión +> —un fallo de la infraestructura transaccional (begin / commit / rollback) aflora +> como `ServiceError::Backend`. Los argumentos `no_rollback_for` / +> `rollback_only_for` de `#[transactional]` (no mostrados aquí) te permiten ajustar +> qué variantes de error desencadenan un rollback; por defecto se revierte ante +> cualquier `Err`. + +El ayudante `persist` centraliza el guardar-y-mapear, y mapea una escritura +`@Version` obsoleta a un `Conflict`: + +```rust,ignore +async fn persist(&self, wallet: Wallet) -> Result { + let saved = self + .repository + .save(wallet) + .await + .map_err(|e| { + if is_optimistic_lock(&e) { + ServiceError::Conflict("wallet was modified concurrently; retry".into()) + } else { + ServiceError::Backend(e.to_string()) + } + })? + .ok_or_else(|| ServiceError::Backend("save returned no row".into()))?; + Ok(self.mapper.to_response(&saved)) +} +``` + +`deposit` y `withdraw` son lecturas-modificación-escrituras simples que se apoyan +en el mismo par `load_active` + `persist`; su seguridad ante la concurrencia +proviene del bloqueo optimista `@Version` del repositorio (una escritura obsoleta +→ `409`), no de una transacción. + +> **Tip** **Punto de control.** El crate `-core` contiene ahora el servicio (con +> su trait, implementación y `ServiceError`), el mapper y el generador de números +> —cada uno un bean de DI, ninguno construido a mano. El servicio compila contra +> `-models` y `-interfaces` pero no sabe nada de `-web`. + +## Paso 7 — Cablear los crates con `firefly::link!` + +Ahora el binario. El crate `-web` contiene el `@RestController` (Paso 8) y el +arranque `FireflyApplication` de una línea —pero una dependencia de Cargo en los +crates de capa **no basta**. Como el descubrimiento es en tiempo de enlazado, el +enlazador eliminará por dead-strip los registros de bean / controlador / esquema +de un crate de capa a menos que el binario realmente *referencie* ese crate. La +macro `firefly::link!` es esa referencia. + +Crea `web/src/main.rs`: + +```rust,ignore +// LINK-TIME WIRING — DO NOT REMOVE. Force-links each layer crate so its beans, +// controllers, and schemas survive dead-code elimination into the binary. +firefly::link!( + lumen_ledger_core, + lumen_ledger_models, + lumen_ledger_interfaces +); + +mod controllers; + +#[tokio::main] +async fn main() -> Result<(), firefly::BoxError> { + firefly::FireflyApplication::new("lumen-ledger") + .version(firefly::VERSION) + .run() + .await +} +``` + +Lo que acaba de ocurrir: `firefly::link!(a, b, c)` se expande a +`extern crate a as _;` por cada crate, que es exactamente la referencia que el +enlazador necesita para conservar los registros `inventory` de ese crate. Sin +ella obtienes el clásico síntoma de "6 de 16 beans" —el binario compila, enlaza, +se ejecuta y descarta silenciosamente la mitad de sus beans. El propio crate +`-web` está referenciado (él *es* el binario, y declara `mod controllers`), así +que no aparece en la lista de `link!`; las tres capas de biblioteca sí. + +Observa que `main` en sí mismo es la misma única línea que escribiste en +[Inicio rápido](./02-quickstart.md) —`FireflyApplication::new(name).run().await`— +solo que con `.version(firefly::VERSION)` establecido para que `/actuator/info` +informe de la versión del framework. Un servicio por capas necesita exactamente +**una** línea extra de cableado (`link!`); todo lo demás se descubre. + +Para convertir un crate `link!` olvidado de un bug silencioso en un fallo ruidoso, +protege el arranque con `assert_discovered`. Lo llamas justo después de que +`bootstrap()` retorne (la costura de prueba de Inicio rápido), usando el +`Bootstrapped::container` devuelto: + +```rust,ignore +let app = firefly::FireflyApplication::new("lumen-ledger") + .bootstrap() + .await + .expect("bootstrap"); + +// At least 8 beans (repository, service, mapper, component, config, …) and at +// least 1 controller were discovered — across all three layer crates. +firefly::assert_discovered(&app.container, 8, 1); +``` + +`assert_discovered(&container, min_beans, min_controllers)` entra en pánico al +arrancar si el número de beans o controladores descubiertos cae por debajo del +suelo que afirmas —la comprobación más útil de un servicio por capas. + +> **Tip** **Punto de control.** `cargo run -p firefly-sample-lumen-ledger-web` +> arranca en `:8080` (público) con la superficie de gestión en `:8081`, y el +> informe de arranque lista los beans extraídos de los cuatro crates de código. Si +> el recuento de beans parece demasiado pequeño, falta un crate en +> `firefly::link!`. + +## Paso 8 — La superficie web de calidad de producción + +El `@RestController` es la última capa, y es más que CRUD —lleva la disciplina de +errores y validación que se espera de un servicio Spring Boot, cada fallo +renderizado como `application/problem+json` de RFC 9457. Conociste cada una de +estas herramientas en [Tu primera API HTTP](./06-first-http-api.md) y +[OpenAPI](./06a-openapi.md); aquí se componen sobre el servicio por capas. + +El controlador es un bean `#[derive(Controller)]` que hace autowiring del puerto +`dyn WalletService` de `-core` y es auto-montado por `#[rest_controller]`: + +```rust,ignore +use std::sync::Arc; +use firefly::prelude::*; +use firefly::web::{PageRequest, Path, Query, Valid, WebError, WebResult}; +use lumen_ledger_core::{ServiceError, WalletService}; + +#[derive(Clone, Controller)] +pub struct WalletController { + #[autowired] + service: Arc, +} + +#[rest_controller(path = "/api/v1", tag = "Wallets")] +impl WalletController { + #[post("/wallets", summary = "Open a wallet", status = 201, + header("Idempotency-Key", description = "optional client-supplied key to make retries safe"))] + async fn open( + State(api): State, + headers: axum::http::HeaderMap, + Valid(body): Valid, // 422 on a blank owner / bad currency + ) -> WebResult<(StatusCode, Json)> { + let view = api.service.create(body).await.map_err(service_to_web)?; + Ok((StatusCode::CREATED, Json(view))) + } + + #[get("/wallets/:id", summary = "Fetch a wallet")] + async fn get( + State(api): State, + Path(id): Path, // 400 on a non-UUID id + ) -> WebResult> { + let view = api.service.get(id).await.map_err(service_to_web)?; + Ok(Json(view)) + } + + #[get("/wallets/page", summary = "List wallets by status (paged)")] + async fn list_paged( + State(api): State, + Query(query): Query, + PageRequest(pageable): PageRequest, // binds page/size/sort + ) -> WebResult>> { + let page = api.service.list_by_status(query.status, pageable).await.map_err(service_to_web)?; + Ok(Json(page)) + } + // …deposit, withdraw, transfer, search, set_status, delete… +} +``` + +Lo que acaba de ocurrir, preocupación por preocupación: + +| Preocupación | Cómo se gestiona | +|---|---| +| Bean validation en el borde | `Valid` / `Valid` / `Valid` — un owner en blanco, una moneda no ISO (`#[validate(pattern = "[A-Z]{3}")]`), o un importe no positivo (`#[validate(range(min = 1))]`) es un **422** antes de que el servicio se ejecute | +| Path / query malformado | los extractores `firefly::web::{Path, Query}` del framework — un id que no es UUID o un `?owner=` ausente es un problem **400**, no el texto plano por defecto de axum | +| Transferencia atómica | `POST /api/v1/wallets/:id/transfer` debita el origen y abona el destino dentro de **una transacción** (Paso 6). Una transferencia rechazada no mueve dinero | +| Conflicto de bloqueo optimista | una escritura `@Version` obsoleta → `ServiceError::Conflict` → **409** | +| Monedero desconocido | `ServiceError::NotFound` → **404** | +| Ciclo de vida de estado | `PATCH /api/v1/wallets/:id/status` transiciona `active → frozen → closed`; un monedero congelado rechaza un débito con **422** | +| Eliminación | `DELETE /api/v1/wallets/:id` → **204**, delegando en `delete_by_id` | +| Paginación | `GET /api/v1/wallets/page?status=active&page=1&size=20&sort=balance,desc` devuelve un `Page` al estilo de Spring Data (`content` + `totalElements`). El resolver `PageRequest` enlaza `page` / `size` / `sort` en un `Pageable` (exactamente como un parámetro `Pageable` de Spring), que el servicio pasa a la consulta derivada paginada `find_by_status` | +| Filtrado | `GET /api/v1/wallets/search?owner=¤cy=&status=&minBalance=&maxBalance=` enlaza un DTO de query `WalletFilter` (cada campo un parámetro de query de OpenAPI); el servicio convierte los criterios presentes en una `Specification` que el repositorio compila a un `WHERE` consciente del dialecto. Se requiere al menos un criterio | + +> **Note** **Término clave — regla del huérfano.** La *regla del huérfano* de Rust +> prohíbe implementar un trait para un tipo cuando *ambos* son foráneos al crate +> actual. `WebError` (de `firefly`) y `ServiceError` (de `-core`) son ambos +> foráneos a `-web`, así que `impl From for WebError` es ilegal aquí. +> El controlador los mapea con una pequeña función libre en su lugar —la misma +> restricción que hizo del `@Mapper` un bean en lugar de un `impl From`: + +```rust,ignore +fn service_to_web(err: ServiceError) -> WebError { + match err { + ServiceError::NotFound => WebError::from(FireflyError::not_found("wallet not found")), + ServiceError::Validation(d) => WebError::from(FireflyError::validation(d)), + ServiceError::Conflict(d) => WebError::from(FireflyError::conflict(d)), + ServiceError::Backend(d) => WebError::from(FireflyError::internal(d)), + } +} +``` + +> **Tip** **Punto de control.** Cada handler del controlador devuelve +> `WebResult`, y cada fallo de dominio fluye a través de `service_to_web` hacia +> un estado de problem preciso. Abre `http://localhost:8081/swagger-ui` tras +> `cargo run` para ver toda la superficie del monedero —cuerpos, parámetros de +> query y la cabecera `Idempotency-Key` declarada— renderizada a partir del +> inventario. La documentación OpenAPI está en el puerto de **gestión**, junto a +> actuator y admin, nunca en la API pública. + +## Paso 9 — Entregar a los llamadores un SDK tipado + +El quinto crate, `-sdk`, es un cliente saliente tipado sobre +`firefly_client::RestClient`, que reutiliza los DTOs de `-interfaces` para que un +llamador nunca redeclare el contrato. Como `-sdk` depende solo de `-interfaces`, +importarlo arrastra los DTOs y nada más —sin persistencia, sin pila web. + +```rust,ignore +use firefly_client::{ClientError, RestBuilder, RestClient, NO_BODY}; +use http::Method; +use lumen_ledger_interfaces::{AmountRequest, CreateWalletRequest, WalletResponse}; + +pub struct WalletClient { + inner: RestClient, +} + +impl WalletClient { + pub fn new(base_url: impl AsRef) -> Self { + Self { inner: RestBuilder::new(base_url).build() } + } + + /// `POST /api/v1/wallets` — open a wallet. + pub async fn create_wallet( + &self, + request: &CreateWalletRequest, + ) -> Result { + self.inner + .request::<_, WalletResponse>(Method::POST, "/api/v1/wallets", Some(request)) + .await + } + + /// `GET /api/v1/wallets/{id}` — fetch one wallet. + pub async fn get_wallet( + &self, + id: impl std::fmt::Display, + ) -> Result { + let path = format!("/api/v1/wallets/{id}"); + self.inner + .request::<(), WalletResponse>(Method::GET, &path, NO_BODY) + .await + } + // …list_wallets, deposit, withdraw… +} +``` + +Lo que acaba de ocurrir: cada método se asigna a un endpoint y (de)serializa los +DTOs compartidos, de modo que el llamador programa contra *los mismos tipos* que +el servidor impone —una deriva del contrato no compila. Cada método devuelve +`Result`; un cuerpo RFC 9457 que no sea 2xx se decodifica en un +`FireflyError` tipado accesible vía `ClientError::as_firefly`. El constructor +`with_client` envuelve un `RestClient` ya configurado (cabeceras personalizadas, +reintentos, timeouts, un token bearer), la vía trae-tu-propio-cliente. La +superficie completa de `RestClient` se cubre en +[Clientes HTTP](./13-http-clients.md). + +### Generar el SDK en su lugar + +También puedes **generar** un cliente equivalente a partir del documento OpenAPI +del servicio en ejecución, para que nunca vuelvas a escribir un método a mano: + +```bash +firefly openapi-client --spec wallet-openapi.json -o src/generated.rs --client-name WalletClient +``` + +`firefly openapi-client` recorre la spec y emite un cliente autocontenido —un +`struct` / `enum` de modelo por cada entrada de `components.schemas` y una +`async fn` por operación, con parámetros de path / query tipados y cuerpos JSON. +El archivo generado va encabezado con +`// Code generated by \`firefly openapi-client\`. DO NOT EDIT.`. El catálogo +completo del generador está en [La CLI](./19-cli.md). + +> **Tip** **Punto de control.** `cargo test -p firefly-sample-lumen-ledger-sdk` +> compila el cliente y ejecuta sus comprobaciones de contrato —el resultado +> tipado de cada método encaja con un DTO compartido de `-interfaces`. (La propia +> ida y vuelta por red la ejercita la prueba de integración de `-web` en el +> Paso 10.) + +## Paso 10 — Ejecutar y probar todo el grafo + +Con los cinco crates en su sitio, ejecuta y prueba el servicio: + +```bash +cargo run -p firefly-sample-lumen-ledger-web # boots on :8080, management on :8081 +cargo test -p firefly-sample-lumen-ledger-web # in-process cross-crate round-trip +``` + +La prueba de integración arranca todo el grafo en proceso con `bootstrap()` (sin +socket enlazado), afirma el descubrimiento con `assert_discovered(&app.container, +8, 1)`, y conduce toda la superficie pública a través del `api_router` devuelto +—crear / obtener / depositar / retirar, la consulta de estado paginada, la +specification de búsqueda, la transición de estado, eliminar, la transferencia +atómica (incluida cada vía de rechazo), y cada vía de problem (404, los fallos de +validación 422, el 400 de path-malformado / query-ausente). También comprueba el +router de **gestión**: que el documento OpenAPI se sirve ahí (y *ausente* de la +API pública), y que una ruta de gestión desconocida responde un problem 404 de +RFC 9457 —el mismo contrato que la API pública. + +El propósito de la prueba es la arquitectura, no solo las aserciones: demuestra +que cada capa se cablea junta solo mediante DI. El `@RestController` en `-web` +alcanza el `@Service` en `-core`, que alcanza el `@Repository` en `-models`, que +alcanza el datasource `@Bean` —todo descubierto, nada ensamblado a mano, a través +de cuatro fronteras de crate. + +> **Tip** **Punto de control.** Ambos comandos tienen éxito. `cargo run` imprime +> un informe de arranque cuya línea `:: beans ::` se extrae de cada crate de +> código, y la batería de pruebas está en verde —el servicio por capas se comporta +> como una sola aplicación. + +## Resumen — lo que construiste + +Convertiste una muestra de un solo crate en un microservicio por capas de cinco +crates sin añadir una raíz de composición: + +| Capa | Crate | Lo que aporta | +|---|---|---| +| contrato | `…-interfaces` | DTOs + el enum `WalletStatus` — `#[derive(Schema, Validate)]`, no depende de nadie | +| persistencia | `…-models` | la `@Entity` `Wallet`, el `@Repository` de dos derives, el `@Bean` del datasource asíncrono | +| negocio | `…-core` | el puerto `@Service`, el `@Mapper`, un `@Component`; la transferencia atómica `#[transactional]` | +| web | `…-web` | el `@RestController`, el cableado `firefly::link!`, el `FireflyApplication` de una línea | +| cliente | `…-sdk` | un `RestClient` tipado sobre los DTOs compartidos (o generado a partir de OpenAPI) | + +Ahora también sabes: + +- Por qué las flechas de dependencia deben apuntar estrictamente hacia adentro, y + cómo cada capa aporta exactamente los estereotipos que le pertenecen. +- Que un repositorio al estilo de Spring Data son dos derives —`#[derive(Entity)]` + y `#[derive(SqlxRepository)]`— construidos a partir de un bean de datasource + asíncrono, dándote `ReactiveCrudRepository` + `ReactiveSpecificationRepository` + + consultas derivadas gratis. +- Que `firefly::link!` es la *única* línea de cableado que necesita un servicio por + capas, que existe porque el descubrimiento es en tiempo de enlazado, y que + `assert_discovered` convierte un crate olvidado en un fallo de arranque ruidoso. +- Cómo una transferencia atómica compone `#[transactional]`, el bloqueo optimista + y un diseño de precondiciones-primero para que una transferencia rechazada no + mueva dinero. +- Cómo entregar a los llamadores un SDK tipado que reutiliza el crate del contrato + —o generar uno a partir del documento OpenAPI en vivo. + +## Ejercicios + +1. **Provoca el dead-strip.** Comenta un crate en la línea `firefly::link!` (por + ejemplo `lumen_ledger_models`), y luego `cargo run -p + firefly-sample-lumen-ledger-web`. Observa cómo `assert_discovered` falla al + arrancar con el pánico "discovered N beans but expected at least 8" —ese es + exactamente el bug que `link!` previene. Restaura la línea. +2. **Traza una petición a través de cuatro crates.** Con el servicio en ejecución, + `curl -X POST localhost:8080/api/v1/wallets -H 'content-type: application/json' + -d '{"owner":"ada","currency":"EUR","openingBalance":1000}'`. Nombra, en orden, + qué crate gestiona cada salto: el controlador (`-web`), el servicio (`-core`), + el mapper (`-core`), el repositorio (`-models`), el datasource (`-models`). +3. **Rompe la afirmación de atomicidad de la transferencia.** Lee `transfer_tx` y + confirma que las comprobaciones de moneda / fondos / actividad se ejecutan todas + *antes* del primer `persist`. Luego haz `curl` de una transferencia con + `amount` mayor que el saldo del origen y verifica que el saldo del origen queda + inalterado después (`GET` de él) —una transferencia rechazada no mueve dinero. +4. **Añade una consulta derivada.** Añade `find_by_currency(&self, currency: &str) + -> Result, DataError>` al bloque `#[firefly::repository]` (cuerpo + `unimplemented!()`), exponla a través del servicio y una ruta de controlador, y + confirma que funciona —sin escribir nada de SQL. +5. **Genera el SDK.** Ejecuta el servicio, obtén la spec con `curl + localhost:8081/v3/api-docs > wallet-openapi.json`, y luego `firefly + openapi-client --spec wallet-openapi.json -o /tmp/generated.rs --client-name + WalletClient`. Compara los métodos generados con el cliente `-sdk` escrito a + mano. + +## Adónde ir después + +- Conduce una aplicación plenamente cableada en proceso —la costura `bootstrap()`, + el `api_router` / `management_router`, y las pruebas de ida y vuelta entre + crates como la del Paso 10— en **[Pruebas](./18-testing.md)**. +- Revisa la maquinaria de persistencia que este capítulo dispuso por capas + (entidades, consultas derivadas, specifications, bloqueo optimista) en + **[Persistencia y repositorios reactivos](./07-persistence.md)**. +- Lleva el servicio por capas a producción —PostgreSQL real vía `DATABASE_URL`, + contenedores y la superficie de gestión— en + **[Producción y despliegue](./20-production.md)**. diff --git a/docs/book/src-es/91-appendix-modules.md b/docs/book/src-es/91-appendix-modules.md new file mode 100644 index 00000000..518b17bf --- /dev/null +++ b/docs/book/src-es/91-appendix-modules.md @@ -0,0 +1,102 @@ +# Apéndice: Índice de módulos + +El workspace de Firefly Rust incluye **86 miembros**: 74 crates del framework bajo +`crates/`, además de la suite de integración entre crates y 11 entradas de ejemplo: el +ejemplo recurrente del libro, [`lumen`](https://github.com/fireflyframework/fireflyframework-rust/tree/main/samples/lumen), +junto a `orders`, `reactive-banking`, `macro-quickstart`, `linkspike-core`, +`linkspike-web` y el ejemplo multimódulo `lumen-ledger`. Cada crate +incluye su propio `README.md` con su superficie pública completa, su justificación de diseño y un +inicio rápido ejecutable. + +El catálogo canónico y siempre actualizado es +[`MODULES.md`](https://github.com/fireflyframework/fireflyframework-rust/blob/main/MODULES.md) +en la raíz del repositorio. Este apéndice lo reproduce nivel a nivel como un +índice navegable. + +## Puerta de entrada + +| Crate | Qué proporciona | +|-------|------------------| +| `firefly` | La fachada de dependencia única: `use firefly::prelude::*;`, alias por crate, el contrato de la macro `__rt`, adaptadores activados por features | +| `firefly-macros` | Macros declarativas: `#[derive(Command/Query/Component/DomainEvent/AggregateRoot)]`, `#[command_handler]`/`#[query_handler]`, `#[scheduled]`, `#[rest_controller]`+verbos, `#[event_listener]` | + +## Cimientos + +| Crate | Qué proporciona | +|-------|------------------| +| `firefly-kernel` | `ProblemDetail` según RFC 7807, `FireflyResult`, `Clock`, la jerarquía `FireflyError`, ámbitos task-local de correlación/petición/tenant, el kit `ddd` (`Entity`/`Specification`/eventos de dominio) | +| `firefly-reactive` | El núcleo reactivo `Mono` / `Flux`: la piedra angular de cada superficie reactiva de Firefly | +| `firefly-utils` | Helpers de try, reintentos con backoff exponencial, slug, AES-256-GCM, plantillas | +| `firefly-validators` | IBAN, BIC, Luhn, divisa, teléfono, contraseña, IVA, identificadores nacionales/fiscales | +| `firefly-web` | Renderizador de problemas, correlación, idempotencia, enmascaramiento de PII, CORS, CSRF, cabeceras de seguridad, métricas de servidor, los responders reactivos `MonoJson`/`NdJson`/`Sse`, arranque de TLS | +| `firefly-config` | Binding tipado de YAML+env+flags, perfiles, marcadores `${...}`, recarga en tiempo de ejecución, fuentes de propiedades enmascaradas, `ApplicationEventBus`, cliente de config-server | +| `firefly-i18n` | `Bundle` de mensajes con reconocimiento de locale + selector de Accept-Language | +| `firefly-session` | Sesiones HTTP en el lado servidor + `SessionStore` + `SessionLayer` | + +## Plataforma + +| Crate | Qué proporciona | +|-------|------------------| +| `firefly-cache` | Puerto `Adapter` + Memory/NoOp/Fallback + memoización tipada `Typed` | +| `firefly-observability` | `tracing` + enriquecimiento por correlación, composite de health, banner de arranque, trace-context W3C, métricas | +| `firefly-data` | Puertos agnósticos al almacenamiento: DSL de filtros + `Specification`, `SqlDialect` (pg/mysql/sqlite) + `Specification::to_mongo()`, `Page`, `Repository`, `ReactiveCrudRepository`, `PostgresReactiveRepository`, auditoría + borrado lógico, consultas derivadas, paginación | +| `firefly-cqrs` | `Bus` de comandos/consultas con middleware de validación/caché/autorización + `send_mono`/`query_mono` reactivos | +| `firefly-eda` | Envoltura `Event`, puertos `Publisher`/`Subscriber`/`Broker`, `InMemoryBroker`, topics con globs, grupos de consumidores, reintentos/DLQ, suscripciones reactivas `Flux` | +| `firefly-eventsourcing` | Raíces de agregado, event store, snapshots, proyecciones, stream global, outbox transaccional, multitenencia | +| `firefly-orchestration` | `Saga`, `Workflow` (DAG), `Tcc`: compensación, reintento por paso, `StepContext` | +| `firefly-rule-engine` | DSL en YAML → AST → evaluador con `between`/null/`regex`, `EvaluationMode`, validador + `ActionHandler` | +| `firefly-plugins` | SPI de ciclo de vida + registro compuesto | +| `firefly-lifecycle` | Orquestador `Application::run()` con captura de señales + drenaje | +| `firefly-actuator` | `/actuator/{health,info,metrics,env,tasks,version}` + probes, loggers, httpexchanges, threaddump, refresh, y los informes de introspección de DI `beans`/`mappings`/`conditions` (renderizados a partir del inventario en tiempo de compilación de `firefly-container`) | +| `firefly-scheduling` | `Scheduler` Cron + FixedRate + FixedDelay con zonas | +| `firefly-resilience` | `CircuitBreaker`, `RateLimiter`, `Bulkhead`, `Timeout`, `Chain` componible | +| `firefly-security` | `BearerLayer`, `FilterChain` RBAC, `JwksVerifier`, `oauth2`, `RoleHierarchy`, `CsrfLayer`, `BcryptPasswordEncoder` | +| `firefly-migrations` | Migraciones SQL versionadas solo hacia adelante sobre un puerto `Database` | +| `firefly-openapi` | Generador OpenAPI 3.1 + shim de Swagger-UI | +| `firefly-sse` | Escritor de Server-Sent Events con heartbeat + Last-Event-Id | +| `firefly-transactional` | `with_tx(ctx, db, f)` sobre puertos `Database`/`Transaction` enchufables | +| `firefly-testkit` | Firmantes HMAC, `SpyBroker`, helpers de test JSON | +| `firefly-aop` | Consejo orientado a aspectos: `Pointcut`, `JoinPoint`, `Aspect`, `intercept` | +| `firefly-shell` | Framework de CLI interactiva + hooks de arranque `CommandLineRunner`/`ApplicationRunner` | +| `firefly-websocket` | Servidor WebSocket sobre axum + `BroadcastHub` por topic | + +## Adaptadores + +| Crate | Puerto → backend | +|-------|----------------| +| `firefly-client` | `RestClient` REST + `WebClient` reactivo + SOAP/gRPC/GraphQL/WS | +| `firefly-config-server` | Endpoint REST de configuración centralizada para servicios distribuidos | +| `firefly-idp` + `idp-internal-db` / `idp-keycloak` / `idp-azure-ad` / `idp-aws-cognito` | Proveedores de identidad | +| `firefly-ecm` + `ecm-storage-aws` / `ecm-storage-azure` / `ecm-esignature-*` | Gestión de contenidos + firma electrónica | +| `firefly-notifications` + `notifications-smtp` / `-twilio` / `-firebase` / `-sendgrid` / `-resend` | Canales de notificación | +| `firefly-callbacks` | Subsistema de webhooks salientes (dispatcher HMAC + auditoría + administración) | +| `firefly-webhooks` | Ingestión entrante (Stripe / GitHub / Twilio / HMAC genérico + DLQ) | +| `firefly-data-sqlx` | Puertos `firefly_data` → relacional (Postgres / MySQL / SQLite sobre `sqlx`) | +| `firefly-data-mongodb` | Puertos `firefly_data` → almacén documental (MongoDB) | +| `firefly-cache-redis` / `firefly-cache-postgres` | `cache::Adapter` → Redis (RESP) / tabla clave-valor de Postgres | +| `firefly-eda-kafka` / `-rabbitmq` / `-postgres` / `-redis` | `eda::Broker` → Kafka / RabbitMQ / outbox de Postgres / Redis Streams | +| `firefly-session-redis` / `firefly-session-postgres` / `firefly-session-mongodb` | `SessionRegistry` distribuido → sorted set de Redis / tabla de Postgres / colección de MongoDB | + +## Starters + +| Crate | Qué empaqueta | +|-------|------------------| +| `firefly-starter-core` | web + cache + observability + eda + cqrs + actuator + lifecycle + scheduling | +| `firefly-starter-application` | starter-core + registro de plugins | +| `firefly-starter-domain` | starter-core + stores de event-sourcing en memoria | +| `firefly-starter-data` | starter-core (tú aportas la BD) | +| `firefly-starter-web` | `WebStack`: `Core` + CORS + cabeceras de seguridad + métricas de petición + access log | +| `firefly-starter-experience` | `ExperienceStack` (alias `Bff`): `WebStack` + `DomainClients` (el `ClientFactory`) + gates de `SignalService` + `WorkflowState` con soporte de Redis + `WorkflowQueryService` + `ChildWorkflowService`: el nivel de experiencia (BFF) | +| `firefly-backoffice` | starter-application + middleware de contexto de back-office | + +## DI / Operaciones / Tooling + +| Crate | Qué proporciona | +|-------|------------------| +| `firefly-container` | Contenedor de DI opcional con claves por `TypeId` (service locator) | +| `firefly-admin` | Dashboard de gestión embebido + API JSON + streams SSE | +| `firefly-cli` | El binario de desarrollo `firefly` (`new` / `generate` / `db` / `openapi` / `actuator` / `doctor` / `completion` / `sbom` / `license`) | + +Para el detalle por crate, abre el `README.md` del crate en el +[repositorio](https://github.com/fireflyframework/fireflyframework-rust/tree/main/crates), +o lee el catálogo [`MODULES.md`](https://github.com/fireflyframework/fireflyframework-rust/blob/main/MODULES.md). diff --git a/docs/book/src-es/92-glossary.md b/docs/book/src-es/92-glossary.md new file mode 100644 index 00000000..dc45e78d --- /dev/null +++ b/docs/book/src-es/92-glossary.md @@ -0,0 +1,269 @@ +# Glosario + +Definiciones de los términos y tipos que se repiten a lo largo de este libro, en +el sentido preciso con que Firefly los emplea. + +### Actuator +La superficie de gestión (`firefly-actuator`) que expone los endpoints +`/actuator/health`, `/actuator/info`, `/actuator/metrics`, `/actuator/env`, +`/actuator/tasks`, `/actuator/version`, `/actuator/loggers`, +`/actuator/httpexchanges`, `/actuator/refresh` y los informes de introspección de +la DI `/actuator/beans`, `/actuator/mappings`, `/actuator/conditions`. +Lo monta `core.actuator_router(..)`, normalmente en un puerto separado y +protegido por cortafuegos. + +### Adapter +Una implementación concreta de un **port**. Se selecciona en el momento del +cableado como un `Arc`, de modo que las dependencias pesadas de los SDK +quedan fuera de los servicios que no las usan. Ejemplos: `RedisAdapter` (un +`cache::Adapter`), `KafkaBroker` (un `eda::Broker`). + +### Aggregate root +La frontera de consistencia en DDD. La variante no orientada a eventos mantiene +un búfer `PendingEvents` (`firefly_kernel::ddd`); la variante orientada a +eventos es un `AggregateRoot` cuyo estado se reconstruye reproduciendo sus +`DomainEvent`s (`firefly-eventsourcing`). + +### Autowired +Un campo de componente que el contenedor de DI resuelve e inyecta por tipo +(`#[autowired]`, `firefly-container`). El tipo del campo determina la forma: +`Arc` (obligatorio), `Option>` (opcional), `Vec>` (todas las +implementaciones), `Provider` (diferido). Resuelve e inyecta un campo de +componente por tipo. + +### Backpressure +Un consumidor lento que regula a un productor rápido. Los flujos reactivos `Flux` +de Firefly (responders NDJSON/SSE, `WebClient::body_to_flux`, flujos de filas de +Postgres) respetan la contrapresión (backpressure) de extremo a extremo, de modo +que un flujo grande nunca acaba por completo en memoria. + +### Bean +Cualquier valor que el `Container` de DI construye, cablea y posee. Se declara con +un derive de **stereotype** (`#[derive(Component/Service/Repository/Configuration/ +Controller)]`) o lo produce un método factoría `#[bean]` en un contenedor +`#[derive(Configuration)]`. Se indexa por `TypeId`; se resuelve con +`resolve::()`. + +### BFF (Backend-for-Frontend) +Véase **Experience tier**. + +### Bus +El despachador de comandos/consultas de CQRS (`firefly_cqrs::Bus`). Los handlers +se registran por tipo de entrada; el despacho se indexa por +`std::any::TypeId`. `send`/`query` son async; `send_mono`/`query_mono` son sus +gemelos reactivos. + +### Compensation +El paso de deshacer que ejecuta una **saga** en orden inverso cuando un paso +posterior falla (`Step::with_compensation`). En la saga de transferencia de +Lumen, la compensación del adeudo es un depósito de reembolso en el monedero de +origen. + +### CompensationPolicy +Cómo deshace sus efectos una saga/workflow ante un fallo: `BestEffort` (continuar +compensando aunque una compensación falle) o `StopOnError` (abortar en el primer +fallo de compensación). + +### Component scanning +Descubrimiento de beans en tiempo de enlace (`Container::scan()` / +`firefly::scan`): cada derive de stereotype no genérico registra un thunk de +`inventory`, y `scan` los recopila por todo el grafo de crates, aplica las +**conditions** y los **profiles**, y registra a los supervivientes. El +descubrimiento es en tiempo de enlace, no reflexivo. Los beans genéricos usan el +recurso alternativo `register_all!`. + +### Conditional bean +Un bean que se registra solo cuando el entorno coincide — `#[firefly(profile = +"…", condition_on_property = "k=v", condition_on_bean = "T", +condition_on_missing_bean = "T", condition_on_class = "label", +condition_on_single_candidate = "T")]`. Lo evalúa `scan` en dos pasadas (primero +los hechos de configuración/profile, después las comprobaciones que dependen del +registro). + +### Container +El localizador de servicios de DI opcional e indexado por `TypeId` +(`firefly-container`): registra beans, resuelve por tipo o por nombre, admite +scopes, vínculos a trait objects, desambiguación con `primary`/`order`, +`Provider` diferido, hooks de ciclo de vida y component scanning. Distinto de +**Core** (el paquete de infraestructura ya cableada). + +### Core +El paquete de infraestructura ya cableada que devuelve `Core::new(CoreConfig)` +(`firefly-starter-core`): caché, bus de CQRS, broker de eventos, composición de +health, métricas, scheduler, logging y la cadena de middleware. + +### Correlation id +Un identificador por petición que viaja en la cabecera `X-Correlation-Id` y en un +scope task-local del kernel. Enriquece automáticamente cada línea de log, cada +evento publicado y cada llamada saliente de cliente, de modo que una petición se +hila a través de los servicios. + +### DomainEvent +El evento orientado a eventos, versionado y con formato de cable de +`firefly-eventsourcing` (distinto del `TransientDomainEvent` transitorio de +`firefly_kernel::ddd`). Su JSON usa un formato de cable estable y versionado. + +### Event (EDA) +El sobre por el que fluye todo evento de `firefly-eda` — `id`, `type`, `source`, +`topic`, `correlationId`, `time`, `headers`, `payload`, `key`. Se construye con +`Event::new`; el sobre sigue un esquema JSON estable. + +### Experience tier (BFF) +La capa de servicios superior (`firefly-starter-experience`, `ExperienceStack` / +`Bff`): un Backend-for-Frontend que compone varios SDK de **domain** en endpoints +REST atómicos y específicos de cada recorrido (journey). No posee ninguna base de +datos y solo llama a servicios de dominio (`channel → experience → domain → +core`). Se construye a partir de `DomainClients` (la `ClientFactory`), las +compuertas de `SignalService`, un `WorkflowState` con soporte de Redis y +`WorkflowQueryService`. + +### FireflyError +El tipo de error del framework (`firefly_kernel::FireflyError`). Se renderiza como +una respuesta `application/problem+json` conforme a RFC 7807, y es el canal de +error fijo del `Mono`/`Flux` reactivo (su señal terminal `Err`). + +### FilterChain +El emparejador de autorización basado en rutas de `firefly-security` (`permit` / +`require` / glob `permit_pattern` / `require_pattern`). Es fail-closed en cuanto +se declara cualquier regla: una ruta no declarada se deniega por defecto. + +### Flux +Un publisher reactivo de *0..N* valores más una finalización-o-error terminal +(`firefly_reactive::Flux`). + +### Idempotency +El comportamiento de reproducción que se aplica a las peticiones `POST`/`PUT`/`PATCH` +que llevan una cabecera `Idempotency-Key`. Una repetición reproduce la respuesta +almacenada (`Idempotent-Replay: true`); reutilizarla con un cuerpo distinto +produce un 409. + +### Mono +Un publisher reactivo de *como mucho un* valor más un error terminal +(`firefly_reactive::Mono`). Un `Mono` vacío es `Ok(None)`. + +### NDJSON +JSON delimitado por saltos de línea (`application/x-ndjson`) — un documento JSON +compacto por línea. El responder `NdJson(Flux)` lo transmite con +contrapresión. + +### Outbox (transactional) +Un patrón (`TransactionalOutbox`, `firefly-eda-postgres`) que escribe los eventos +en la misma transacción que el cambio de estado y los entrega a los consumidores +después, ofreciendo entrega al-menos-una-vez sin necesidad de un broker aparte. + +### Port +Un trait `async_trait` object-safe que define un punto de integración — +`cache::Adapter`, `eda::Broker`, `notifications::Channel`, `idp::Adapter`. El +código depende del port; un **adapter** lo implementa. + +### Primary +El desambiguador (`#[firefly(primary)]`) que elige un bean cuando hay varias +implementaciones vinculadas al mismo port. Resolver sin ningún primary entre +varios candidatos es un error `NoUniqueBean` que nombra a cada candidato. + +### Problem (RFC 7807 / 9457) +El sobre de error `application/problem+json` (`type`, `title`, `status`, +`detail`) que todo servicio Firefly renderiza para errores y panics, siguiendo el +estándar RFC 9457. RFC 9457 deja obsoleto a RFC 7807 y es compatible a nivel de +cable con él; el libro usa ambos números indistintamente. + +### Profile +Un entorno con nombre (`prod`, `dev`, `test`) que controla los conditional beans +(`#[firefly(profile = "expr")]`). La gramática de la expresión admite `&`, `|`, +`!`, la coma como OR y paréntesis. Los profiles activos residen en el +`ApplicationContext` / `ConditionContext`. + +### Projection +Un handler del lado de lectura que construye un modelo de lectura a partir de los +eventos (`firefly_eventsourcing::Projection`), dirigido por agregado (`replay`) o +sobre el flujo global (`drive_once` / `replay_all`). + +### Qualifier +Un nombre que se usa para seleccionar un bean concreto cuando varios comparten el +mismo tipo (`#[firefly(qualifier = "replica")]` → `resolve_named`). + +### Reactive +El modelo de programación `Mono`/`Flux` (`firefly-reactive`) y todo lo construido +sobre él — endpoints reactivos, repositorios, el `WebClient`, EDA/CQRS reactivos. +Si has usado alguna biblioteca de reactive-streams, el modelo de publisher te +resultará familiar. + +### Saga +Un motor de transacciones distribuidas secuencial (`firefly_orchestration::Saga`) +con compensación en orden inverso ante un fallo. Véanse también `Workflow` (DAG) y +`Tcc`. + +### Scheduler +El ejecutor de tareas (`firefly_scheduling::Scheduler`) que gestiona disparadores +Cron, FixedRate y FixedDelay, cada uno en su propia tarea de tokio con +recuperación ante panics. + +### Scope +El ciclo de vida de un bean (`#[firefly(scope = "…")]`): `singleton` (una única +instancia cacheada, el valor por defecto), `transient` (una nueva en cada +resolución), `request` o `session` (ambos gestionados por un `ScopeHandler`). + +### Signal +Un evento externo que satisface una compuerta de workflow aparcada en la +**experience tier** (`SignalService::deliver` / `Node::wait_for_signal`). La +entrega se almacena en búfer, de modo que una señal que llega antes de que la +compuerta se aparque no se pierde. + +### SSE (Server-Sent Events) +Un protocolo de streaming unidireccional (`text/event-stream`). El responder +`Sse(Flux)` y el `SseWriter` de `firefly-sse` lo emiten; +`WebClient::body_to_flux` lo decodifica. + +### Specification +Un predicado de regla de negocio componible +(`firefly_kernel::ddd::Specification`) que se combina con `.and()`, `.or()`, +`.not()`. Cualquier `Fn(&T) -> bool` lo es. + +### Starter +Un crate que agrupa una pila por defecto razonable, de modo que un servicio +depende de un único crate. `firefly-starter-core` es el punto de partida común; +`firefly-starter-domain` y `firefly-starter-experience` añaden las capas de +domain y BFF. + +### Stereotype +La etiqueta de rol arquitectónico que lleva un bean de DI (`component`, +`service`, `repository`, `configuration`, `controller`, `bean`), establecida por +el derive que lo declaró. Son funcionalmente equivalentes; las diferencias están +en la intención documentada y en la agrupación que muestra la vista `/beans` del +admin. + +### TCC (Try-Confirm-Cancel) +Un motor de transacciones distribuidas en dos fases +(`firefly_orchestration::Tcc`): hace Try a todos los participantes, Confirm a +todos en caso de éxito y Cancel a los participantes ya intentados ante cualquier +fallo de Try. + +### Value object +Un tipo de dominio definido por completo por sus atributos (sin identidad) e +**inmutable**: cada operación devuelve un valor nuevo. El `Money` de Lumen es el +ejemplo canónico — aritmética exacta de céntimos enteros, cerrada bajo +`add`/`subtract`. La contraparte en DDD de un **aggregate root** (que sí tiene +identidad). + +### Verifier +El port asíncrono (`firefly_security::Verifier`) que valida un bearer token y +devuelve una `Authentication`. `JwksVerifier`, los adapters de IDP y los closures +`VerifierFn` lo satisfacen todos. + +### WebClient +El cliente HTTP reactivo (`firefly_client::WebClient`) cuyos operadores +terminales devuelven `Mono`/`Flux` (`body_to_mono`, `body_to_flux`, `exchange`). + +### Workflow +Un motor de transacciones distribuidas en forma de DAG +(`firefly_orchestration::Workflow`): los nodos independientes se ejecutan de +forma concurrente dentro de una oleada, con compensación en orden inverso bajo +una `CompensationPolicy` configurable. En la **experience tier**, un nodo puede +aparcarse en una compuerta de **signal**. + +### WorkflowState +Estado de recorrido (journey) persistido y con soporte de Redis en la +**experience tier** (`firefly_starter_experience::WorkflowState`): hace ida y +vuelta del snapshot `StepContext` de una ejecución de workflow a través del +`Adapter` de caché, indexado por correlation id, de modo que un recorrido +aparcado sobrevive a una desconexión del cliente (`save` / `load` / `delete`).