Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/core/src/DevelopmentExceptionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Tempest\Container\Container;
use Tempest\Http\Request;
use Tempest\Router\Exceptions\JsonHttpExceptionHandler;
use Tempest\Router\MatchedRoute;
use Throwable;
use Whoops\Handler\HandlerInterface;
Expand All @@ -17,13 +18,19 @@
public function __construct(
private Container $container,
private ExceptionReporter $exceptionReporter,
private JsonHttpExceptionHandler $jsonHandler,
) {
$this->whoops = new Run();
$this->whoops->pushHandler($this->createHandler());
}

public function handle(Throwable $throwable): void
{
if ($this->container->get(Request::class)->headers->get('accept') === 'application/json') {
$this->jsonHandler->handle($throwable);
return;
}

$this->exceptionReporter->report($throwable);
$this->whoops->handleException($throwable);
}
Expand Down
10 changes: 10 additions & 0 deletions packages/router/src/Exceptions/HttpExceptionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Tempest\Core\Kernel;
use Tempest\Http\GenericResponse;
use Tempest\Http\HttpRequestFailed;
use Tempest\Http\Request;
use Tempest\Http\Response;
use Tempest\Http\Session\CsrfTokenDidNotMatch;
use Tempest\Http\Status;
Expand All @@ -18,6 +19,9 @@
use Tempest\View\GenericView;
use Throwable;

use function Tempest\get;
use function Tempest\Support\arr;

final readonly class HttpExceptionHandler implements ExceptionHandler
{
public function __construct(
Expand All @@ -26,10 +30,16 @@ public function __construct(
private ResponseSender $responseSender,
private Container $container,
private ExceptionReporter $exceptionReporter,
private JsonHttpExceptionHandler $jsonHandler,
) {}

public function handle(Throwable $throwable): void
{
if (get(Request::class)->headers->get('Accept') === 'application/json') {
$this->jsonHandler->handle($throwable);
return;
}

try {
$this->exceptionReporter->report($throwable);

Expand Down
84 changes: 84 additions & 0 deletions packages/router/src/Exceptions/JsonHttpExceptionHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace Tempest\Router\Exceptions;

use Tempest\Auth\Exceptions\AccessWasDenied;
use Tempest\Container\Container;
use Tempest\Core\AppConfig;
use Tempest\Core\ExceptionHandler;
use Tempest\Core\ExceptionReporter;
use Tempest\Core\Kernel;
use Tempest\Http\HttpRequestFailed;
use Tempest\Http\Response;
use Tempest\Http\Responses\Json;
use Tempest\Http\Session\CsrfTokenDidNotMatch;
use Tempest\Http\Status;
use Tempest\Router\ResponseSender;
use Throwable;

use function Tempest\Support\arr;

final readonly class JsonHttpExceptionHandler implements ExceptionHandler
{
public function __construct(
private AppConfig $appConfig,
private Kernel $kernel,
private ResponseSender $responseSender,
private Container $container,
private ExceptionReporter $exceptionReporter,
) {}

public function handle(Throwable $throwable): void
{
try {
$this->exceptionReporter->report($throwable);

$response = match (true) {
$throwable instanceof ConvertsToResponse => $throwable->toResponse(),
$throwable instanceof AccessWasDenied => $this->renderErrorResponse(Status::FORBIDDEN),
$throwable instanceof HttpRequestFailed => $this->renderErrorResponse($throwable->status),
$throwable instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT),
default => $this->renderErrorResponse(
Status::INTERNAL_SERVER_ERROR,
$this->appConfig->environment->isLocal() ? $throwable : null,
),
};

$this->responseSender->send($response);
} finally {
$this->kernel->shutdown();
}
}

private function renderErrorResponse(Status $status, ?Throwable $exception = null): Response
{
return new Json(
$this->appConfig->environment->isLocal() && $exception !== null
? [
'message' => $exception->getMessage(),
'exception' => get_class($exception),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => arr($exception->getTrace())->map(
fn ($trace) => arr($trace)->removeKeys('args')->toArray(),
)->toArray(),
] : [
'message' => static::getErrorMessage($status, $exception),
],
)->setStatus($status);
}

private static function getErrorMessage(Status $status, ?Throwable $exception = null): ?string
{
return (
$exception?->getMessage() ?: match ($status) {
Status::INTERNAL_SERVER_ERROR => 'An unexpected server error occurred',
Status::NOT_FOUND => 'This page could not be found on the server',
Status::FORBIDDEN => 'You do not have permission to access this page',
Status::UNAUTHORIZED => 'You must be authenticated in to access this page',
Status::UNPROCESSABLE_CONTENT => 'The request could not be processed due to invalid data',
default => null,
}
);
}
}
35 changes: 34 additions & 1 deletion packages/router/src/HandleRouteExceptionMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@
use Tempest\Http\Request;
use Tempest\Http\Response;
use Tempest\Http\Responses\Invalid;
use Tempest\Http\Responses\Json;
use Tempest\Http\Responses\NotFound;
use Tempest\Http\Status;
use Tempest\Router\Exceptions\ConvertsToResponse;
use Tempest\Router\Exceptions\RouteBindingFailed;
use Tempest\Validation\Exceptions\ValidationFailed;
use Tempest\Validation\Rule;
use Tempest\Validation\Validator;

use function Tempest\get;
use function Tempest\Support\arr;

#[Priority(Priority::FRAMEWORK - 10)]
final readonly class HandleRouteExceptionMiddleware implements HttpMiddleware
Expand All @@ -24,7 +31,9 @@ public function __invoke(Request $request, HttpMiddlewareCallable $next): Respon
if ($this->routeConfig->throwHttpExceptions === true) {
$response = $this->forward($request, $next);

if ($response->status->isServerError() || $response->status->isClientError()) {
$isValidationError = $response->status === Status::UNPROCESSABLE_CONTENT;

if (! $isValidationError && ($response->status->isServerError() || $response->status->isClientError())) {
throw new HttpRequestFailed(
request: $request,
status: $response->status,
Expand All @@ -38,15 +47,39 @@ public function __invoke(Request $request, HttpMiddlewareCallable $next): Respon
return $this->forward($request, $next);
}

private function wantsJson(Request $request): bool
{
return $request->headers->get('Accept') === 'application/json';
}

private function forward(Request $request, HttpMiddlewareCallable $next): Response
{
try {
return $next($request);
} catch (ConvertsToResponse $convertsToResponse) {
return $convertsToResponse->toResponse();
} catch (RouteBindingFailed) {
if ($this->wantsJson($request)) {
return new NotFound([
'message' => 'The requested resource was not found.',
]);
}

return new NotFound();
} catch (ValidationFailed $validationException) {
if ($this->wantsJson($request)) {
$errors = arr($validationException->failingRules)->map(
fn (array $failingRulesForField, string $field) => arr($failingRulesForField)->map(
fn (Rule $rule) => get(Validator::class)->getErrorMessage($rule, $field),
)->toArray(),
);

return new Json([
'message' => $errors->first()[0],
'errors' => $errors->toArray(),
])->setStatus(Status::UNPROCESSABLE_CONTENT);
}

return new Invalid($validationException->subject, $validationException->failingRules);
}
}
Expand Down
Loading