Skip to content

Commit 86a28c5

Browse files
authored
refactor(router): improve exception handling (#1263)
1 parent 36edbd8 commit 86a28c5

File tree

13 files changed

+105
-86
lines changed

13 files changed

+105
-86
lines changed

packages/http/src/HttpException.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
namespace Tempest\Http;
44

55
use Exception;
6+
use Tempest\Core\HasContext;
67

78
/**
89
* Represents an HTTP exception.
910
*/
10-
final class HttpException extends Exception
11+
final class HttpException extends Exception implements HasContext
1112
{
1213
public function __construct(
1314
public readonly Status $status,
@@ -17,4 +18,14 @@ public function __construct(
1718
) {
1819
parent::__construct($message ?: '', $status->value, $previous);
1920
}
21+
22+
public function context(): array
23+
{
24+
return [
25+
'status' => $this->status->value,
26+
'message' => $this->message,
27+
'response' => $this->response,
28+
'previous' => $this->getPrevious()?->getMessage(),
29+
];
30+
}
2031
}

packages/http/src/Responses/ServerError.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Tempest\Http\Responses;
66

7+
use Exception;
78
use Generator;
89
use Tempest\Http\IsResponse;
910
use Tempest\Http\Response;
@@ -14,9 +15,12 @@ final class ServerError implements Response
1415
{
1516
use IsResponse;
1617

17-
public function __construct(View|Generator|string|array|null $body = null)
18+
private(set) ?Exception $exception = null;
19+
20+
public function __construct(View|Generator|string|array|null $body = null, ?Exception $exception = null)
1821
{
1922
$this->status = Status::INTERNAL_SERVER_ERROR;
2023
$this->body = $body;
24+
$this->exception = $exception;
2125
}
2226
}

packages/router/src/Exceptions/HttpErrorResponseProcessor.php

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,35 @@
66
use Tempest\Http\HttpException;
77
use Tempest\Http\Response;
88
use Tempest\Router\ResponseProcessor;
9+
use Tempest\Router\RouteConfig;
910

1011
final readonly class HttpErrorResponseProcessor implements ResponseProcessor
1112
{
1213
public function __construct(
1314
private AppConfig $appConfig,
15+
private RouteConfig $routeConfig,
1416
) {}
1517

1618
public function process(Response $response): Response
1719
{
18-
// Throwing an HttpException during tests would make testing more
19-
// complex, and is not strictly needed. During development, we
20-
// don't need exceptions either, since the exception handler
21-
// is different. For this reason, we skip processing here.
22-
if (! $this->appConfig->environment->isProduction()) {
20+
// If the response is not a server or client error, we don't need to
21+
// handle it. In this case, we simply return back the response.
22+
if (! $response->status->isServerError() && ! $response->status->isClientError()) {
2323
return $response;
2424
}
2525

26-
// Don't handle responses that already have a body. This is to avoid
27-
// interferring with error responses voluntarily thrown in userland.
26+
// If the response already has a body, it means it is most likely
27+
// meant to be returned as-is, so we don't have to throw an exception.
2828
if ($response->body) {
2929
return $response;
3030
}
3131

32-
// We throw an exception on server and client errors,
33-
// which is handled in the HTTP exception handler.
34-
if ($response->status->isServerError() || $response->status->isClientError()) {
35-
throw new HttpException(status: $response->status, response: $response);
32+
// During tests, the router is generally configured to not throw HTTP exceptions in order
33+
// to perform assertions on the responses. In this case, we return the response as is.
34+
if (! $this->routeConfig->throwHttpExceptions) {
35+
return $response;
3636
}
3737

38-
return $response;
38+
throw new HttpException(status: $response->status, response: $response);
3939
}
4040
}

packages/router/src/GenericRouter.php

Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
namespace Tempest\Router;
66

77
use BackedEnum;
8+
use Exception;
89
use Psr\Http\Message\ServerRequestInterface as PsrRequest;
9-
use ReflectionException;
1010
use Tempest\Container\Container;
1111
use Tempest\Core\AppConfig;
1212
use Tempest\Http\GenericRequest;
@@ -18,6 +18,7 @@
1818
use Tempest\Http\Responses\Invalid;
1919
use Tempest\Http\Responses\NotFound;
2020
use Tempest\Http\Responses\Ok;
21+
use Tempest\Http\Responses\ServerError;
2122
use Tempest\Mapper\ObjectFactory;
2223
use Tempest\Reflection\ClassReflector;
2324
use Tempest\Router\Exceptions\ControllerActionHasNoReturn;
@@ -32,24 +33,15 @@
3233
use function Tempest\Support\Regex\replace;
3334
use function Tempest\Support\str;
3435

35-
final class GenericRouter implements Router
36+
final readonly class GenericRouter implements Router
3637
{
37-
private bool $handleExceptions = true;
38-
3938
public function __construct(
40-
private readonly Container $container,
41-
private readonly RouteMatcher $routeMatcher,
42-
private readonly AppConfig $appConfig,
43-
private readonly RouteConfig $routeConfig,
39+
private Container $container,
40+
private RouteMatcher $routeMatcher,
41+
private AppConfig $appConfig,
42+
private RouteConfig $routeConfig,
4443
) {}
4544

46-
public function throwExceptions(): self
47-
{
48-
$this->handleExceptions = false;
49-
50-
return $this;
51-
}
52-
5345
public function dispatch(Request|PsrRequest $request): Response
5446
{
5547
return $this->processResponse(
@@ -69,25 +61,16 @@ private function processRequest(Request|PsrRequest $request): Response
6961
return new NotFound();
7062
}
7163

72-
$this->container->singleton(
73-
MatchedRoute::class,
74-
fn () => $matchedRoute,
75-
);
76-
77-
$callable = $this->getCallable($matchedRoute);
64+
$this->container->singleton(MatchedRoute::class, fn () => $matchedRoute);
7865

79-
if ($this->handleExceptions) {
80-
try {
81-
$request = $this->resolveRequest($request, $matchedRoute);
82-
$response = $callable($request);
83-
} catch (NotFoundException) {
84-
return new NotFound();
85-
} catch (ValidationException $validationException) {
86-
return new Invalid($validationException->subject, $validationException->failingRules);
87-
}
88-
} else {
66+
try {
67+
$callable = $this->getCallable($matchedRoute);
8968
$request = $this->resolveRequest($request, $matchedRoute);
9069
$response = $callable($request);
70+
} catch (NotFoundException) {
71+
return new NotFound();
72+
} catch (ValidationException $validationException) {
73+
return new Invalid($validationException->subject, $validationException->failingRules);
9174
}
9275

9376
return $response;

packages/router/src/RouteConfig.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public function __construct(
2525
public Middleware $middleware = new Middleware(
2626
SetCookieMiddleware::class,
2727
),
28+
29+
public bool $throwHttpExceptions = true,
2830
) {}
2931

3032
public function apply(RouteConfig $newConfig): void

packages/router/src/Static/StaticGenerateCommand.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Tempest\Http\Status;
1919
use Tempest\HttpClient\HttpClient;
2020
use Tempest\Router\DataProvider;
21+
use Tempest\Router\RouteConfig;
2122
use Tempest\Router\Router;
2223
use Tempest\Router\Static\Exceptions\DeadLinksDetectedException;
2324
use Tempest\Router\Static\Exceptions\InvalidStatusCodeException;
@@ -44,6 +45,7 @@ final class StaticGenerateCommand
4445

4546
public function __construct(
4647
private readonly AppConfig $appConfig,
48+
private readonly RouteConfig $routeConfig,
4749
private readonly Console $console,
4850
private readonly Kernel $kernel,
4951
private readonly Container $container,
@@ -96,6 +98,8 @@ public function __invoke(
9698
};
9799
});
98100

101+
$this->routeConfig->throwHttpExceptions = false;
102+
99103
foreach ($this->staticPageConfig->staticPages as $staticPage) {
100104
/** @var DataProvider $dataProvider */
101105
$dataProvider = $this->container->get($staticPage->dataProviderClass ?? GenericDataProvider::class);

src/Tempest/Framework/Testing/Http/HttpRouterTester.php

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111
use Tempest\Http\Mappers\RequestToPsrRequestMapper;
1212
use Tempest\Http\Method;
1313
use Tempest\Http\Request;
14-
use Tempest\Router\GenericRouter;
14+
use Tempest\Router\RouteConfig;
1515
use Tempest\Router\Router;
1616

1717
use function Tempest\map;
1818

1919
final class HttpRouterTester
2020
{
21+
private bool $throwHttpExceptions = false;
22+
2123
public function __construct(
2224
private Container $container,
2325
) {}
@@ -34,17 +36,6 @@ public function get(string $uri, array $headers = []): TestResponseHelper
3436
);
3537
}
3638

37-
public function throwExceptions(): self
38-
{
39-
$router = $this->container->get(Router::class);
40-
41-
if ($router instanceof GenericRouter) {
42-
$router->throwExceptions();
43-
}
44-
45-
return $this;
46-
}
47-
4839
public function head(string $uri, array $headers = []): TestResponseHelper
4940
{
5041
return $this->sendRequest(
@@ -146,11 +137,23 @@ public function sendRequest(Request $request): TestResponseHelper
146137
/** @var Router $router */
147138
$router = $this->container->get(Router::class);
148139

140+
$this->container->get(RouteConfig::class)->throwHttpExceptions = $this->throwHttpExceptions;
141+
149142
return new TestResponseHelper(
150143
$router->dispatch(map($request)->with(RequestToPsrRequestMapper::class)->do()),
151144
);
152145
}
153146

147+
/**
148+
* Instructs the router to throw {@see Tempest\Http\HttpException} errors when an error response is returned. This mimics production behavior.
149+
*/
150+
public function throwExceptions(bool $throw = true): self
151+
{
152+
$this->throwHttpExceptions = $throw;
153+
154+
return $this;
155+
}
156+
154157
public function makePsrRequest(
155158
string $uri,
156159
Method $method = Method::GET,

tests/Integration/Application/HttpApplicationTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ final class HttpApplicationTest extends FrameworkIntegrationTestCase
1414
public function test_http_application_run(): void
1515
{
1616
$this->http
17-
->throwExceptions()
1817
->get('/')
1918
->assertOk();
2019
}

tests/Integration/Http/GenericResponseSenderTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ public function test_stream(): void
119119
$response = new EventStream(fn () => yield 'hello');
120120
$responseSender = $this->container->get(GenericResponseSender::class);
121121
$responseSender->send($response);
122+
122123
$output = ob_get_clean();
123124

124125
// restore phpunit's output buffer
@@ -137,6 +138,7 @@ public function test_stream_with_custom_event(): void
137138
});
138139
$responseSender = $this->container->get(GenericResponseSender::class);
139140
$responseSender->send($response);
141+
140142
$output = ob_get_clean();
141143

142144
// restore phpunit's output buffer

tests/Integration/Route/Fixtures/Http500Controller.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,25 @@
44

55
use Exception;
66
use Tempest\Http\Responses\ServerError;
7+
use Tempest\Router\Get;
78

89
final class Http500Controller
910
{
10-
public function basic500(): string
11+
#[Get('/throws-exception')]
12+
public function throwsException(): string
1113
{
1214
throw new Exception('oops');
1315
}
1416

15-
public function custom500(): ServerError
17+
#[Get('/returns-server-error')]
18+
public function serverError(): ServerError
1619
{
17-
return new ServerError('internal server error');
20+
return new ServerError();
21+
}
22+
23+
#[Get('/returns-server-error-with-body')]
24+
public function serverErrorWithBody(): ServerError
25+
{
26+
return new ServerError('custom error');
1827
}
1928
}

0 commit comments

Comments
 (0)