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
11 changes: 9 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ ifeq ($(ARCH),arm64)
endif

TTY := $(shell [ -t 0 ] && echo -it)
HOST_USER := $(shell id -u):$(shell id -g)

PHP_VERSION := $(shell sed -n 's/.*"php": *"^\([0-9]*\.[0-9]*\)".*/\1/p' composer.json)
IMAGE_VERSION := 1.0.0
PHP_IMAGE := gustavofreze/php:${PHP_VERSION}-cli-${IMAGE_VERSION}
WORKSPACE := /var/www/html

DOCKER_RUN = docker run ${PLATFORM} --rm ${TTY} --net=host -v ${PWD}:${WORKSPACE} ${PHP_IMAGE}
# The runner drops to the calling user, as the twelve service Makefiles and the CLI already do. A
# root runner writes `vendor/`, `reports/` and the PHPStan cache into the bind mount owned by root,
# and then only the owner can remove them: measured at 801 root owned paths from a single
# `make review` in this repository. COMPOSER_HOME moves off /root because that path belongs to root
# inside the image and a uid with no passwd entry cannot write it.
DOCKER_RUN = docker run ${PLATFORM} -u ${HOST_USER} --rm ${TTY} --net=host \
-e COMPOSER_HOME=/tmp/composer \
-v ${PWD}:${WORKSPACE} ${PHP_IMAGE}

