Skip to content

Commit 7a8f5cc

Browse files
authored
Merge pull request #21 from fireflyframework/fix/cqrs-problem-detail
Keep raw handler failures out of problem documents and publish the cause's own detail over the bus
2 parents d79e8dc + d2c80ca commit 7a8f5cc

35 files changed

Lines changed: 809 additions & 35 deletions

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y
44

55
## [Unreleased]
66

7+
### Changed
8+
9+
- **Over the CQRS bus, `detail` is the cause's own sentence.** A refusal raised inside a handler — a `409` from the
10+
domain, a `#[PreAuthorize]` denial — now publishes the same problem document it publishes thrown from a
11+
controller, `detail` included, instead of prefixing it with `Processing command [<class>] failed:`; the HTML error
12+
page's lede follows. The wrapper's `getMessage()` keeps the prefix for the log. The seam is the kernel's:
13+
`FireflyException::detail()` (`getMessage()` by default) is what `ErrorResponse::fromException()` publishes, and
14+
`withDetail(?string)` replaces it, with `null` meaning the exception holds no sentence written for a client.
15+
16+
### Security
17+
18+
- **A command or query handler's unhandled throwable no longer reaches the wire.** `CommandProcessingException` and
19+
`QueryProcessingException` are `FireflyException`s whose message is `Processing command [<class>] failed: <the
20+
cause's message>`, and the web layer published that message as `detail` — so a handler's `QueryException` put its
21+
SQL and bindings, and the command's class name, into a 500 problem document with `firefly.web.problem.disclose`
22+
off, where the same throwable thrown from a controller answered with the opaque sentence. A wrapper of a cause that
23+
is not a `FireflyException` now answers exactly like that controller: `An unexpected error occurred. It has been
24+
logged; quote reference <traceId> if you report it.` with disclosure off, and the cause's own message (no prefix)
25+
with it on, keeping its own `COMMAND_PROCESSING_ERROR`/`QUERY_PROCESSING_ERROR` code. The same rule now covers the
26+
event-sourcing mapper's `EventSerializationException` for an event constructor that threw, the orchestration
27+
engine's `ORCHESTRATION_CONDITION_FAILED`, and the orchestration REST surface, which published any refusal's message.
28+
729
## [26.10.2] - 2026-10-13
830

931
### Added

