33namespace Tempest \Router \Exceptions ;
44
55use Tempest \Auth \Exceptions \AccessWasDenied ;
6- use Tempest \Container \Container ;
76use Tempest \Core \AppConfig ;
87use Tempest \Core \Priority ;
98use Tempest \Http \ContentType ;
109use Tempest \Http \GenericResponse ;
1110use Tempest \Http \HttpRequestFailed ;
1211use Tempest \Http \Request ;
1312use Tempest \Http \Response ;
14- use Tempest \Http \Responses \ Invalid ;
13+ use Tempest \Http \SensitiveField ;
1514use Tempest \Http \Session \CsrfTokenDidNotMatch ;
15+ use Tempest \Http \Session \Session ;
1616use Tempest \Http \Status ;
17+ use Tempest \Intl \Translator ;
18+ use Tempest \Reflection \ClassReflector ;
19+ use Tempest \Support \Arr ;
1720use Tempest \Support \Filesystem ;
21+ use Tempest \Support \Json ;
1822use Tempest \Validation \Exceptions \ValidationFailed ;
23+ use Tempest \Validation \FailingRule ;
24+ use Tempest \Validation \Validator ;
1925use Tempest \View \GenericView ;
2026use Throwable ;
2127
2228/**
2329 * Renders exceptions for HTML content. The priority is lowered by one because
2430 * JSON-rendering should be the default for requests without `Accept` header.
2531 */
26- #[Priority(Priority::LOWEST + 1 )]
32+ #[Priority(Priority::LOW )]
2733final readonly class HtmlExceptionRenderer implements ExceptionRenderer
2834{
2935 public function __construct (
3036 private AppConfig $ appConfig ,
31- private Container $ container ,
37+ private Translator $ translator ,
38+ private Request $ request ,
39+ private Validator $ validator ,
40+ private Session $ session ,
3241 ) {}
3342
3443 public function canRender (Throwable $ throwable , Request $ request ): bool
@@ -39,45 +48,47 @@ public function canRender(Throwable $throwable, Request $request): bool
3948 public function render (Throwable $ throwable ): Response
4049 {
4150 $ response = match (true ) {
42- $ throwable instanceof ConvertsToResponse => $ throwable ->toResponse (),
43- $ throwable instanceof ValidationFailed => new Invalid ($ throwable ->subject , $ throwable ->failingRules , $ throwable ->targetClass ),
44- $ throwable instanceof AccessWasDenied => $ this ->renderErrorResponse (Status::FORBIDDEN ),
45- $ throwable instanceof HttpRequestFailed => $ this ->renderErrorResponse ($ throwable ->status , $ throwable ),
51+ $ throwable instanceof ValidationFailed => $ this ->renderValidationFailedResponse ($ throwable ),
52+ $ throwable instanceof AccessWasDenied => $ this ->renderErrorResponse (Status::FORBIDDEN , message: $ throwable ->accessDecision ->message ),
4653 $ throwable instanceof CsrfTokenDidNotMatch => $ this ->renderErrorResponse (Status::UNPROCESSABLE_CONTENT ),
47- default => $ this ->renderErrorResponse (Status::INTERNAL_SERVER_ERROR , $ throwable ),
54+ $ throwable instanceof HttpRequestFailed => $ this ->renderHttpRequestFailed ($ throwable ),
55+ $ throwable instanceof ConvertsToResponse => $ throwable ->convertToResponse (),
56+ default => $ this ->renderErrorResponse (Status::INTERNAL_SERVER_ERROR ),
4857 };
4958
5059 if ($ this ->shouldRenderDevelopmentException ($ throwable )) {
5160 return new DevelopmentException (
5261 throwable: $ throwable ,
5362 response: $ response ,
54- request: $ this ->container -> get (Request::class) ,
63+ request: $ this ->request ,
5564 );
5665 }
5766
5867 return $ response ;
5968 }
6069
61- private function renderErrorResponse ( Status $ status , ? Throwable $ exception = null ): Response
70+ private function renderHttpRequestFailed ( HttpRequestFailed $ exception ): Response
6271 {
63- if ($ exception instanceof HttpRequestFailed && $ exception ->cause ?->body) {
72+ if ($ exception ->cause && is_string ($ exception ->cause ->body )) {
73+ return $ this ->renderErrorResponse ($ exception ->status , message: $ exception ->cause ->body );
74+ }
75+
76+ if ($ exception ->cause && $ exception ->cause ->body ) {
6477 return $ exception ->cause ;
6578 }
6679
80+ return $ this ->renderErrorResponse ($ exception ->status );
81+ }
82+
83+ private function renderErrorResponse (Status $ status , ?string $ message = null ): Response
84+ {
6785 return new GenericResponse (
6886 status: $ status ,
6987 body: new GenericView (__DIR__ . '/production/error.view.php ' , [
7088 'css ' => $ this ->getStyleSheet (),
7189 'status ' => $ status ->value ,
7290 'title ' => $ status ->description (),
73- 'message ' => $ exception ?->getMessage() ?: match ($ status ) {
74- Status::INTERNAL_SERVER_ERROR => 'An unexpected server error occurred ' ,
75- Status::NOT_FOUND => 'This page could not be found on the server ' ,
76- Status::FORBIDDEN => 'You do not have permission to access this page ' ,
77- Status::UNAUTHORIZED => 'You must be authenticated in to access this page ' ,
78- Status::UNPROCESSABLE_CONTENT => 'The request could not be processed due to invalid data ' ,
79- default => null ,
80- },
91+ 'message ' => $ message ?? $ this ->translator ->translate ("http_status_error. {$ status ->value }" ),
8192 ]),
8293 );
8394 }
@@ -103,4 +114,52 @@ private function shouldRenderDevelopmentException(Throwable $throwable): bool
103114
104115 return true ;
105116 }
117+
118+ private function renderValidationFailedResponse (ValidationFailed $ exception ): Response
119+ {
120+ $ status = Status::UNPROCESSABLE_CONTENT ;
121+ $ headers = [];
122+
123+ if ($ referer = $ this ->request ->headers ->get ('referer ' )) {
124+ $ headers ['Location ' ] = $ referer ;
125+ $ status = Status::FOUND ;
126+ }
127+
128+ $ this ->session ->flash (Session::VALIDATION_ERRORS , $ exception ->failingRules );
129+ $ this ->session ->flash (Session::ORIGINAL_VALUES , $ this ->filterSensitiveFields ($ this ->request , $ exception ->targetClass ));
130+
131+ $ errors = Arr \map_iterable ($ exception ->failingRules , fn (array $ failingRulesForField , string $ field ) => Arr \map_iterable (
132+ array: $ failingRulesForField ,
133+ map: fn (FailingRule $ rule ) => $ this ->validator ->getErrorMessage ($ rule , $ field ),
134+ ));
135+
136+ $ headers ['x-validation ' ] = Json \encode ($ errors );
137+
138+ return new GenericResponse (
139+ status: $ status ,
140+ headers: $ headers ,
141+ );
142+ }
143+
144+ /**
145+ * @param class-string|null $targetClass
146+ */
147+ private function filterSensitiveFields (Request $ request , ?string $ targetClass ): array
148+ {
149+ $ body = $ request ->body ;
150+
151+ if ($ targetClass === null ) {
152+ return $ body ;
153+ }
154+
155+ $ reflector = new ClassReflector ($ targetClass );
156+
157+ foreach ($ reflector ->getPublicProperties () as $ property ) {
158+ if ($ property ->hasAttribute (SensitiveField::class)) {
159+ unset($ body [$ property ->getName ()]);
160+ }
161+ }
162+
163+ return $ body ;
164+ }
106165}
0 commit comments