RESET := \033[0m
GREEN := \033[0;32m
Expand Down Expand Up @@ -55,7 +63,6 @@ show-image: ## Show the pinned PHP tooling image

.PHONY: clean
clean: ## Remove dependencies and generated artifacts
@sudo chown -R ${USER}:${USER} ${PWD}
@rm -rf reports vendor .phpunit.cache *.lock

.PHONY: help
Expand Down
359 changes: 352 additions & 7 deletions README.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
"psr/log": "^3.0",
"tiny-blocks/http": "^7.0",
"tiny-blocks/http": "^7.1",
"tiny-blocks/http-correlation-id": "^2.1"
},
"require-dev": {
Expand All @@ -37,10 +37,14 @@
"infection/infection": "^0.34",
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^13.2",
"sentry/sentry": "^4.0",
"slevomat/coding-standard": "^8.31",
"slim/slim": "^4.15",
"squizlabs/php_codesniffer": "^4.0"
},
"suggest": {
"sentry/sentry": "Required to use SentryReporter (^4.0)."
},
"minimum-stability": "stable",
"prefer-stable": true,
"autoload": {
Expand Down
34 changes: 32 additions & 2 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,48 @@ parameters:
# Constructor parameter holds the registered entries; PHPDoc is prohibited on constructors.
- identifier: missingType.iterableValue
path: src/ExceptionMappingTable.php
# Constructor parameter holds the response headers; PHPDoc is prohibited on constructors.
- identifier: missingType.iterableValue
path: src/MappedError.php
# Constructor parameter holds the accumulated tags; PHPDoc is prohibited on constructors.
- identifier: missingType.iterableValue
path: src/ReportedError.php
# Tag values reach the provider untyped, because ReportedError cannot annotate the array they come from.
- identifier: argument.type
path: src/Internal/Reporting/SentryTags.php
# Header values reach the response untyped; root cause is the same as the MappedError entry above.
- identifier: argument.type
path: src/Internal/Response/MappedResponse.php
# Iteration over the constructor-typed array of entries; root cause is the same as above.
- identifier: method.nonObject
path: src/ExceptionMappingTable.php
# The mapTo return type cannot be inferred without the constructor-level array shape.
- identifier: return.type
path: src/ExceptionMappingTable.php
# Constructor parameter holds the composed reporters; PHPDoc is prohibited inside src/Internal/.
- identifier: missingType.iterableValue
path: src/Internal/Reporting/CompositeErrorReporter.php
# Iteration over the constructor-typed array of reporters; root cause is the same as above.
- identifier: method.nonObject
path: src/Internal/Reporting/CompositeErrorReporter.php
# Trace lines and the assembled detail map; PHPDoc is prohibited inside src/Internal/.
- identifier: missingType.iterableValue
path: src/Internal/ExceptionDetails.php
# Header map arrives from the MappedError constructor; PHPDoc is prohibited inside src/Internal/.
- identifier: missingType.iterableValue
path: src/Internal/Response/ResponseHeaders.php
# Internal matcher accepts a list of class-strings; PHPDoc is prohibited inside src/Internal/.
- identifier: missingType.iterableValue
path: src/Internal/AnyExactClassMatcher.php
path: src/Internal/Mapping/AnyExactClassMatcher.php
# Closure invocation is opaque to PHPStan; PHPDoc is prohibited inside src/Internal/.
- identifier: return.type
path: src/Internal/DynamicMappedErrorResolver.php
path: src/Internal/Mapping/DynamicMappedErrorResolver.php
# Tag map the fake exception replays; PHPDoc is prohibited on constructors and inside tests/.
- identifier: missingType.iterableValue
path: tests/Unit/ContextualException.php
# The replayed map cannot be narrowed without the constructor tag; root cause is the same as above.
- identifier: return.type
path: tests/Unit/ContextualException.php
# json_decode in test assertions yields mixed; PHPDoc is prohibited inside tests/.
- identifier: offsetAccess.nonOffsetAccessible
path: tests/Unit/ErrorMiddlewareTest.php
Expand Down
28 changes: 14 additions & 14 deletions src/ErrorHandlingSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,6 @@ private function __construct(
) {
}

/**
* Creates an ErrorHandlingSettings with all flags disabled.
*
* @return ErrorHandlingSettings The default settings instance.
*/
public static function default(): ErrorHandlingSettings
{
return ErrorHandlingSettings::from(
logErrors: false,
logErrorDetails: false,
displayErrorDetails: false
);
}

/**
* Creates an ErrorHandlingSettings from the given flags.
*
Expand All @@ -50,4 +36,18 @@ public static function from(
displayErrorDetails: $displayErrorDetails
);
}

/**
* Creates an ErrorHandlingSettings with all flags disabled.
*
* @return ErrorHandlingSettings The default settings instance.
*/
public static function default(): ErrorHandlingSettings
{
return ErrorHandlingSettings::from(
logErrors: false,
logErrorDetails: false,
displayErrorDetails: false
);
}
}
34 changes: 31 additions & 3 deletions src/ErrorMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@

namespace TinyBlocks\Http\ErrorHandler;

use Closure;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Throwable;
use TinyBlocks\Http\Code;
use TinyBlocks\Http\ErrorHandler\Internal\DefaultErrorMiddlewareBuilder;
use TinyBlocks\Http\ErrorHandler\Internal\ErrorLogger;
use TinyBlocks\Http\ErrorHandler\Internal\ErrorOutcome;
use TinyBlocks\Http\ErrorHandler\Internal\Reporting\CompositeErrorReporter;
use TinyBlocks\Http\ErrorHandler\Internal\ResolvedRoute;
use TinyBlocks\Http\ErrorHandler\Reporters\SilentErrorReporter;

/**
* PSR-15 middleware that captures exceptions thrown downstream, delegates to a consumer-provided
Expand All @@ -29,21 +34,33 @@ private function __construct(private ErrorOutcome $errorOutcome)
* Builds an ErrorMiddleware from its configuration components.
*
* @param LoggerInterface|null $logger The logger to use for error logging, or <code>null</code> to disable logging.
* @param Closure(ErrorPayload, ServerRequestInterface): string $message The rule settling the message the
* response answers with.
* @param ExceptionMappingTable $mappings The composed table that maps exceptions to error responses.
* @param Closure(?MappedError, Code): ReportingPriority $priority The rule deriving a priority from the rule
* that matched and the resolved status.
* @param ErrorHandlingSettings $settings The settings controlling error display and logging behavior.
* @param bool $fallbackOnUnmapped Whether to return a fallback response when no mapping matches.
* @param ErrorReporter $reporter The reporter notified after the response is produced and logged. Defaults to
* {@see SilentErrorReporter}, which forwards every report to no provider.
* @return ErrorMiddleware The configured middleware instance.
*/
public static function build(
?LoggerInterface $logger,
Closure $message,
ExceptionMappingTable $mappings,
Closure $priority,
ErrorHandlingSettings $settings,
bool $fallbackOnUnmapped
bool $fallbackOnUnmapped,
ErrorReporter $reporter = new SilentErrorReporter()
): ErrorMiddleware {
$errorOutcome = new ErrorOutcome(
message: $message,
mappings: $mappings,
priority: $priority,
settings: $settings,
errorLogger: ErrorLogger::from(logger: $logger, settings: $settings),
errorReporter: CompositeErrorReporter::create()->with(reporter: $reporter),
fallbackOnUnmapped: $fallbackOnUnmapped
);

Expand All @@ -62,10 +79,21 @@ public static function create(): ErrorMiddlewareBuilder

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$tags = ReportingTags::pending();
$route = ResolvedRoute::pending();
$routed = $request
->withAttribute(ResolvedRoute::ATTRIBUTE_NAME, $route)
->withAttribute(ReportingTags::ATTRIBUTE_NAME, $tags);

try {
return $handler->handle($request);
return $handler->handle($routed);
} catch (Throwable $exception) {
return $this->errorOutcome->resolve(request: $request, exception: $exception);
return $this->errorOutcome->resolve(
tags: $tags,
route: $route,
request: $routed,
exception: $exception
);
}
}
}
45 changes: 44 additions & 1 deletion src/ErrorMiddlewareBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@

