Repository navigation
Expand file tree
/
Copy pathHtmlExceptionResponseRenderer.php
More file actions
295 lines (266 loc) · 10.4 KB
/
Copy pathHtmlExceptionResponseRenderer.php
File metadata and controls
295 lines (266 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
<?php
declare(strict_types=1);
namespace Arcanum\Hyper;
use Arcanum\Flow\River\LazyResource;
use Arcanum\Flow\River\Stream;
use Arcanum\Glitch\ArcanumException;
use Arcanum\Glitch\ExceptionRenderer;
use Arcanum\Glitch\HttpException;
use Arcanum\Shodo\HelperResolver;
use Arcanum\Shodo\TemplateEngine;
use Arcanum\Shodo\TemplateResolver;
use Psr\Http\Message\ResponseInterface;
/**
* Renders exceptions as styled HTML error pages.
*
* Uses the same TemplateEngine as the success rendering path. When an
* app-provided error template exists (co-located or app-wide), it's
* rendered through the engine with error-specific variables. When no
* template exists, falls back to a self-contained built-in error page.
*
* Production mode: status code, title, message, navigation links.
* Debug mode adds: exception class, file:line, stack trace.
* Verbose errors adds: suggestion hint from ArcanumException.
*/
class HtmlExceptionResponseRenderer implements ExceptionRenderer
{
private string $dtoClass = '';
private bool $isHtmxRequest = false;
public function __construct(
private readonly bool $debug = false,
private readonly bool $verboseErrors = false,
private readonly ?TemplateEngine $engine = null,
private readonly ?TemplateResolver $templateResolver = null,
private readonly ?HelperResolver $helpers = null,
) {
}
/**
* Set the DTO class context for co-located error template resolution.
*
* Called by the kernel or middleware when the route is resolved.
* When not set, only app-wide error templates are checked.
*/
public function setDtoClass(string $dtoClass): void
{
$this->dtoClass = $dtoClass;
}
/**
* Mark the current request as htmx-initiated.
*
* When true and no app-provided error template exists, renders a
* minimal error fragment instead of the full styled error page.
*/
public function setIsHtmxRequest(bool $isHtmx): void
{
$this->isHtmxRequest = $isHtmx;
}
public function render(\Throwable $e): ResponseInterface
{
$status = match (true) {
$e instanceof HttpException => $e->getStatusCode(),
$e instanceof \Arcanum\Validation\ValidationException => StatusCode::UnprocessableEntity,
default => StatusCode::InternalServerError,
};
$title = $e instanceof ArcanumException
? $e->getTitle()
: $status->reason()->value;
$suggestion = $e instanceof ArcanumException
? $e->getSuggestion()
: null;
$html = $this->renderAppTemplate($status, $title, $e, $suggestion)
?? ($this->isHtmxRequest
? $this->buildHtmxFragment($status, $e)
: $this->buildHtml($status, $title, $e, $suggestion));
$body = new Stream(LazyResource::for('php://memory', 'w+'));
$body->write($html);
return new Response(
new Message(
new Headers([
'Content-Type' => ['text/html; charset=UTF-8'],
'Content-Length' => [(string) strlen($html)],
]),
$body,
Version::v11,
),
$status,
);
}
/**
* Try to render an app-provided error template via the TemplateEngine.
*
* Resolution order (via TemplateResolver::resolveForStatus):
* 1. Co-located: {DtoClass}.{status}.html (when dtoClass is set)
* 2. App-wide: {errorTemplatesDirectory}/{status}.html
* 3. null (falls through to built-in error page)
*/
private function renderAppTemplate(
StatusCode $status,
string $title,
\Throwable $e,
?string $suggestion,
): ?string {
if ($this->engine === null || $this->templateResolver === null) {
return null;
}
$templatePath = $this->templateResolver->resolveForStatus(
$this->dtoClass,
$status->value,
'html',
);
if ($templatePath === null) {
return null;
}
$variables = $this->buildErrorVariables($status, $title, $e, $suggestion);
return $this->engine->render($templatePath, $variables);
}
/**
* Build the template variable array for error templates.
*
* @return array<string, mixed>
*/
private function buildErrorVariables(
StatusCode $status,
string $title,
\Throwable $e,
?string $suggestion,
): array {
$rawMessage = $e->getMessage();
if ($rawMessage === $status->reason()->value) {
$rawMessage = $this->defaultDescription($status);
}
$variables = [
'code' => $status->value,
'title' => $title,
'message' => $rawMessage,
'suggestion' => $this->verboseErrors ? $suggestion : null,
'errors' => $e instanceof \Arcanum\Validation\ValidationException
? $e->errorsByField() : [],
'__escape' => static fn(string $v): string =>
htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'),
'__helpers' => $this->helpers !== null
? $this->helpers->for($this->dtoClass) : [],
];
if ($this->debug) {
$variables['exception'] = get_class($e);
$variables['file'] = $e->getFile();
$variables['line'] = $e->getLine();
$variables['trace'] = $e->getTraceAsString();
}
return $variables;
}
/**
* Build a minimal error fragment for htmx requests.
*
* For 422 (validation): an unstyled <ul> of field error messages.
* For other errors: a single <p> with the error message.
* No layout, no styles — htmx will swap this into the existing page.
*/
private function buildHtmxFragment(StatusCode $status, \Throwable $e): string
{
if (
$status === StatusCode::UnprocessableEntity
&& $e instanceof \Arcanum\Validation\ValidationException
) {
$items = '';
foreach ($e->errorsByField() as $field => $messages) {
foreach ($messages as $message) {
$items .= '<li>' . $this->escape($field) . ': '
. $this->escape($message) . '</li>';
}
}
return '<ul>' . $items . '</ul>';
}
$message = $e->getMessage();
if ($message === $status->reason()->value) {
$message = $this->defaultDescription($status);
}
return '<p>' . $this->escape($message) . '</p>';
}
/**
* Render the built-in fallback error page.
*
* Uses a pre-compiled PHP template via extract() + require, deliberately
* bypassing the Shodo TemplateEngine. The error page must render even
* when the template engine, cache, or configuration is broken.
*/
private function buildHtml(
StatusCode $status,
string $title,
\Throwable $e,
?string $suggestion,
): string {
$rawMessage = $e->getMessage();
if ($rawMessage === $status->reason()->value) {
$rawMessage = $this->defaultDescription($status);
}
$variables = [
'code' => $status->value,
'title' => $this->escape($title),
'message' => $this->escape($rawMessage),
'suggestion' => ($this->verboseErrors && $suggestion !== null)
? $this->escape($suggestion)
: null,
'exception' => $this->debug ? $this->escape(get_class($e)) : null,
'file' => $this->debug ? $this->escape($e->getFile()) : null,
'line' => $this->debug ? $e->getLine() : null,
'trace' => $this->debug ? $this->escape($e->getTraceAsString()) : null,
];
return (static function (string $__template, array $__variables): string {
extract($__variables);
ob_start();
require $__template;
return (string) ob_get_clean();
})(__DIR__ . '/Templates/compiled-error.php', $variables);
}
/**
* Human-friendly default descriptions for common HTTP error codes.
*
* Used when no custom exception message was provided. Each description
* explains what happened in plain language and hints at what to do.
*/
private function defaultDescription(StatusCode $status): string
{
return match ($status) {
StatusCode::BadRequest
=> "The request couldn't be understood — check the"
. " data you sent and try again",
StatusCode::Unauthorized
=> 'You need to sign in to access this page',
StatusCode::Forbidden
=> "You don't have permission to access this page",
StatusCode::NotFound
=> "The page you're looking for doesn't exist —"
. " it may have been moved or removed",
StatusCode::MethodNotAllowed
=> 'This URL exists, but not for that HTTP method',
StatusCode::NotAcceptable
=> "The requested format isn't available for this"
. ' resource',
StatusCode::RequestTimeout
=> 'The server waited too long for the request'
. ' — try again',
StatusCode::Conflict
=> 'The request conflicts with the current state'
. ' of the resource',
StatusCode::Gone
=> 'This resource has been permanently removed',
StatusCode::UnprocessableEntity
=> "The data you submitted couldn't be processed"
. ' — check for errors and try again',
StatusCode::TooManyRequests
=> "You've made too many requests — slow down and"
. ' try again shortly',
StatusCode::InternalServerError
=> 'Something went wrong on our end — this has'
. ' been noted and will be looked into',
StatusCode::ServiceUnavailable
=> "The service is temporarily unavailable —"
. ' try again in a moment',
default => $status->reason()->value,
};
}
private function escape(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}
}