You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 7a8f5cc
Browse filesBrowse the repository at this point in the historyBrowse files
@@ -503,9 +504,9 @@ Lo que `render()` deliberadamente **no** decide es en qué `FireflyException` se
503
504
}
504
505
```
505
506
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.
507
508
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.
509
510
510
511
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.
511
512
@@ -557,7 +558,7 @@ Un intento de retiro se rechaza por dos vías distintas, y las dos **no dan el m
557
558
"code": "AUTHENTICATION_FAILED",
558
559
"category": "security",
559
560
"severity": "warning",
560
-
"detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: Authentication is required.",
561
+
"detail": "Authentication is required.",
561
562
"type": "about:blank",
562
563
"instance": "/api/v1/wallets/wlt-1/withdraw",
563
564
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
@@ -575,7 +576,7 @@ El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun
575
576
"code": "ACCESS_DENIED",
576
577
"category": "security",
577
578
"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.",
579
580
"type": "about:blank",
580
581
"instance": "/api/v1/wallets/wlt-1/withdraw",
581
582
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
@@ -585,7 +586,7 @@ El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun
585
586
}
586
587
```
587
588
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 causael 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.
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.
422
425
423
426
`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:
->assertJsonPath('detail', 'cannot withdraw 9999.99 EUR; balance is 20.00 EUR');
1169
1170
```
1170
1171
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.
1172
1173
1173
1174
`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:
0 commit comments