Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0|^2.0",
"psr/log": "^3.0.0",
"rector/rector": "^2.1",
"rector/rector": "2.2.1",
"symfony/cache": "^7.3",
"symfony/mailer": "^7.2.6",
"symfony/process": "^7.3",
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/DevelopmentExceptionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
namespace Tempest\Core;

use Tempest\Container\Container;
use Tempest\Http\ContentType;
use Tempest\Http\Request;
use Tempest\Router\Exceptions\JsonHttpExceptionRenderer;
use Tempest\Router\MatchedRoute;
use Throwable;
use Whoops\Handler\HandlerInterface;
Expand All @@ -17,6 +19,7 @@
public function __construct(
private Container $container,
private ExceptionReporter $exceptionReporter,
private JsonHttpExceptionRenderer $jsonHandler,
) {
$this->whoops = new Run();
$this->whoops->pushHandler($this->createHandler());
Expand All @@ -25,7 +28,14 @@ public function __construct(
public function handle(Throwable $throwable): void
{
$this->exceptionReporter->report($throwable);
$this->whoops->handleException($throwable);

$request = $this->container->get(Request::class);

match (true) {
$request->accepts(ContentType::HTML, ContentType::XHTML) => $this->whoops->handleException($throwable),
$request->accepts(ContentType::JSON) => $this->jsonHandler->render($throwable),
default => $this->whoops->handleException($throwable),
};
}

private function createHandler(): HandlerInterface
Expand Down
1 change: 0 additions & 1 deletion packages/http/src/IsRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use Tempest\Validation\SkipValidation;

use function Tempest\get;
use function Tempest\Support\Arr\every;
use function Tempest\Support\Arr\get_by_key;
use function Tempest\Support\Arr\has_key;
use function Tempest\Support\str;
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;
}
}
24 changes: 19 additions & 5 deletions packages/router/src/Exceptions/HttpExceptionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
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\Request;
use Tempest\Http\Response;
use Tempest\Http\Session\CsrfTokenDidNotMatch;
use Tempest\Http\Status;
Expand All @@ -26,19 +28,20 @@ public function __construct(
private ResponseSender $responseSender,
private Container $container,
private ExceptionReporter $exceptionReporter,
private JsonHttpExceptionRenderer $jsonHandler,
) {}

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->handleHtml($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);
Expand All @@ -47,6 +50,17 @@ public function handle(Throwable $throwable): void
}
}

private function handleHtml(Throwable $throwable): Response
{
return 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),
};
}

private function renderErrorResponse(Status $status, ?HttpRequestFailed $exception = null): Response
{
return new GenericResponse(
Expand Down
94 changes: 94 additions & 0 deletions packages/router/src/Exceptions/JsonHttpExceptionRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace Tempest\Router\Exceptions;

use Tempest\Auth\Exceptions\AccessWasDenied;
use Tempest\Container\Container;
use Tempest\Core\AppConfig;
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 Tempest\Validation\Exceptions\ValidationFailed;
use Tempest\Validation\Rule;
use Tempest\Validation\Validator;
use Throwable;

use function Tempest\Support\arr;

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

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 instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT),
default => $this->renderErrorResponse(
Status::INTERNAL_SERVER_ERROR,
$this->appConfig->environment->isLocal() ? $throwable : null,
),
};
}

private function renderValidationErrorResponse(ValidationFailed $exception): Response
{
$errors = arr($exception->failingRules)->map(
fn (array $failingRulesForField, string $field) => arr($failingRulesForField)->map(
fn (Rule $rule) => $this->container->get(Validator::class)->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' => $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,
}
);
}
}
18 changes: 16 additions & 2 deletions packages/router/src/HandleRouteExceptionMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\JsonHttpExceptionRenderer;
use Tempest\Router\Exceptions\RouteBindingFailed;
use Tempest\Validation\Exceptions\ValidationFailed;

Expand All @@ -17,6 +20,7 @@
{
public function __construct(
private RouteConfig $routeConfig,
private JsonHttpExceptionRenderer $jsonHandler,
) {}

public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
Expand Down Expand Up @@ -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);
}
}
Expand Down