‎book/src-es/04-first-http-api.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ Lo que `render()` deliberadamente **no** decide es en qué `FireflyException` se
480480
public static function toFireflyException(Throwable $e, bool $disclose = true, ?string $reference = null): FireflyException
481481
{
482482
return match (true) {
483+
$e instanceof FireflyException && $e->detail() === null => self::withheld($e, $disclose, $reference),
483484
$e instanceof FireflyException => $e,
484485
self::isExecutionTimeLimit($e) => self::executionTimeExceeded($e, $reference),
485486
$e instanceof MethodNotAllowedHttpException => self::methodNotAllowed($e),
@@ -503,9 +504,9 @@ Lo que `render()` deliberadamente **no** decide es en qué `FireflyException` se
503504
}
504505
```
505506

506-
Cinco brazos que cubren cuatro casos — el `405` tiene un brazo propio solo para que los verbos que permite puedan sacarse de la frase del enrutador y llevarse a un miembro `allowed` y a la cabecera `Allow` — y todos ellos terminan de vuelta en la misma llamada `ErrorResponse::fromException(...)->toArray()`, así que la forma de la carga útil nunca depende de qué brazo la produjo.
507+
Seis brazos que cubren cinco casos — el `405` tiene un brazo propio solo para que los verbos que permite puedan sacarse de la frase del enrutador y llevarse a un miembro `allowed` y a la cabecera `Allow` — y todos ellos terminan de vuelta en la misma llamada `ErrorResponse::fromException(...)->toArray()`, así que la forma de la carga útil nunca depende de qué brazo la produjo.
507508

508-
Una `FireflyException` — o una de sus subclases tipadas, como `ResourceNotFoundException` — se devuelve intacta y se renderiza con su propio `httpStatus()`, porque su mensaje lo escribió tu aplicación *para* el cliente; ese es justamente el sentido de la taxonomía. El propio límite de tiempo de ejecución de PHP se nombra aparte y responde `503` con una cabecera `Retry-After`, porque «el servidor detuvo esta petición a los N segundos» es algo sobre lo que quien llama puede actuar y un `500` desnudo no lo es. Una excepción HTTP de Laravel/Symfony — una URL que no coincide con ninguna ruta, un verbo que una ruta no acepta — conserva su **código de estado real**, así que una ruta no coincidente sigue respondiendo `404` y nunca un `500` engañoso; solo se reemplaza la redacción del propio enrutador por una frase escrita para una persona, y los verbos permitidos de un `405` pasan a un miembro `allowed` y a la cabecera `Allow`, donde un cliente puede leerlos sin analizar inglés. Cualquier otra cosa es un accidente, y `$disclose` — `firefly.web.problem.disclose`, por defecto `false` — decide si su mensaje puede publicarse siquiera: con él apagado el cuerpo lleva una frase fija que nombra la misma referencia que lleva el `traceId` del documento, y el mensaje real se queda en el log, que es donde el SQL de una `QueryException` y sus enlaces deben estar.
509+
Una `FireflyException` — o una de sus subclases tipadas, como `ResourceNotFoundException` — se devuelve intacta y se renderiza con su propio `httpStatus()`, porque su mensaje lo escribió tu aplicación *para* el cliente; ese es justamente el sentido de la taxonomía. Lo que llega al cable es el `detail()` de la excepción, que es ese mensaje salvo que la excepción diga otra cosa — y el primer brazo es para la que lo dice: una `FireflyException` cuyo `detail()` es `null` no lleva ninguna frase para el cliente (el envoltorio del bus CQRS sobre una `QueryException` es una, Capítulo 7), así que conserva su propio código, estado, categoría y miembros de extensión, pero toma su frase exactamente como el último brazo se la daría al throwable que envuelve. El propio límite de tiempo de ejecución de PHP se nombra aparte y responde `503` con una cabecera `Retry-After`, porque «el servidor detuvo esta petición a los N segundos» es algo sobre lo que quien llama puede actuar y un `500` desnudo no lo es. Una excepción HTTP de Laravel/Symfony — una URL que no coincide con ninguna ruta, un verbo que una ruta no acepta — conserva su **código de estado real**, así que una ruta no coincidente sigue respondiendo `404` y nunca un `500` engañoso; solo se reemplaza la redacción del propio enrutador por una frase escrita para una persona, y los verbos permitidos de un `405` pasan a un miembro `allowed` y a la cabecera `Allow`, donde un cliente puede leerlos sin analizar inglés. Cualquier otra cosa es un accidente, y `$disclose` — `firefly.web.problem.disclose`, por defecto `false` — decide si su mensaje puede publicarse siquiera: con él apagado el cuerpo lleva una frase fija que nombra la misma referencia que lleva el `traceId` del documento, y el mensaje real se queda en el log, que es donde el SQL de una `QueryException` y sus enlaces deben estar.
509510

510511
Pedir un monedero que nunca se abrió se renderiza así. Fíjate en `instance`: el RFC 9457 §3.1.5 lo define como una **referencia** URI, y una referencia relativa se resuelve contra la URI base del documento — así que el `api/v1/wallets/wlt-999` pelado que devuelve `$request->path()`, servido desde `/api/v1/wallets/wlt-999`, identificaría `/api/v1/api/v1/wallets/wlt-999`. LaraFly publica la forma relativa a la raíz, que es el único trabajo de `ProblemMapper::instanceFor()`, y un cliente puede compararla con la ruta que pidió. Fíjate también en `type`: el RFC 9457 §3.1.1 dice que un `type` ausente *es* `about:blank`, y LaraFly lo escribe en vez de dejar que el lector tenga que saberlo — apunta `firefly.web.problem.type-uri` a una URI base y el `code` estable deriva uno real y abrible.
511512

@@ -557,7 +558,7 @@ Un intento de retiro se rechaza por dos vías distintas, y las dos **no dan el m
557558
"code": "AUTHENTICATION_FAILED",
558559
"category": "security",
559560
"severity": "warning",
560-
"detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: Authentication is required.",
561+
"detail": "Authentication is required.",
561562
"type": "about:blank",
562563
"instance": "/api/v1/wallets/wlt-1/withdraw",
563564
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
@@ -575,7 +576,7 @@ El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun
575576
"code": "ACCESS_DENIED",
576577
"category": "security",
577578
"severity": "warning",
578-
"detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: You do not have permission to do this.",
579+
"detail": "You do not have permission to do this.",
579580
"type": "about:blank",
580581
"instance": "/api/v1/wallets/wlt-1/withdraw",
581582
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
@@ -585,7 +586,7 @@ El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun
585586
}
586587
```
587588

588-
Los dos se producen sin ningún cambio de código en `WalletController::withdraw()`: el bus envuelve la excepción de seguridad que haya levantado el evaluador en una `CommandProcessingException` que copia el código, el estado, la categoría y los miembros de extensión de la causa, así que el cable lleva la identidad de la causa y no un error genérico del bus, y el 403 nombra las mismas `requiredAuthorities` que nombraría un método de controlador rechazado. Solo `detail` delata que pasó por el bus: el envoltorio antepone a la frase de la causa el comando que estaba procesando. El Capítulo 10 vuelve sobre este par — y sobre las dos pruebas distribuidas que lo fijan — en detalle.
589+
Los dos se producen sin ningún cambio de código en `WalletController::withdraw()`: el bus envuelve la excepción de seguridad que haya levantado el evaluador en una `CommandProcessingException` que copia el código, el estado, la categoría y los miembros de extensión de la causa, así que el cable lleva la identidad de la causa y no un error genérico del bus, y el 403 nombra las mismas `requiredAuthorities` que nombraría un método de controlador rechazado. Ni siquiera `detail` delata que pasó por el bus: es la propia frase de la causa, palabra por palabra. El *mensaje* del envoltorio sigue nombrando el comando que estaba procesando — `Processing command [Lumen\Application\Command\Withdraw] failed: …` — pero esa línea es para el log, y el Capítulo 7 explica por qué se mantienen separados. El Capítulo 10 vuelve sobre este par — y sobre las dos pruebas distribuidas que lo fijan — en detalle.
589590

590591
### Manejar una excepción tú mismo
591592

‎book/src-es/07-cqrs.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,11 +414,14 @@ final class CommandProcessingException extends CqrsException
414414
$cause instanceof FireflyException ? $cause->extensions() : [],
415415
$cause instanceof FireflyException ? $cause->title() : null,
416416
);
417+
418+
// …
419+
$this->withDetail($cause instanceof FireflyException ? $cause->detail() : null);
417420
}
418421
}
419422
```
420423

421-
Tres cosas merecen destacarse. Primero, la causa siempre está disponible en `getPrevious()` — el quinto argumento del constructor hacia el padre — así que nada del fallo original se pierde, solo se envuelve. Segundo, esto **preserva la categoría**: cuando la causa es una `FireflyException` (todas las excepciones del kernel lo son), su propio `httpStatus()`/`category()`/`severity()` se copian sobre el envoltorio en vez de recaer en un genérico `500`/`Internal`, y también sus miembros de extensión RFC 9457 y su título, razón por la cual un `#[PreAuthorize]` rechazado sigue nombrando sus `requiredAuthorities` en el documento de problema después de que el bus lo envuelva (el Capítulo 4 muestra ese documento). Una `ResourceNotFoundException` (404, negocio) lanzada por un id de monedero desconocido sigue renderizándose como una respuesta de detalles de problema 404 incluso después de ser envuelta — el bus añade contexto, nunca enmascara el fallo. Tercero, `send()` comprueba `$e instanceof CommandProcessingException` antes de envolver, así que un despacho de comando anidado (un manejador que a su vez llama a `$commands->send(...)`) nunca queda envuelto dos veces en dos capas de la misma excepción. `QueryProcessingException` es el gemelo del lado de lectura, idéntico en forma.
424+
Cuatro cosas merecen destacarse. Primero, la causa siempre está disponible en `getPrevious()` — el quinto argumento del constructor hacia el padre — así que nada del fallo original se pierde, solo se envuelve. Segundo, esto **preserva la categoría**: cuando la causa es una `FireflyException` (todas las excepciones del kernel lo son), su propio `httpStatus()`/`category()`/`severity()` se copian sobre el envoltorio en vez de recaer en un genérico `500`/`Internal`, y también sus miembros de extensión RFC 9457 y su título, razón por la cual un `#[PreAuthorize]` rechazado sigue nombrando sus `requiredAuthorities` en el documento de problema después de que el bus lo envuelva (el Capítulo 4 muestra ese documento). Una `ResourceNotFoundException` (404, negocio) lanzada por un id de monedero desconocido sigue renderizándose como una respuesta de detalles de problema 404 incluso después de ser envuelta — el bus añade contexto, nunca enmascara el fallo. Tercero, `send()` comprueba `$e instanceof CommandProcessingException` antes de envolver, así que un despacho de comando anidado (un manejador que a su vez llama a `$commands->send(...)`) nunca queda envuelto dos veces en dos capas de la misma excepción. `QueryProcessingException` es el gemelo del lado de lectura, idéntico en forma. Cuarto, el mensaje y el `detail` son **dos frases distintas**. El mensaje — `Processing command [Lumen\Application\Command\Withdraw] failed: …` — nombra el comando y cita la causa al pie de la letra, que es lo que quiere una línea de log. El documento de problema publica en su lugar `detail()`, y `withDetail()` lo fija al de la causa: una causa redactada llega al cliente palabra por palabra, exactamente como si la hubiera lanzado un controlador, y una causa que *no* es una `FireflyException` — una `QueryException` con su SQL y sus parámetros, un `TypeError` que nombra una ruta del servidor — no tiene ninguna frase para el cliente, así que el detalle es `null` y la capa web responde con la misma frase opaca que da a ese mismo throwable lanzado desde un controlador (o, con `firefly.web.problem.disclose=true`, el propio mensaje de la causa sin el prefijo). El envoltorio conserva su propio código `500 COMMAND_PROCESSING_ERROR`; solo se retiene la frase.
422425

