Skip to content
Merged
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
82 changes: 63 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,31 @@
+ [Declaring a mapping](#declaring-a-mapping)
+ [Composing multiple mappings](#composing-multiple-mappings)
+ [Resolving the response from the exception](#resolving-the-response-from-the-exception)
+ [Unmapped exceptions that carry their own status](#unmapped-exceptions-that-carry-their-own-status)
+ [Logging and displaying error details](#logging-and-displaying-error-details)
+ [Disabling the fallback for unmapped exceptions](#disabling-the-fallback-for-unmapped-exceptions)
* [License](#license)
* [Contributing](#contributing)

## Overview

Provides a PSR-15 middleware that captures exceptions thrown by downstream handlers and translates them into
structured JSON error responses. Each consumer declares an `ExceptionMapping`, a class whose `mappings()` method
returns an `ExceptionMappingTable` that turns each known exception into a `MappedError` (machine-readable code, HTTP
status between 400 and 599, human-readable message, optional headers). The consumer declares only the rules it owns,
and the middleware composes the tables of every configured mapping into a single first-match-wins lookup, so neither
the consumer nor the middleware repeats the composition. Unmapped exceptions either short-circuit to a generic 500
fallback or rethrow, depending on the builder configuration.
Provides a PSR-15 middleware that captures exceptions thrown by downstream handlers and translates them into structured
JSON error responses. Each consumer declares an `ExceptionMapping`, a class whose `mappings()` method returns an
`ExceptionMappingTable` that turns each known exception into a `MappedError` (machine-readable code, HTTP status between
400 and 599, human-readable message, optional headers). The consumer declares only the rules it owns, and the middleware
composes the tables of every configured mapping into a single first-match-wins lookup, so neither the consumer nor the
middleware repeats the composition. Unmapped exceptions either short-circuit to the fallback response or rethrow,
depending on the builder configuration.

The fallback distinguishes a client mistake from a server failure. An unmapped exception that already declares a 4xx
status as its exception code is answered with that status, and every other unmapped exception is answered with a generic
`500`. This matters for framework exceptions the consumer never maps, most notably the routing exceptions a PSR-15 stack
raises for an unknown path or an unsupported method: without it, a caller typing the wrong URL is told the server
failed, and a client that retries on 5xx keeps retrying a request that can never succeed.

The library integrates with [tiny-blocks/http-correlation-id](https://github.com/tiny-blocks/http-correlation-id)
to enrich every log entry with the request's correlation identifier when one is present on the request
attributes, so error logs can be grouped across services without any extra plumbing in the consumer's log
calls.
to enrich every log entry with the request's correlation identifier when one is present on the request attributes, so
error logs can be grouped across services without any extra plumbing in the consumer's log calls.

## Installation

Expand Down Expand Up @@ -87,8 +93,8 @@ $middleware = ErrorMiddleware::create()
### Composing multiple mappings

When several verticals each own a mapping (for example, a write side and a read side), pass them all to
`withMappings`. The middleware composes them into a single first-match-wins lookup, evaluating the mappings in the
order given, so the consumer never writes the composition by hand.
`withMappings`. The middleware composes them into a single first-match-wins lookup, evaluating the mappings in the order
given, so the consumer never writes the composition by hand.

```php
<?php
Expand All @@ -109,9 +115,9 @@ $middleware = ErrorMiddleware::create()

### Resolving the response from the exception

Builds the `MappedError` from the matched exception when the response depends on runtime state (for example,
when the exception carries fields that should be exposed to the client). The closure receives the matched throwable
and returns a `MappedError` built from it.
Builds the `MappedError` from the matched exception when the response depends on runtime state (for example, when the
exception carries fields that should be exposed to the client). The closure receives the matched throwable and returns a
`MappedError` built from it.

```php
<?php
Expand Down Expand Up @@ -141,10 +147,48 @@ final readonly class GatewayExceptionMapping implements ExceptionMapping
}
```

### Unmapped exceptions that carry their own status

An unmapped exception whose exception code is a 4xx status is answered with that status, not with the generic 500.
Nothing is registered for this: it is how the fallback behaves. The response follows the same envelope, with the code
naming the status.

This covers the exceptions a consumer has no reason to map, because they belong to the framework rather than to the
domain. A PSR-15 routing stack raises one for an unknown path and another for an unsupported method, and both carry the
status on the exception itself.

```php
<?php

declare(strict_types=1);

use Slim\Exception\HttpNotFoundException;
use TinyBlocks\Http\ErrorHandler\ErrorMiddleware;
use TinyBlocks\Http\ErrorHandler\ExceptionMapping;

# The mapping is supplied by the consumer and declares nothing about routing.
$mapping = /** @var ExceptionMapping */ null;

$middleware = ErrorMiddleware::create()
->withMapping(mapping: $mapping)
->build();

# A request for a path with no route reaches the middleware as HttpNotFoundException, whose code is 404.
# The response is 404 {"code":"NOT_FOUND","message":"Not Found."} instead of a 500.
```

An unmapped exception carrying a 5xx code, or a code that is not an HTTP status at all (a driver error number, for
example), stays a generic 500. Only client errors are adopted, because a server-side failure the consumer did not map is
exactly what the generic fallback is for.

### Logging and displaying error details

Enables structured error logging and the optional inclusion of exception details in the response body. The
defaults are silent and secure: nothing is logged and no stack traces are returned to the client.
Enables structured error logging and the optional inclusion of exception details in the response body. The defaults are
silent and secure: nothing is logged and no stack traces are returned to the client.

The log level follows the status of the response that is actually returned. A 4xx is logged as a warning and everything
else as an error, whether the status came from a mapping or from the fallback. A validation failure the consumer maps to
422 is a client mistake, and logging it as an error inflates every error-rate signal built on top of the logs.

```php
<?php
Expand Down Expand Up @@ -174,8 +218,8 @@ $middleware = ErrorMiddleware::create()

### Disabling the fallback for unmapped exceptions

Forces unmapped exceptions to propagate to the outer handler instead of returning the generic 500 fallback.
Useful when a higher-level error boundary should observe the original throwable.
Forces unmapped exceptions to propagate to the outer handler instead of returning the generic 500 fallback. Useful when
a higher-level error boundary should observe the original throwable.

```php
<?php
Expand Down
9 changes: 5 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,17 @@
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
"psr/log": "^3.0",
"tiny-blocks/http": "^6.3",
"tiny-blocks/http-correlation-id": "^2.0"
"tiny-blocks/http": "^6.4",
"tiny-blocks/http-correlation-id": "^2.1"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.52",
"guzzlehttp/psr7": "^2.12",
"guzzlehttp/psr7": "^2.13",
"infection/infection": "^0.33",
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^13.1",
"slevomat/coding-standard": "^8.30",
"slevomat/coding-standard": "^8.31",
"slim/slim": "^4.15",
"squizlabs/php_codesniffer": "^4.0"
},
"minimum-stability": "stable",
Expand Down
23 changes: 23 additions & 0 deletions src/Internal/ClientErrorStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\ErrorHandler\Internal;

use Throwable;
use TinyBlocks\Http\Code;

final readonly class ClientErrorStatus
{
public static function from(Throwable $exception): ?Code
{
$code = Code::tryFromNullable($exception->getCode());

return $code?->isClientError() === true ? $code : null;
}

public static function matches(int $status): bool
{
return $status >= Code::BAD_REQUEST->value && $status < Code::INTERNAL_SERVER_ERROR->value;
}
}
6 changes: 4 additions & 2 deletions src/Internal/ErrorLogger.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public static function from(?LoggerInterface $logger, ErrorHandlingSettings $set
return new ErrorLogger(logger: $logger, settings: $settings);
}

public function log(ServerRequestInterface $request, Throwable $exception): void
public function log(int $status, ServerRequestInterface $request, Throwable $exception): void
{
if (is_null($this->logger) || !$this->settings->logErrors) {
return;
Expand All @@ -43,6 +43,8 @@ public function log(ServerRequestInterface $request, Throwable $exception): void
$context['trace'] = $exception->getTraceAsString();
}

$logger->error('error', $context);
ClientErrorStatus::matches(status: $status)
? $logger->warning('error', $context)
: $logger->error('error', $context);
}
}
12 changes: 9 additions & 3 deletions src/Internal/ErrorOutcome.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,16 @@ public function resolve(ServerRequestInterface $request, Throwable $exception):
throw $exception;
}

$this->errorLogger->log(request: $request, exception: $exception);

return is_null($mapped)
$response = is_null($mapped)
? FallbackResponse::from(settings: $this->settings, exception: $exception)->toResponse()
: MappedResponse::from(mapped: $mapped)->toResponse();

$this->errorLogger->log(
status: $response->getStatusCode(),
request: $request,
exception: $exception
);

return $response;
}
}
9 changes: 9 additions & 0 deletions src/Internal/FallbackResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ public static function from(ErrorHandlingSettings $settings, Throwable $exceptio

public function toResponse(): ResponseInterface
{
$clientError = ClientErrorStatus::from(exception: $this->exception);

if (!is_null($clientError)) {
return Response::from(
body: ['code' => $clientError->name, 'message' => sprintf('%s.', $clientError->message())],
code: $clientError
);
}

if ($this->settings->displayErrorDetails) {
return Response::from(
body: [
Expand Down
71 changes: 71 additions & 0 deletions tests/Integration/Drivers/Slim/RoutingExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace Test\TinyBlocks\Http\ErrorHandler\Integration\Drivers\Slim;

use GuzzleHttp\Psr7\ServerRequest;
use PHPUnit\Framework\TestCase;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Test\TinyBlocks\Http\ErrorHandler\Unit\UnmappedExceptions;
use TinyBlocks\Http\Code;
use TinyBlocks\Http\ErrorHandler\ErrorMiddleware;

final class RoutingExceptionTest extends TestCase
{
public function testProcessWhenSlimReportsNoRouteThenNotFoundIsReturned(): void
{
/** @Given a request for a path no route was registered at */
$request = new ServerRequest('GET', '/v1/unknown');

/** @And a handler that throws the routing exception Slim raises for it */
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willThrowException(new HttpNotFoundException($request));

/** @And a middleware that maps none of the framework exceptions */
$middleware = ErrorMiddleware::create()
->withMapping(mapping: new UnmappedExceptions())
->build();

/** @When the middleware processes the request */
$actual = $middleware->process($request, $handler);

/** @Then the caller receives the not found status instead of an internal error */
self::assertSame(Code::NOT_FOUND->value, $actual->getStatusCode());

/** @And the body follows the standard error envelope */
self::assertJsonStringEqualsJsonString(
'{"code":"NOT_FOUND","message":"Not Found."}',
(string)$actual->getBody()
);
}

public function testProcessWhenSlimReportsMethodNotAllowedThenMethodNotAllowedIsReturned(): void
{
/** @Given a request using a method the route does not support */
$request = new ServerRequest('DELETE', '/v1/users');

/** @And a handler that throws the routing exception Slim raises for it */
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willThrowException(new HttpMethodNotAllowedException($request));

/** @And a middleware that maps none of the framework exceptions */
$middleware = ErrorMiddleware::create()
->withMapping(mapping: new UnmappedExceptions())
->build();

/** @When the middleware processes the request */
$actual = $middleware->process($request, $handler);

/** @Then the caller receives the method not allowed status instead of an internal error */
self::assertSame(Code::METHOD_NOT_ALLOWED->value, $actual->getStatusCode());

/** @And the body follows the standard error envelope */
self::assertJsonStringEqualsJsonString(
'{"code":"METHOD_NOT_ALLOWED","message":"Method Not Allowed."}',
(string)$actual->getBody()
);
}
}
Loading