diff --git a/packages/core/src/DevelopmentExceptionHandler.php b/packages/core/src/DevelopmentExceptionHandler.php deleted file mode 100644 index cf4d28db2..000000000 --- a/packages/core/src/DevelopmentExceptionHandler.php +++ /dev/null @@ -1,64 +0,0 @@ -whoops = new Run(); - $this->whoops->pushHandler($this->createHandler()); - } - - public function handle(Throwable $throwable): void - { - $this->exceptionReporter->report($throwable); - $this->whoops->handleException($throwable); - } - - private function createHandler(): HandlerInterface - { - $handler = new PrettyPageHandler(); - - $handler->addDataTableCallback('Route', function () { - $route = $this->container->get(MatchedRoute::class); - - if (! $route) { - return []; - } - - return [ - 'Handler' => $route->route->handler->getDeclaringClass()->getFileName() . ':' . $route->route->handler->getName(), - 'URI' => $route->route->uri, - 'Allowed parameters' => $route->route->parameters, - 'Received parameters' => $route->params, - ]; - }); - - $handler->addDataTableCallback('Request', function () { - $request = $this->container->get(Request::class); - - return [ - 'URI' => $request->uri, - 'Method' => $request->method->value, - 'Headers' => $request->headers->toArray(), - 'Parsed body' => array_filter(array_values($request->body)) ? $request->body : [], - 'Raw body' => $request->raw, - ]; - }); - - return $handler; - } -} diff --git a/packages/core/src/ExceptionHandlerInitializer.php b/packages/core/src/ExceptionHandlerInitializer.php index fe3e24e01..a4a291cd1 100644 --- a/packages/core/src/ExceptionHandlerInitializer.php +++ b/packages/core/src/ExceptionHandlerInitializer.php @@ -17,7 +17,6 @@ public function initialize(Container $container): ExceptionHandler return match (true) { PHP_SAPI === 'cli' => $container->get(ConsoleExceptionHandler::class), - $config->environment->isLocal() => $container->get(DevelopmentExceptionHandler::class), default => $container->get(HttpExceptionHandler::class), }; } diff --git a/packages/http/src/Responses/NotAcceptable.php b/packages/http/src/Responses/NotAcceptable.php new file mode 100644 index 000000000..5336039a0 --- /dev/null +++ b/packages/http/src/Responses/NotAcceptable.php @@ -0,0 +1,19 @@ +status = Status::NOT_ACCEPTABLE; + } +} diff --git a/packages/router/src/Exceptions/HtmlExceptionRenderer.php b/packages/router/src/Exceptions/HtmlExceptionRenderer.php new file mode 100644 index 000000000..606789c30 --- /dev/null +++ b/packages/router/src/Exceptions/HtmlExceptionRenderer.php @@ -0,0 +1,119 @@ +toResponse(); + } + + if ($this->appConfig->environment->isLocal()) { + $whoops = $this->createHandler(); + + return new GenericResponse( + status: Status::INTERNAL_SERVER_ERROR, + body: $whoops->handleException($throwable), + ); + } + + return match (true) { + $throwable instanceof RouteBindingFailed => $this->renderErrorResponse(Status::NOT_FOUND), + $throwable instanceof ValidationFailed => new Invalid($throwable->subject, $throwable->failingRules), + $throwable instanceof AccessWasDenied => $this->renderErrorResponse(Status::FORBIDDEN), + $throwable instanceof HttpRequestFailed => $this->renderErrorResponse($throwable->status, $throwable), + $throwable instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT), + default => $this->renderErrorResponse(Status::INTERNAL_SERVER_ERROR), + }; + } + + private function renderErrorResponse(Status $status, ?Throwable $exception = null): Response + { + return new GenericResponse( + status: $status, + body: new GenericView(__DIR__ . '/HttpErrorResponse/error.view.php', [ + 'css' => $this->getStyleSheet(), + 'status' => $status->value, + 'title' => $status->description(), + 'message' => $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, + }, + ]), + ); + } + + private function getStyleSheet(): string + { + return Filesystem\read_file(__DIR__ . '/HttpErrorResponse/style.css'); + } + + private function createHandler(): Run + { + $handler = new PrettyPageHandler(); + + $handler->addDataTableCallback('Route', function () { + $route = $this->container->get(MatchedRoute::class); + + if (! $route) { + return []; + } + + return [ + 'Handler' => $route->route->handler->getDeclaringClass()->getFileName() . ':' . $route->route->handler->getName(), + 'URI' => $route->route->uri, + 'Allowed parameters' => $route->route->parameters, + 'Received parameters' => $route->params, + ]; + }); + + $handler->addDataTableCallback('Request', function () { + $request = $this->container->get(Request::class); + + return [ + 'URI' => $request->uri, + 'Method' => $request->method->value, + 'Headers' => $request->headers->toArray(), + 'Parsed body' => array_filter(array_values($request->body)) ? $request->body : [], + 'Raw body' => $request->raw, + ]; + }); + + $whoops = new Run(); + + $whoops->pushHandler($handler); + + $whoops->writeToOutput(send: false); + + return $whoops; + } +} diff --git a/packages/router/src/Exceptions/HttpExceptionHandler.php b/packages/router/src/Exceptions/HttpExceptionHandler.php index fe093c0b0..9bcb6bc75 100644 --- a/packages/router/src/Exceptions/HttpExceptionHandler.php +++ b/packages/router/src/Exceptions/HttpExceptionHandler.php @@ -2,20 +2,16 @@ 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\ContentType; use Tempest\Http\GenericResponse; -use Tempest\Http\HttpRequestFailed; -use Tempest\Http\Response; -use Tempest\Http\Session\CsrfTokenDidNotMatch; +use Tempest\Http\Request; use Tempest\Http\Status; use Tempest\Router\ResponseSender; -use Tempest\Support\Filesystem; -use Tempest\View\GenericView; use Throwable; final readonly class HttpExceptionHandler implements ExceptionHandler @@ -26,19 +22,21 @@ public function __construct( private ResponseSender $responseSender, private Container $container, private ExceptionReporter $exceptionReporter, + private JsonExceptionRenderer $jsonHandler, + private HtmlExceptionRenderer $htmlHandler, ) {} public function handle(Throwable $throwable): void { + $request = $this->container->get(Request::class); + 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), - $throwable instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT), - default => $this->renderErrorResponse(Status::INTERNAL_SERVER_ERROR), + $request->accepts(ContentType::HTML, ContentType::XHTML) => $this->htmlHandler->render($throwable), + $request->accepts(ContentType::JSON) => $this->jsonHandler->render($throwable), + default => new GenericResponse(status: Status::NOT_ACCEPTABLE), }; $this->responseSender->send($response); @@ -46,29 +44,4 @@ public function handle(Throwable $throwable): void $this->kernel->shutdown(); } } - - private function renderErrorResponse(Status $status, ?HttpRequestFailed $exception = null): Response - { - return new GenericResponse( - status: $status, - body: new GenericView(__DIR__ . '/HttpErrorResponse/error.view.php', [ - 'css' => $this->getStyleSheet(), - 'status' => $status->value, - 'title' => $status->description(), - 'message' => $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, - }, - ]), - ); - } - - private function getStyleSheet(): string - { - return Filesystem\read_file(__DIR__ . '/HttpErrorResponse/style.css'); - } } diff --git a/packages/router/src/Exceptions/JsonExceptionRenderer.php b/packages/router/src/Exceptions/JsonExceptionRenderer.php new file mode 100644 index 000000000..f60b12e58 --- /dev/null +++ b/packages/router/src/Exceptions/JsonExceptionRenderer.php @@ -0,0 +1,84 @@ + $throwable->toResponse(), + $throwable instanceof ValidationFailed => $this->renderValidationErrorResponse($throwable), + $throwable instanceof RouteBindingFailed => $this->renderErrorResponse(Status::NOT_FOUND), + $throwable instanceof AccessWasDenied => $this->renderErrorResponse(Status::FORBIDDEN), + $throwable instanceof HttpRequestFailed => $this->renderErrorResponse($throwable->status, $throwable), + $throwable instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT), + default => $this->renderErrorResponse(Status::INTERNAL_SERVER_ERROR, $throwable), + }; + } + + private function renderValidationErrorResponse(ValidationFailed $exception): Response + { + $errors = arr($exception->failingRules)->map( + fn (array $failingRulesForField, string $field) => arr($failingRulesForField)->map( + fn (Rule $rule) => $this->validator->getErrorMessage($rule, $field), + )->toArray(), + ); + + return new Json([ + 'message' => $errors->first()[0], + 'errors' => $errors->toArray(), + ])->setStatus(Status::UNPROCESSABLE_CONTENT); + } + + private function renderErrorResponse(Status $status, ?Throwable $exception = null): Response + { + return new Json( + $this->appConfig->environment->isLocal() && $exception !== null + ? [ + 'message' => static::getErrorMessage($status, $exception), + '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 => $status->description(), + } + ); + } +} diff --git a/packages/router/src/HandleRouteExceptionMiddleware.php b/packages/router/src/HandleRouteExceptionMiddleware.php index 72188081c..81bbf2b9a 100644 --- a/packages/router/src/HandleRouteExceptionMiddleware.php +++ b/packages/router/src/HandleRouteExceptionMiddleware.php @@ -3,12 +3,15 @@ namespace Tempest\Router; use Tempest\Core\Priority; +use Tempest\Http\ContentType; use Tempest\Http\HttpRequestFailed; use Tempest\Http\Request; use Tempest\Http\Response; use Tempest\Http\Responses\Invalid; +use Tempest\Http\Responses\NotAcceptable; use Tempest\Http\Responses\NotFound; use Tempest\Router\Exceptions\ConvertsToResponse; +use Tempest\Router\Exceptions\JsonExceptionRenderer; use Tempest\Router\Exceptions\RouteBindingFailed; use Tempest\Validation\Exceptions\ValidationFailed; @@ -17,6 +20,7 @@ { public function __construct( private RouteConfig $routeConfig, + private JsonExceptionRenderer $jsonHandler, ) {} public function __invoke(Request $request, HttpMiddlewareCallable $next): Response @@ -44,9 +48,19 @@ private function forward(Request $request, HttpMiddlewareCallable $next): Respon return $next($request); } catch (ConvertsToResponse $convertsToResponse) { return $convertsToResponse->toResponse(); - } catch (RouteBindingFailed) { - return new NotFound(); + } catch (RouteBindingFailed $routeBindingFailed) { + return match (true) { + $request->accepts(ContentType::HTML, ContentType::XHTML) => new NotFound(), + $request->accepts(ContentType::JSON) => $this->jsonHandler->render($routeBindingFailed), + default => new NotAcceptable(), + }; } catch (ValidationFailed $validationException) { + return match (true) { + $request->accepts(ContentType::HTML, ContentType::XHTML) => new Invalid($validationException->subject, $validationException->failingRules), + $request->accepts(ContentType::JSON) => $this->jsonHandler->render($validationException), + default => new NotAcceptable(), + }; + return new Invalid($validationException->subject, $validationException->failingRules); } } diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index 3b1af2dcd..a4f12c1b3 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -245,6 +245,16 @@ public function test_converts_to_response(): void ->assertHeaderContains('Location', 'https://tempestphp.com'); } + public function test_router_returns_json_exception_when_accepts_json(): void + { + $this->registerRoute([Http500Controller::class, 'throwsException']); + + $this->http + ->get('/throws-exception', headers: ['Accept' => 'application/json']) + ->assertStatus(Status::INTERNAL_SERVER_ERROR) + ->assertJsonHasKeys('message'); + } + public function test_head_requests(): void { $this->registerRoute([HeadController::class, 'implicitHead']);