423426
`HandlerRegistry` lanza sus propias excepciones, más estrechas, para un error de **cableado** en vez de un fallo de negocio — un mensaje sin mapear enviado a través del bus:
424427

‎book/src-es/10-security.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,10 +1165,11 @@ it('allows an authorized withdraw and renders an overdraw as 409 problem+json',
11651165
$this->postJson("/api/v1/wallets/{$id}/withdraw", ['amount_minor' => 999999])
11661166
->assertStatus(409)
11671167
// …
1168-
->assertJsonPath('category', 'business');
1168+
->assertJsonPath('category', 'business')
1169+
->assertJsonPath('detail', 'cannot withdraw 9999.99 EUR; balance is 20.00 EUR');
11691170
```
11701171

1171-
La autoridad concedida es `'ROLE_WALLET_OWNER'` — ya con el prefijo `ROLE_` incorporado — y la expresión de `WithdrawHandler` llama a `hasRole('WALLET_OWNER')`, que `SecurityExpressionRoot::hasRole()` normaliza a la cadena idéntica `'ROLE_WALLET_OWNER'` antes de comprobar la pertenencia; las dos formas de escribirlo se encuentran en el medio. El principal es `owner-4` y el monedero se abre para `owner-4`, y eso es lo que permite que `hasPermission(#command, 'debit')` conceda el cargo. El `409` de la segunda llamada es el contraste importante: la autorización y la validación de dominio son dos capas genuinamente independientes — que se te *permita* retirar no dice nada sobre si la retirada en sí es *válida*, y la propia invariante de no-sobregiro de `Wallet::withdraw()` (Capítulo 6) sigue aplicándose exactamente igual que antes.
1172+
La autoridad concedida es `'ROLE_WALLET_OWNER'` — ya con el prefijo `ROLE_` incorporado — y la expresión de `WithdrawHandler` llama a `hasRole('WALLET_OWNER')`, que `SecurityExpressionRoot::hasRole()` normaliza a la cadena idéntica `'ROLE_WALLET_OWNER'` antes de comprobar la pertenencia; las dos formas de escribirlo se encuentran en el medio. El principal es `owner-4` y el monedero se abre para `owner-4`, y eso es lo que permite que `hasPermission(#command, 'debit')` conceda el cargo. El `409` de la segunda llamada es el contraste importante: la autorización y la validación de dominio son dos capas genuinamente independientes — que se te *permita* retirar no dice nada sobre si la retirada en sí es *válida*, y la propia invariante de no-sobregiro de `Wallet::withdraw()` (Capítulo 6) sigue aplicándose exactamente igual que antes. Su `detail` es la frase del dominio palabra por palabra: el envoltorio del bus guarda la clase del comando para el log y publica la frase propia de la causa.
11721173

11731174
`samples/lumen/tests/Application/TransferSecurityTest.php` demuestra el mismo guardián directamente contra el `CommandBus`, con el patrón de desenvolver mediante `getPrevious()` que introdujo el Capítulo 7:
11741175

0 commit comments

Comments
 (0)