Skip to content

Commit a546674

Browse files
committed
refactor(core): improve http error handling
1 parent 1af376a commit a546674

27 files changed

+457
-347
lines changed

packages/console/src/Exceptions/ConsoleExceptionHandler.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@
2525
final readonly class ConsoleExceptionHandler implements ExceptionHandler
2626
{
2727
public function __construct(
28-
private AppConfig $appConfig,
29-
private Container $container,
3028
private Kernel $kernel,
3129
#[Tag('console')]
3230
private Highlighter $highlighter,

packages/core/src/DevelopmentExceptionHandler.php

Lines changed: 0 additions & 64 deletions
This file was deleted.

packages/core/src/ExceptionHandlerInitializer.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ public function initialize(Container $container): ExceptionHandler
1717

1818
return match (true) {
1919
PHP_SAPI === 'cli' => $container->get(ConsoleExceptionHandler::class),
20-
$config->environment->isLocal() => $container->get(DevelopmentExceptionHandler::class),
2120
default => $container->get(HttpExceptionHandler::class),
2221
};
2322
}

packages/core/src/FrameworkKernel.php

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
use Dotenv\Dotenv;
88
use ErrorException;
99
use RuntimeException;
10-
use Tempest\Console\Exceptions\ConsoleExceptionHandler;
1110
use Tempest\Container\Container;
1211
use Tempest\Container\GenericContainer;
1312
use Tempest\Core\Kernel\FinishDeferredTasks;
@@ -17,7 +16,6 @@
1716
use Tempest\Core\Kernel\RegisterEmergencyExceptionHandler;
1817
use Tempest\EventBus\EventBus;
1918
use Tempest\Process\GenericProcessExecutor;
20-
use Tempest\Router\Exceptions\HttpExceptionHandler;
2119
use Tempest\Support\Filesystem;
2220

2321
final class FrameworkKernel implements Kernel
@@ -248,11 +246,6 @@ public function registerExceptionHandler(): self
248246
return $this;
249247
}
250248

251-
// TODO: refactor to not have a hard-coded dependency on these exception handlers
252-
if (! class_exists(ConsoleExceptionHandler::class) || ! class_exists(HttpExceptionHandler::class)) {
253-
return $this;
254-
}
255-
256249
$handler = $this->container->get(ExceptionHandler::class);
257250

258251
set_exception_handler($handler->handle(...));

