Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
64 changes: 0 additions & 64 deletions packages/core/src/DevelopmentExceptionHandler.php

This file was deleted.

1 change: 0 additions & 1 deletion packages/core/src/ExceptionHandlerInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}
Expand Down
19 changes: 19 additions & 0 deletions packages/http/src/Responses/NotAcceptable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Responses;

use Tempest\Http\IsResponse;
use Tempest\Http\Response;
use Tempest\Http\Status;

final class NotAcceptable implements Response
{
use IsResponse;

public function __construct()
{
$this->status = Status::NOT_ACCEPTABLE;
}
}
119 changes: 119 additions & 0 deletions packages/router/src/Exceptions/HtmlExceptionRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace Tempest\Router\Exceptions;

use Tempest\Auth\Exceptions\AccessWasDenied;
use Tempest\Container\Container;
use Tempest\Core\AppConfig;
use Tempest\Http\GenericResponse;
use Tempest\Http\HttpRequestFailed;
use Tempest\Http\Request;
use Tempest\Http\Response;
use Tempest\Http\Responses\Invalid;
use Tempest\Http\Session\CsrfTokenDidNotMatch;
use Tempest\Http\Status;
use Tempest\Router\MatchedRoute;
use Tempest\Support\Filesystem;
use Tempest\Validation\Exceptions\ValidationFailed;
use Tempest\View\GenericView;
use Throwable;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Run;

final readonly class HtmlExceptionRenderer
{
public function __construct(
private AppConfig $appConfig,
private Container $container,
) {}

public function render(Throwable $throwable): Response
{
if ($throwable instanceof ConvertsToResponse) {
return $throwable->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;
}
}
45 changes: 9 additions & 36 deletions packages/router/src/Exceptions/HttpExceptionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,49 +22,26 @@ 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),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we have HtmlHttpExceptionRenderer then for consistency?

Copy link
Contributor Author

@NeoIsRecursive NeoIsRecursive Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, absolutely!

Would it make sense to remove the DeveloperExceptionHandler and in the HtmlHttpExceptionRenderer handle to show either whooosh or the custom view?

Tests for this (entire PR) is on my todo 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to remove the DeveloperExceptionHandler and in the HtmlHttpExceptionRenderer handle to show either whooosh or the custom view?

Maybe, but I'd say out of scope for this PR?

default => new GenericResponse(status: Status::NOT_ACCEPTABLE),
};

$this->responseSender->send($response);
} finally {
$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');
}
}
84 changes: 84 additions & 0 deletions packages/router/src/Exceptions/JsonExceptionRenderer.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\Core\AppConfig;
use Tempest\Http\HttpRequestFailed;
use Tempest\Http\Response;
use Tempest\Http\Responses\Json;
use Tempest\Http\Session\CsrfTokenDidNotMatch;
use Tempest\Http\Status;
use Tempest\Validation\Exceptions\ValidationFailed;
use Tempest\Validation\Rule;
use Tempest\Validation\Validator;
use Throwable;

use function Tempest\Support\arr;

final readonly class JsonExceptionRenderer
{
public function __construct(
private AppConfig $appConfig,
private Validator $validator,
) {}

public function render(Throwable $throwable): Response
{
return match (true) {
$throwable instanceof ConvertsToResponse => $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(),
}
);
}
}
Loading
Loading