namespace TinyBlocks\Http\ErrorHandler;

use Closure;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use TinyBlocks\Http\Code;
use TinyBlocks\Http\ErrorHandler\Exceptions\MappingNotConfigured;

/**
* Fluent builder that assembles an {@see ErrorMiddleware} from a logger, exception mappings, and settings.
* Fluent builder that assembles an {@see ErrorMiddleware} from a logger, exception mappings, error reporters,
* settings, and the rules deciding the message a response answers with and the priority an error carries.
*/
interface ErrorMiddlewareBuilder
{
Expand Down Expand Up @@ -36,6 +40,19 @@ public function withLogger(?LoggerInterface $logger): ErrorMiddlewareBuilder;
*/
public function withMapping(ExceptionMapping $mapping): ErrorMiddlewareBuilder;

/**
* Registers the rule that settles the message the response answers with.
*
* <p>Without this rule the response answers what the mapping or the fallback already said. An
* application that speaks to people replaces it, and decides from the resolved code and the request,
* so the message is settled once and never parsed back out of a rendered body.</p>
*
* @param Closure(ErrorPayload, ServerRequestInterface): string $resolver The rule naming the message to answer
* with.
* @return ErrorMiddlewareBuilder The configured builder.
*/
public function withMessage(Closure $resolver): ErrorMiddlewareBuilder;

/**
* Returns a builder with the given exception mappings registered.
*
Expand All @@ -44,6 +61,32 @@ public function withMapping(ExceptionMapping $mapping): ErrorMiddlewareBuilder;
*/
public function withMappings(ExceptionMapping ...$mappings): ErrorMiddlewareBuilder;

/**
* Registers the rule that derives a priority from the status the middleware resolved.
*
* <p>The default is {@see ReportingPriority::from}, which every consumer is free to replace. What an
* exception declares through {@see PrioritizedError} still wins over whatever this rule answers.</p>
*
* @param Closure(?MappedError, Code): ReportingPriority $resolver The rule naming the priority an error
* justifies.
* @return ErrorMiddlewareBuilder The configured builder.
*/
public function withPriority(Closure $resolver): ErrorMiddlewareBuilder;

/**
* Returns a builder with the given error reporter registered.
*
* <p>Reporters are optional, and the method may be called more than once to register several of
* them, so one application can report to an error tracking service, a metrics backend, and an
* audit trail at once. Each one is notified after the response has been produced and the log
* entry emitted, and a failure thrown by one of them is discarded, so it can never affect the
* response.</p>
*
* @param ErrorReporter $reporter The reporter notified when an error is handled.
* @return ErrorMiddlewareBuilder The configured builder.
*/
public function withReporter(ErrorReporter $reporter): ErrorMiddlewareBuilder;

/**
* Returns a builder configured with the given error handling settings.
*
Expand Down
25 changes: 25 additions & 0 deletions src/ErrorPayload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\ErrorHandler;

use TinyBlocks\Http\Code;

/**
* Body the middleware is about to answer with, before the message is final.
*
* <p>An application that speaks to people rather than to machines rewrites the message, and the
* rest of the answer stays as the middleware resolved it. Handing it this, rather than the rendered
* response, is what keeps the rewrite from being a parse and a re-encode of what was just built.</p>
*/
final readonly class ErrorPayload
{
public function __construct(
public string $code,
public Code $status,
public string $message,
public bool $wasMapped
) {
}
}
30 changes: 30 additions & 0 deletions src/ErrorReporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\ErrorHandler;

/**
* Consumer-provided sink that forwards a handled error to an external observability provider.
*
* <p>One implementation adapts one provider, and the middleware accepts any number of them, so a
* single application can report to an error tracking service, a metrics backend, and an audit trail
* without any of them knowing about the others.</p>
*
* <p>Reporting is the best effort. Reporters run synchronously, after the response has been produced
* and after the log entry has been emitted, and any failure thrown by an implementation is
* discarded, so a provider that fails never fails the response. A slow one still adds its own
* duration to it.</p>
*/
interface ErrorReporter
{
/**
* Reports the error to the provider this reporter adapts.
*
* <p>Implementations are free to throw. The middleware discards whatever comes out, so an
* implementation never needs to defend itself for the sake of the request.</p>
*
* @param ReportedError $error The resolved error context to forward.
*/
public function report(ReportedError $error): void;
}
8 changes: 4 additions & 4 deletions src/ExceptionMappingRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

use Closure;
use Throwable;
use TinyBlocks\Http\ErrorHandler\Internal\DefaultMappingEntry;
use TinyBlocks\Http\ErrorHandler\Internal\DynamicMappedErrorResolver;
use TinyBlocks\Http\ErrorHandler\Internal\FixedMappedErrorResolver;
use TinyBlocks\Http\ErrorHandler\Internal\Mapping\DefaultMappingEntry;
use TinyBlocks\Http\ErrorHandler\Internal\Mapping\DynamicMappedErrorResolver;
use TinyBlocks\Http\ErrorHandler\Internal\Mapping\FixedMappedErrorResolver;

/**
* Intermediate builder closing a rule registered on an {@see ExceptionMappingTable}. A rule
Expand All @@ -25,7 +25,7 @@ public function __construct(private ExceptionMappingTable $table, private Except
* Closes the rule with a fixed MappedError produced from the given fields.
*
* @param string $code Machine-readable error code.
* @param int $status HTTP response status code (400-599).
* @param int $status HTTP response status code, one of the known HTTP error statuses.
* @param string $message Human-readable error description.
* @param array<string, string|string[]> $headers Optional HTTP response headers.
* @return ExceptionMappingTable The table with the new rule appended.
Expand Down
8 changes: 4 additions & 4 deletions src/ExceptionMappingTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
namespace TinyBlocks\Http\ErrorHandler;

use Throwable;
use TinyBlocks\Http\ErrorHandler\Internal\AnyExactClassMatcher;
use TinyBlocks\Http\ErrorHandler\Internal\ExactClassMatcher;
use TinyBlocks\Http\ErrorHandler\Internal\SubclassMatcher;
use TinyBlocks\Http\ErrorHandler\Internal\Mapping\AnyExactClassMatcher;
use TinyBlocks\Http\ErrorHandler\Internal\Mapping\ExactClassMatcher;
use TinyBlocks\Http\ErrorHandler\Internal\Mapping\SubclassMatcher;

/**
* Fluent table of exception-to-MappedError rules, evaluated in registration order. The first
Expand Down Expand Up @@ -53,7 +53,7 @@ public function when(string $exceptionClass): ExceptionMappingRule
public function mapTo(Throwable $exception): ?MappedError
{
foreach ($this->entries as $entry) {
$mappedError = $entry->resolve(exception: $exception);
$mappedError = $entry->resolve($exception);

if (!is_null($mappedError)) {
return $mappedError;
Expand Down
Loading