packages/http/src/HttpRequestFailed.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
final class HttpRequestFailed extends Exception implements HasContext
1313
{
1414
public function __construct(
15-
public readonly Request $request,
16-
public readonly Status $status,
15+
private(set) readonly Status $status,
1716
?string $message = null,
18-
public readonly ?Response $cause = null,
17+
private(set) readonly ?Response $cause = null,
18+
private(set) readonly ?Request $request = null,
1919
?Throwable $previous = null,
2020
) {
2121
parent::__construct(
22-
message: $message ?: 'Failed request: ' . $status->value . ' ' . $this->status->description(),
22+
message: $message ?: '',
2323
code: $status->value,
2424
previous: $previous,
2525
);
@@ -28,8 +28,8 @@ public function __construct(
2828
public function context(): array
2929
{
3030
return [
31-
'request_uri' => $this->request->uri,
32-
'request_method' => $this->request->method->value,
31+
'request_uri' => $this->request?->uri,
32+
'request_method' => $this->request?->method->value,
3333
'status' => $this->status->value,
3434
'message' => $this->message,
3535
'cause' => $this->cause,

packages/http/src/Responses/Invalid.php

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,12 @@
1111
use Tempest\Http\Session\Session;
1212
use Tempest\Http\Status;
1313
use Tempest\Reflection\ClassReflector;
14+
use Tempest\Support\Arr;
1415
use Tempest\Support\Json;
1516
use Tempest\Validation\FailingRule;
16-
use Tempest\Validation\Rule;
1717
use Tempest\Validation\Validator;
1818

1919
use function Tempest\get;
20-
use function Tempest\Support\arr;
2120

2221
final class Invalid implements Response
2322
{
@@ -40,21 +39,17 @@ public function __construct(
4039
$this->addHeader('Location', $referer);
4140
$this->status = Status::FOUND;
4241
} else {
43-
$this->status = Status::BAD_REQUEST;
42+
$this->status = Status::UNPROCESSABLE_CONTENT;
4443
}
4544

4645
$this->flash(Session::VALIDATION_ERRORS, $failingRules);
4746
$this->flash(Session::ORIGINAL_VALUES, $this->filterSensitiveFields($request, $targetClass));
48-
$this->addHeader(
49-
'x-validation',
50-
Json\encode(
51-
arr($failingRules)->map(
52-
fn (array $failingRulesForField) => arr($failingRulesForField)->map(
53-
fn (FailingRule $rule) => $this->validator->getErrorMessage($rule),
54-
)->toArray(),
55-
)->toArray(),
56-
),
57-
);
47+
$this->addHeader('x-validation', value: Json\encode(
48+
Arr\map_iterable($failingRules, fn (array $failingRulesForField) => Arr\map_iterable(
49+
array: $failingRulesForField,
50+
map: fn (FailingRule $rule) => $this->validator->getErrorMessage($rule),
51+
)),
52+
));
5853
}
5954

6055
/**
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http\Responses;
6+
7+
use Tempest\Http\IsResponse;
8+
use Tempest\Http\Response;
9+
use Tempest\Http\Status;
10+
11+
final class NotAcceptable implements Response
12+
{
13+
use IsResponse;
14+
15+
public function __construct()
16+
{
17+
$this->status = Status::NOT_ACCEPTABLE;
18+
}
19+
}

packages/icon/src/Icon.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ private function fetchSvg(string $collection, string $icon): ?string
6464

6565
if ($response->status !== Status::OK) {
6666
throw new HttpRequestFailed(
67-
request: new GenericRequest(Method::GET, $url),
6867
status: $response->status,
6968
cause: $response,
7069
);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<?php
2+
3+
namespace Tempest\Router\Exceptions;
4+
5+
use Tempest\Auth\Exceptions\AccessWasDenied;
6+
use Tempest\Container\Container;
7+
use Tempest\Core\AppConfig;
8+
use Tempest\Http\GenericResponse;
9+
use Tempest\Http\HttpRequestFailed;
10+
use Tempest\Http\Request;
11+
use Tempest\Http\Response;
12+
use Tempest\Http\Responses\Invalid;
13+
use Tempest\Http\Session\CsrfTokenDidNotMatch;
14+
use Tempest\Http\Status;
15+
use Tempest\Router\MatchedRoute;
16+
use Tempest\Support\Filesystem;
17+
use Tempest\Validation\Exceptions\ValidationFailed;
18+
use Tempest\View\GenericView;
19+
use Throwable;
20+
use Whoops\Handler\PrettyPageHandler;
21+
use Whoops\Run;
22+
23+
final readonly class HtmlExceptionRenderer
24+
{
25+
public function __construct(
26+
private AppConfig $appConfig,
27+
private Container $container,
28+
) {}
29+
30+
public function render(Throwable $throwable): Response
31+
{
32+
if ($throwable instanceof ConvertsToResponse) {
33+
return $throwable->toResponse();
34+
}
35+
36+
if ($this->shouldRenderDevelopmentException($throwable)) {
37+
$whoops = $this->createHandler();
38+
39+
return new GenericResponse(
40+
status: Status::INTERNAL_SERVER_ERROR,
41+
body: $whoops->handleException($throwable),
42+
);
43+
}
44+
45+
return match (true) {
46+
$throwable instanceof ValidationFailed => new Invalid($throwable->subject, $throwable->failingRules, $throwable->targetClass),
47+
$throwable instanceof RouteBindingFailed => $this->renderErrorResponse(Status::NOT_FOUND),
48+
$throwable instanceof AccessWasDenied => $this->renderErrorResponse(Status::FORBIDDEN),
49+
$throwable instanceof HttpRequestFailed => $this->renderErrorResponse($throwable->status, $throwable),
50+
$throwable instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT),
51+
default => $this->renderErrorResponse(Status::INTERNAL_SERVER_ERROR, $throwable),
52+
};
53+
}
54+
55+
private function renderErrorResponse(Status $status, ?Throwable $exception = null): Response
56+
{
57+
if ($exception instanceof HttpRequestFailed && $exception->cause?->body) {
58+
return $exception->cause;
59+
}
60+
61+
return new GenericResponse(
62+
status: $status,
63+
body: new GenericView(__DIR__ . '/html/error.view.php', [
64+
'css' => $this->getStyleSheet(),
65+
'status' => $status->value,
66+
'title' => $status->description(),
67+
'message' => $exception?->getMessage() ?: match ($status) {
68+
Status::INTERNAL_SERVER_ERROR => 'An unexpected server error occurred',
69+
Status::NOT_FOUND => 'This page could not be found on the server',
70+
Status::FORBIDDEN => 'You do not have permission to access this page',
71+
Status::UNAUTHORIZED => 'You must be authenticated in to access this page',
72+
Status::UNPROCESSABLE_CONTENT => 'The request could not be processed due to invalid data',
73+
default => null,
74+
},
75+
]),
76+
);
77+
}
78+
79+
private function getStyleSheet(): string
80+
{
81+
return Filesystem\read_file(__DIR__ . '/html/style.css');
82+
}
83+
84+
private function shouldRenderDevelopmentException(Throwable $throwable): bool
85+
{
86+
if (! $this->appConfig->environment->isLocal()) {
87+
return false;
88+
}
89+
90+
if (! $throwable instanceof HttpRequestFailed) {
91+
return true;
92+
}
93+
94+
if ($throwable->status === Status::NOT_FOUND) {
95+
return false;
96+
}
97+
98+
return true;
99+
}
100+
101+
private function createHandler(): Run
102+
{
103+
$handler = new PrettyPageHandler();
104+
105+
$handler->addDataTableCallback('Route', function () {
106+
$route = $this->container->get(MatchedRoute::class);
107+
108+
if (! $route) {
109+
return [];
110+
}
111+
112+
return [
113+
'Handler' => $route->route->handler->getDeclaringClass()->getFileName() . ':' . $route->route->handler->getName(),
114+
'URI' => $route->route->uri,
115+
'Allowed parameters' => $route->route->parameters,
116+
'Received parameters' => $route->params,
117+
];
118+
});
119+
120+
$handler->addDataTableCallback('Request', function () {
121+
$request = $this->container->get(Request::class);
122+
123+
return [
124+
'URI' => $request->uri,
125+
'Method' => $request->method->value,
126+
'Headers' => $request->headers->toArray(),
127+
'Parsed body' => array_filter(array_values($request->body)) ? $request->body : [],
128+
'Raw body' => $request->raw,
129+
];
130+
});
131+
132+
$whoops = new Run();
133+
$whoops->pushHandler($handler);
134+
135+
return $whoops;
136+
}
137+
}

0 commit comments

Comments
 (0)