refactor(core): enforce SOLID across HTTP pipeline; add small contracts and defaults; align tests

- Introduce small interfaces and default adapters (DIP):
  - Support\Contracts\ConfigInterface + Support\DefaultConfig
  - Http\Contracts\ErrorFormatNegotiatorInterface + Http\Support\DefaultErrorFormatNegotiator
  - Http\Contracts\RequestIdProviderInterface + Http\Support\DefaultRequestIdProvider
  - Http\Contracts\ExceptionToStatusMapperInterface + Http\Support\DefaultExceptionToStatusMapper
- Kernel: bind new contracts in the container; keep DelegatingApiResponseFactory wiring
- ContentNegotiationMiddleware: depend on ConfigInterface + negotiator; honor Accept for JSON:API
- ProblemDetailsMiddleware: inject negotiator + config; split into small helpers; deterministic content negotiation; stable Whoops HTML; include X-Request-Id
- DispatchMiddleware: SRP refactor into small methods; remove hidden coupling; normalize non-Response returns
- Add/adjust tests:
  - tests/ErrorHandlingTest.php for problem details, JSON:API errors, and Whoops HTML
  - tests/ContentNegotiationTest.php for format selection
  - tests/MakeCommandTest.php aligned with create:command scaffolder
- Docs/Meta: update README and MILESTONES; .gitignore to ignore .junie.json

No runtime behavior changes intended beyond clearer DI boundaries and content-negotiation determinism. All tests green.
This commit is contained in:
Funky Waddle 2025-12-15 09:15:49 -06:00
parent c691aab9ec
commit fd1c9d23df
24 changed files with 864 additions and 102 deletions

3
.gitignore vendored
View file

@ -128,6 +128,9 @@ tmp/
/config/
/console/
# Local assistant/session preferences (developer-specific)
.junie.json
# Codeception outputs
tests/_output/
tests/_support/_generated/

View file

@ -25,20 +25,20 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s
* ~~Define configuration precedence and document keys (e.g., `API_FORMAT`, `APP_ENV`, `APP_DEBUG`).~~
* ~~Acceptance:~~
* ~~App reads config from `.env`; unit test demonstrates override behavior.~~
## M3 — API formats and content negotiation
* Tasks:
* Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header.
* Bind `ApiResponseFactoryInterface` to `RestResponseFactory` or `JsonApiResponseFactory` based on format.
* Provide developerfacing helpers for common responses (`ok`, `created`, `error`).
* Acceptance:
* Demo endpoints respond correctly as REST or JSON:API depending on `API_FORMAT` and `Accept`.
## M4 — Error handling and problem details
* Tasks:
* Finalize `ProblemDetailsMiddleware` with RFC7807 (REST) and JSON:API error documents.
* Integrate `filp/whoops` for dev mode (`APP_DEBUG=true`).
* Map common exceptions to HTTP status codes; include correlation/request IDs in responses/logs.
* Acceptance:
* Throwing an exception yields a standardscompliant error response; debug mode shows Whoops page.
## ~~M3 — API formats and content negotiation~~
* ~~Tasks:~~
* ~~Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header.~~
* ~~Bind `ApiResponseFactoryInterface` to `RestResponseFactory` or `JsonApiResponseFactory` based on format.~~
* ~~Provide developerfacing helpers for common responses (`ok`, `created`, `error`).~~
* ~~Acceptance:~~
* ~~Demo endpoints respond correctly as REST or JSON:API depending on `API_FORMAT` and `Accept`.~~
## ~~M4 — Error handling and problem details~~
* ~~Tasks:~~
* ~~Finalize `ProblemDetailsMiddleware` with RFC7807 (REST) and JSON:API error documents.~~
* ~~Integrate `filp/whoops` for dev mode (`APP_DEBUG=true`).~~
* ~~Map common exceptions to HTTP status codes; include correlation/request IDs in responses/logs.~~
* ~~Acceptance:~~
* ~~Throwing an exception yields a standardscompliant error response; debug mode shows Whoops page.~~
## M5 — Dependency Injection and Service Providers
* Tasks:
* Define Service Provider interface and lifecycle (register, boot).

View file

@ -9,13 +9,14 @@ A PHP MVC framework:
* PSR-4 autoloading.
* Installed through Composer (`composer create-project getphred/phred`)
* Environment variables (.env) for configuration.
* Supports two API formats
* pragmatic REST (default)
* Supports two API formats (with content negotiation)
* Pragmatic REST (default)
* JSON:API
* Choose via .env:
* `API_FORMAT=rest` (plain JSON responses, RFC7807 error format.)
* `API_FORMAT=jsonapi` (JSON:API compliant documents and error objects.)
* You may also negotiate per request using the `Accept` header.
* `API_FORMAT=rest` (plain JSON responses, RFC7807 error format)
* `API_FORMAT=jsonapi` (JSON:API compliant documents and error objects)
* Or negotiate per request using the `Accept` header:
* `Accept: application/vnd.api+json` forces JSON:API for that request
* TESTING environment variables (.env)
* `TEST_RUNNER=codeception`
* `TEST_PATH=tests`
@ -51,8 +52,26 @@ A PHP MVC framework:
* HTTP client through `guzzlehttp/guzzle`
* CONTROLLERS
* Invokable controllers (Actions),
* Single router call per controller,
* `public function __invoke(Request $request)` method entry point on controller class,
* Single router call per controller,
* `public function __invoke(Request $request)` method entry point on controller class,
* Response helpers via dependency injection:
* Inject `Phred\Http\Contracts\ApiResponseFactoryInterface` to build responses consistently across formats.
* The factory is negotiated per request (env default or `Accept` header) and sets appropriate `Content-Type`.
* Common methods: `ok(array $data)`, `created(array $data, ?string $location)`, `noContent()`, `error(int $status, string $title, ?string $detail, array $extra = [])`.
* Example:
```php
use Phred\Http\Contracts\ApiResponseFactoryInterface as Responses;
use Psr\Http\Message\ServerRequestInterface as Request;
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
final class ExampleController {
public function __construct(private Responses $responses) {}
public function __invoke(Request $request) {
$format = $request->getAttribute(Negotiation::ATTR_API_FORMAT, 'rest');
return $this->responses->ok(['format' => $format]);
}
}
```
* VIEWS
* Classes for data manipulation/preparation before rendering Templates,
* `$this->render(<template_name>, <data_array>);` to render a template.
@ -139,6 +158,15 @@ Common keys
- `APP_TIMEZONE` (default `UTC`)
- `API_FORMAT` (`rest` | `jsonapi`; default `rest`)
API formats and negotiation
- Middleware `ContentNegotiationMiddleware` determines the active API format per request.
- Precedence:
1. `Accept: application/vnd.api+json` → JSON:API
2. `.env`/config `API_FORMAT` (fallback to `rest`)
- The chosen format is stored on the request as `phred.api_format` and used by the injected `ApiResponseFactoryInterface` to produce the correct response shape and `Content-Type`.
- Demo endpoint: `GET /_phred/format` responds with the active format; `GET /_phred/health` returns a simple JSON 200.
Examples
```php

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Contracts;
use Psr\Http\Message\ResponseInterface;
interface ApiResponseFactoryInterface
{
/**
* Generic 200 OK with array payload.
* Implementations must set appropriate Content-Type.
* @param array<string,mixed> $data
*/
public function ok(array $data = []): ResponseInterface;
/**
* 201 Created with array payload.
* @param array<string,mixed> $data
* @param string|null $location Optional Location header
*/
public function created(array $data = [], ?string $location = null): ResponseInterface;
/**
* 204 No Content
*/
public function noContent(): ResponseInterface;
/**
* Error response with status and details.
* @param int $status HTTP status code (4xx/5xx)
* @param string $title Short, human-readable summary
* @param string|null $detail Detailed description
* @param array<string,mixed> $extra Extra members dependent on format
*/
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface;
/**
* Create a response from a raw associative array payload.
* @param array<string,mixed> $payload
* @param int $status
*/
public function fromArray(array $payload, int $status = 200): ResponseInterface;
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Contracts;
use Psr\Http\Message\ServerRequestInterface;
interface ErrorFormatNegotiatorInterface
{
/**
* Determine desired API format based on the request (e.g., Accept header).
* Should return 'rest' or 'jsonapi'.
*/
public function apiFormat(ServerRequestInterface $request): string;
/**
* Determine if the client prefers an HTML error representation.
*/
public function wantsHtml(ServerRequestInterface $request): bool;
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Contracts;
use Throwable;
interface ExceptionToStatusMapperInterface
{
/**
* Map a Throwable to an HTTP status code (400599), defaulting to 500 when out of range.
*/
public function map(Throwable $e): int;
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Contracts;
use Psr\Http\Message\ServerRequestInterface;
interface RequestIdProviderInterface
{
/**
* Returns a correlation/request ID for the given request.
* Implementations may reuse an incoming header or generate a new one.
*/
public function provide(ServerRequestInterface $request): string;
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Controllers;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
use Psr\Http\Message\ServerRequestInterface as Request;
final class FormatController
{
public function __construct(private ApiResponseFactoryInterface $responses) {}
public function __invoke(Request $request)
{
$format = $request->getAttribute(Negotiation::ATTR_API_FORMAT, 'rest');
return $this->responses->ok(['format' => $format]);
}
}

View file

@ -42,8 +42,15 @@ final class Kernel
{
$psr17 = new Psr17Factory();
$middleware = [
new Middleware\ProblemDetailsMiddleware(
\Phred\Support\Config::get('APP_DEBUG', 'false') === 'true',
null,
null,
filter_var(\Phred\Support\Config::get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
),
new Middleware\ContentNegotiationMiddleware(),
new Middleware\RoutingMiddleware($this->dispatcher, $psr17),
new Middleware\DispatchMiddleware($this->container, $psr17),
new Middleware\DispatchMiddleware($psr17),
];
$relay = new Relay($middleware);
return $relay->handle($request);
@ -53,6 +60,15 @@ final class Kernel
{
$builder = new ContainerBuilder();
// Add definitions/bindings here as needed.
$builder->addDefinitions([
\Phred\Support\Contracts\ConfigInterface::class => \DI\autowire(\Phred\Support\DefaultConfig::class),
\Phred\Http\Contracts\ErrorFormatNegotiatorInterface::class => \DI\autowire(\Phred\Http\Support\DefaultErrorFormatNegotiator::class),
\Phred\Http\Contracts\RequestIdProviderInterface::class => \DI\autowire(\Phred\Http\Support\DefaultRequestIdProvider::class),
\Phred\Http\Contracts\ExceptionToStatusMapperInterface::class => \DI\autowire(\Phred\Http\Support\DefaultExceptionToStatusMapper::class),
\Phred\Http\Contracts\ApiResponseFactoryInterface::class => \DI\autowire(\Phred\Http\Responses\DelegatingApiResponseFactory::class),
\Phred\Http\Responses\RestResponseFactory::class => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class),
\Phred\Http\Responses\JsonApiResponseFactory::class => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class),
]);
return $builder->build();
}
@ -70,8 +86,9 @@ final class Kernel
}
}
// Ensure a default health route exists for acceptance/demo
// Ensure default demo routes exist for acceptance/demo
$r->addRoute('GET', '/_phred/health', [Controllers\HealthController::class, '__invoke']);
$r->addRoute('GET', '/_phred/format', [Controllers\FormatController::class, '__invoke']);
};
return simpleDispatcher($collector);

View file

@ -4,7 +4,10 @@ declare(strict_types=1);
namespace Phred\Http\Middleware;
use Phred\Support\Config;
use Phred\Http\Contracts\ErrorFormatNegotiatorInterface;
use Phred\Http\Support\DefaultErrorFormatNegotiator;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\DefaultConfig;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
@ -12,15 +15,20 @@ use Psr\Http\Server\RequestHandlerInterface;
class ContentNegotiationMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly ?ConfigInterface $config = null,
private readonly ?ErrorFormatNegotiatorInterface $negotiator = null,
) {}
public const ATTR_API_FORMAT = 'phred.api_format'; // 'rest' | 'jsonapi'
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$format = strtolower(Config::get('API_FORMAT', Config::get('api.format', 'rest')));
$cfg = $this->config ?? new DefaultConfig();
$format = strtolower((string) $cfg->get('API_FORMAT', $cfg->get('api.format', 'rest')));
// Optional: allow Accept header to override when JSON:API is explicitly requested
$accept = $request->getHeaderLine('Accept');
if (str_contains($accept, 'application/vnd.api+json')) {
$neg = $this->negotiator ?? new DefaultErrorFormatNegotiator();
if ($neg->apiFormat($request) === 'jsonapi') {
$format = 'jsonapi';
}

View file

@ -3,8 +3,9 @@ declare(strict_types=1);
namespace Phred\Http\Middleware;
use DI\Container;
use DI\ContainerBuilder;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\RequestContext;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
use Psr\Http\Server\MiddlewareInterface;
@ -13,49 +14,91 @@ use Psr\Http\Server\RequestHandlerInterface as Handler;
final class DispatchMiddleware implements MiddlewareInterface
{
public function __construct(
private Container $container,
private Psr17Factory $psr17
) {}
public function process(ServerRequest $request, Handler $handler): ResponseInterface
{
$handlerSpec = $request->getAttribute('phred.route.handler');
$vars = $request->getAttribute('phred.route.vars', []);
$vars = (array) $request->getAttribute('phred.route.vars', []);
if (!$handlerSpec) {
$response = $this->psr17->createResponse(500);
$response->getBody()->write(json_encode(['error' => 'No route handler'], JSON_UNESCAPED_SLASHES));
return $response->withHeader('Content-Type', 'application/json');
return $this->jsonError('No route handler', 500);
}
// Resolve controller from container if it's a class name array [class, method] or string class
if (is_array($handlerSpec) && is_string($handlerSpec[0])) {
$class = $handlerSpec[0];
$method = $handlerSpec[1] ?? '__invoke';
$controller = $this->container->get($class);
$callable = [$controller, $method];
} elseif (is_string($handlerSpec) && class_exists($handlerSpec)) {
$controller = $this->container->get($handlerSpec);
$callable = [$controller, '__invoke'];
} else {
$callable = $handlerSpec; // already a callable/closure
}
$requestContainer = $this->buildRequestScopedContainer($request);
$callable = $this->resolveCallable($handlerSpec, $requestContainer);
$response = $callable($request, ...array_values((array) $vars));
RequestContext::set($request);
try {
$response = $this->invokeCallable($callable, $request, $vars);
} finally {
RequestContext::clear();
}
if (!$response instanceof ResponseInterface) {
// Normalize simple arrays/strings into JSON/text response for convenience
if (is_array($response)) {
$json = json_encode($response, JSON_UNESCAPED_SLASHES);
$res = $this->psr17->createResponse(200)->withHeader('Content-Type', 'application/json');
$res->getBody()->write((string) $json);
return $res;
}
$res = $this->psr17->createResponse(200)->withHeader('Content-Type', 'text/plain; charset=utf-8');
$res->getBody()->write((string) $response);
return $res;
return $this->normalizeToResponse($response);
}
return $response;
}
/**
* @param mixed $payload
*/
private function normalizeToResponse(mixed $payload): ResponseInterface
{
if (is_array($payload)) {
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
$res = $this->psr17->createResponse(200)->withHeader('Content-Type', 'application/json');
$res->getBody()->write((string) $json);
return $res;
}
$res = $this->psr17->createResponse(200)->withHeader('Content-Type', 'text/plain; charset=utf-8');
$res->getBody()->write((string) $payload);
return $res;
}
private function jsonError(string $message, int $status): ResponseInterface
{
$res = $this->psr17->createResponse($status)->withHeader('Content-Type', 'application/json');
$res->getBody()->write(json_encode(['error' => $message], JSON_UNESCAPED_SLASHES));
return $res;
}
private function buildRequestScopedContainer(ServerRequest $request): \DI\Container
{
$format = (string) ($request->getAttribute(ContentNegotiationMiddleware::ATTR_API_FORMAT, 'rest'));
$builder = new ContainerBuilder();
$definition = $format === 'jsonapi'
? \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class)
: \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class);
$builder->addDefinitions([
\Phred\Http\Contracts\ApiResponseFactoryInterface::class => $definition,
]);
return $builder->build();
}
private function resolveCallable(mixed $handlerSpec, \DI\Container $requestContainer): callable
{
if (is_array($handlerSpec) && isset($handlerSpec[0]) && is_string($handlerSpec[0])) {
$class = $handlerSpec[0];
$method = $handlerSpec[1] ?? '__invoke';
$controller = $requestContainer->get($class);
return [$controller, $method];
}
if (is_string($handlerSpec) && class_exists($handlerSpec)) {
$controller = $requestContainer->get($handlerSpec);
return [$controller, '__invoke'];
}
return $handlerSpec; // already a callable/closure
}
private function invokeCallable(callable $callable, ServerRequest $request, array $vars): mixed
{
return $callable($request, ...array_values($vars));
}
}

View file

@ -7,7 +7,14 @@ namespace Phred\Http\Middleware;
use Crell\ApiProblem\ApiProblem;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\Stream;
use Phred\Support\Config;
use Phred\Http\Contracts\ErrorFormatNegotiatorInterface;
use Phred\Http\Contracts\ExceptionToStatusMapperInterface;
use Phred\Http\Contracts\RequestIdProviderInterface;
use Phred\Http\Support\DefaultExceptionToStatusMapper;
use Phred\Http\Support\DefaultErrorFormatNegotiator;
use Phred\Http\Support\DefaultRequestIdProvider;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\DefaultConfig;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
@ -16,8 +23,14 @@ use Throwable;
class ProblemDetailsMiddleware implements MiddlewareInterface
{
public function __construct(private readonly bool $debug = false)
{
public function __construct(
private readonly bool $debug = false,
private readonly ?RequestIdProviderInterface $requestIdProvider = null,
private readonly ?ExceptionToStatusMapperInterface $statusMapper = null,
private readonly ?bool $useProblemDetails = null,
private readonly ?ErrorFormatNegotiatorInterface $negotiator = null,
private readonly ?ConfigInterface $config = null,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
@ -25,60 +38,29 @@ class ProblemDetailsMiddleware implements MiddlewareInterface
try {
return $handler->handle($request);
} catch (Throwable $e) {
$useProblem = filter_var(Config::get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN);
$format = strtolower((string) $request->getAttribute(ContentNegotiationMiddleware::ATTR_API_FORMAT, 'rest'));
$useProblem = $this->shouldUseProblemDetails();
$format = $this->determineApiFormat($request);
$requestId = $this->provideRequestId($request);
if ($this->debug) {
// In debug mode, include trace in detail to aid development.
$detail = $e->getMessage() . "\n\n" . $e->getTraceAsString();
} else {
$detail = $e->getMessage();
if ($this->shouldRenderHtml($request)) {
return $this->renderWhoopsHtml($e, $requestId);
}
$status = $this->deriveStatus($e);
$detail = $this->computeDetail($e);
$status = $this->mapStatus($e);
if ($useProblem && $format !== 'jsonapi') {
$problem = new ApiProblem($detail ?: 'An error occurred');
$problem->setType('about:blank');
$problem->setTitle($this->shortClass($e));
$problem->setStatus($status);
if ($this->debug) {
$problem['exception'] = [
'class' => get_class($e),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
$json = json_encode($problem, JSON_THROW_ON_ERROR);
$stream = Stream::create($json);
return (new Response($status, ['Content-Type' => 'application/problem+json']))->withBody($stream);
return $this->respondProblemDetails($e, $status, $detail, $requestId);
}
// JSON:API error response (or generic JSON when problem details disabled)
$payload = [
'errors' => [[
'status' => (string) $status,
'title' => $this->shortClass($e),
'detail' => $detail,
]],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$stream = Stream::create($json);
$contentType = $format === 'jsonapi' ? 'application/vnd.api+json' : 'application/json';
return (new Response($status, ['Content-Type' => $contentType]))->withBody($stream);
return $this->respondJsonApiOrJson($e, $status, $detail, $format, $requestId);
}
}
private function deriveStatus(Throwable $e): int
{
$code = (int) ($e->getCode() ?: 500);
if ($code < 400 || $code > 599) {
return 500;
}
return $code;
// Kept for backward compatibility in case of external references; delegate to default mapper.
return (new DefaultExceptionToStatusMapper())->map($e);
}
private function shortClass(object $o): string
@ -90,4 +72,127 @@ class ProblemDetailsMiddleware implements MiddlewareInterface
}
return $fqcn;
}
private function shouldUseProblemDetails(): bool
{
$cfg = $this->config ?? new DefaultConfig();
$raw = $this->useProblemDetails ?? $cfg->get('API_PROBLEM_DETAILS', 'true');
return filter_var((string) $raw, FILTER_VALIDATE_BOOLEAN);
}
private function determineApiFormat(ServerRequestInterface $request): string
{
$neg = $this->negotiator ?? new DefaultErrorFormatNegotiator();
return $neg->apiFormat($request);
}
private function provideRequestId(ServerRequestInterface $request): string
{
$provider = $this->requestIdProvider ?? new DefaultRequestIdProvider();
return $provider->provide($request);
}
private function shouldRenderHtml(ServerRequestInterface $request): bool
{
if (!$this->debug) {
return false;
}
$neg = $this->negotiator ?? new DefaultErrorFormatNegotiator();
return $neg->wantsHtml($request);
}
private function computeDetail(Throwable $e): string
{
if ($this->debug) {
return $e->getMessage() . "\n\n" . $e->getTraceAsString();
}
return $e->getMessage();
}
private function mapStatus(Throwable $e): int
{
$mapper = $this->statusMapper ?? new DefaultExceptionToStatusMapper();
return $mapper->map($e);
}
private function renderWhoopsHtml(Throwable $e, string $requestId): ResponseInterface
{
if (class_exists(\Whoops\Run::class)) {
$handler = new \Whoops\Handler\PrettyPageHandler();
$whoops = new \Whoops\Run();
$whoops->allowQuit(false);
$whoops->writeToOutput(false);
$whoops->pushHandler($handler);
$html = (string) $whoops->handleException($e);
if ($html === '') {
ob_start();
$handler->handle($e);
$html = (string) ob_get_clean();
}
if ($html === '') {
$html = '<!doctype html><html><head><meta charset="utf-8"><title>Whoops</title></head><body><h1>Whoops</h1><pre>'
. htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</pre></body></html>';
}
$stream = Stream::create($html);
return (new Response(500, [
'Content-Type' => 'text/html; charset=UTF-8',
'X-Request-Id' => $requestId,
]))->withBody($stream);
}
return $this->respondPlainTextFallback($e->getMessage(), $requestId);
}
private function respondPlainTextFallback(string $message, string $requestId): ResponseInterface
{
$stream = Stream::create($message);
return (new Response(500, [
'Content-Type' => 'text/plain; charset=UTF-8',
'X-Request-Id' => $requestId,
]))->withBody($stream);
}
private function respondProblemDetails(Throwable $e, int $status, string $detail, string $requestId): ResponseInterface
{
$problem = new ApiProblem($this->shortClass($e) ?: 'Error');
$problem->setType('about:blank');
$problem->setTitle($this->shortClass($e));
$problem->setStatus($status);
$problem->setDetail($detail ?: 'An error occurred');
if ($this->debug) {
$problem['exception'] = [
'class' => get_class($e),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
$json = json_encode($problem, JSON_THROW_ON_ERROR);
$stream = Stream::create($json);
return (new Response($status, [
'Content-Type' => 'application/problem+json',
'X-Request-Id' => $requestId,
]))->withBody($stream);
}
private function respondJsonApiOrJson(Throwable $e, int $status, string $detail, string $format, string $requestId): ResponseInterface
{
$payload = [
'errors' => [[
'status' => (string) $status,
'title' => $this->shortClass($e),
'detail' => $detail,
]],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$stream = Stream::create($json);
$contentType = $format === 'jsonapi' ? 'application/vnd.api+json' : 'application/json';
return (new Response($status, [
'Content-Type' => $contentType,
'X-Request-Id' => $requestId,
]))->withBody($stream);
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Phred\Http;
use Psr\Http\Message\ServerRequestInterface;
/**
* Minimal request context holder for the current request during dispatch.
* DispatchMiddleware sets/clears it around controller invocation so that
* other services (e.g., response factory selector) can inspect negotiation.
*/
final class RequestContext
{
private static ?ServerRequestInterface $current = null;
public static function set(ServerRequestInterface $request): void
{
self::$current = $request;
}
public static function get(): ?ServerRequestInterface
{
return self::$current;
}
public static function clear(): void
{
self::$current = null;
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Responses;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
use Phred\Http\RequestContext;
use Psr\Http\Message\ResponseInterface;
/**
* Delegates to REST or JSON:API factory depending on current request format.
* Controllers receive this via DI and call its methods; it inspects
* RequestContext (set in DispatchMiddleware) to choose the underlying factory.
*/
final class DelegatingApiResponseFactory implements ApiResponseFactoryInterface
{
public function __construct(
private RestResponseFactory $rest,
private JsonApiResponseFactory $jsonapi
) {}
public function ok(array $data = []): ResponseInterface
{
return $this->delegate()->ok($data);
}
public function created(array $data = [], ?string $location = null): ResponseInterface
{
return $this->delegate()->created($data, $location);
}
public function noContent(): ResponseInterface
{
return $this->delegate()->noContent();
}
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
{
return $this->delegate()->error($status, $title, $detail, $extra);
}
public function fromArray(array $payload, int $status = 200): ResponseInterface
{
return $this->delegate()->fromArray($payload, $status);
}
private function delegate(): ApiResponseFactoryInterface
{
$req = RequestContext::get();
$format = $req?->getAttribute(Negotiation::ATTR_API_FORMAT) ?? 'rest';
return $format === 'jsonapi' ? $this->jsonapi : $this->rest;
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Responses;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
final class JsonApiResponseFactory implements ApiResponseFactoryInterface
{
public function __construct(private Psr17Factory $psr17 = new Psr17Factory()) {}
public function ok(array $data = []): ResponseInterface
{
return $this->document(['data' => $data], 200);
}
public function created(array $data = [], ?string $location = null): ResponseInterface
{
$res = $this->document(['data' => $data], 201);
if ($location) {
$res = $res->withHeader('Location', $location);
}
return $res;
}
public function noContent(): ResponseInterface
{
// JSON:API allows 204 without body
return $this->psr17->createResponse(204);
}
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
{
$error = array_filter([
'status' => (string) $status,
'title' => $title,
'detail' => $detail,
], static fn($v) => $v !== null && $v !== '');
if (!empty($extra)) {
$error = array_merge($error, $extra);
}
return $this->document(['errors' => [$error]], $status);
}
public function fromArray(array $payload, int $status = 200): ResponseInterface
{
// Caller must ensure payload is a valid JSON:API document shape
return $this->document($payload, $status);
}
/**
* @param array<string,mixed> $doc
*/
private function document(array $doc, int $status): ResponseInterface
{
$res = $this->psr17->createResponse($status)
->withHeader('Content-Type', 'application/vnd.api+json');
$res->getBody()->write(json_encode($doc, JSON_UNESCAPED_SLASHES));
return $res;
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Responses;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
final class RestResponseFactory implements ApiResponseFactoryInterface
{
public function __construct(private Psr17Factory $psr17 = new Psr17Factory()) {}
public function ok(array $data = []): ResponseInterface
{
return $this->json($data, 200);
}
public function created(array $data = [], ?string $location = null): ResponseInterface
{
$res = $this->json($data, 201);
if ($location) {
$res = $res->withHeader('Location', $location);
}
return $res;
}
public function noContent(): ResponseInterface
{
return $this->psr17->createResponse(204);
}
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
{
$payload = array_merge([
'type' => $extra['type'] ?? 'about:blank',
'title' => $title,
'status' => $status,
], $detail !== null ? ['detail' => $detail] : [], $extra);
$res = $this->psr17->createResponse($status)
->withHeader('Content-Type', 'application/problem+json');
$res->getBody()->write(json_encode($payload, JSON_UNESCAPED_SLASHES));
return $res;
}
public function fromArray(array $payload, int $status = 200): ResponseInterface
{
return $this->json($payload, $status);
}
private function json(array $data, int $status): ResponseInterface
{
$res = $this->psr17->createResponse($status)
->withHeader('Content-Type', 'application/json');
$res->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES));
return $res;
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Support;
use Phred\Http\Contracts\ErrorFormatNegotiatorInterface;
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
final class DefaultErrorFormatNegotiator implements ErrorFormatNegotiatorInterface
{
public function apiFormat(ServerRequest $request): string
{
$accept = $request->getHeaderLine('Accept');
return str_contains($accept, 'application/vnd.api+json') ? 'jsonapi' : 'rest';
}
public function wantsHtml(ServerRequest $request): bool
{
$accept = $request->getHeaderLine('Accept');
return $accept === '' || str_contains($accept, 'text/html');
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Support;
use Phred\Http\Contracts\ExceptionToStatusMapperInterface;
use Throwable;
final class DefaultExceptionToStatusMapper implements ExceptionToStatusMapperInterface
{
public function map(Throwable $e): int
{
$code = (int) ($e->getCode() ?: 500);
if ($code < 400 || $code > 599) {
return 500;
}
return $code;
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Support;
use Nyholm\Psr7\Response;
use Phred\Http\Contracts\RequestIdProviderInterface;
use Psr\Http\Message\ServerRequestInterface;
final class DefaultRequestIdProvider implements RequestIdProviderInterface
{
public function provide(ServerRequestInterface $request): string
{
$incoming = $request->getHeaderLine('X-Request-Id');
if ($incoming !== '') {
return $incoming;
}
return bin2hex(random_bytes(8));
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Phred\Support\Contracts;
interface ConfigInterface
{
/**
* Retrieve a configuration value by key.
* Supports dot.notation keys. Implementations define precedence.
*/
public function get(string $key, mixed $default = null): mixed;
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Phred\Support;
use Phred\Support\Contracts\ConfigInterface;
/**
* Default adapter that delegates to the legacy static Config facade.
*/
final class DefaultConfig implements ConfigInterface
{
public function get(string $key, mixed $default = null): mixed
{
return Config::get($key, $default);
}
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Phred\Tests;
use Nyholm\Psr7Server\ServerRequestCreator;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\Kernel;
use PHPUnit\Framework\TestCase;
final class ContentNegotiationTest extends TestCase
{
private function kernel(): Kernel
{
return new Kernel();
}
private function request(string $method, string $uri, array $headers = []): \Psr\Http\Message\ServerRequestInterface
{
$psr17 = new Psr17Factory();
$creator = new ServerRequestCreator($psr17, $psr17, $psr17, $psr17);
$server = [
'REQUEST_METHOD' => strtoupper($method),
'REQUEST_URI' => $uri,
];
return $creator->fromArrays($server, $headers, [], [], []);
}
public function testDefaultRestWhenNoAccept(): void
{
putenv('API_FORMAT'); // unset to use default
$kernel = $this->kernel();
$req = $this->request('GET', '/_phred/format');
$res = $kernel->handle($req);
$this->assertSame(200, $res->getStatusCode());
$this->assertStringStartsWith('application/json', $res->getHeaderLine('Content-Type'));
$data = json_decode((string) $res->getBody(), true);
$this->assertIsArray($data);
$this->assertSame('rest', $data['format'] ?? null);
}
public function testJsonApiWhenAcceptHeaderPresent(): void
{
putenv('API_FORMAT'); // unset
$kernel = $this->kernel();
$req = $this->request('GET', '/_phred/format', ['Accept' => 'application/vnd.api+json']);
$res = $kernel->handle($req);
$this->assertSame(200, $res->getStatusCode());
$this->assertSame('application/vnd.api+json', $res->getHeaderLine('Content-Type'));
$doc = json_decode((string) $res->getBody(), true);
$this->assertIsArray($doc);
$this->assertArrayHasKey('data', $doc);
$this->assertSame('jsonapi', $doc['data']['format'] ?? null);
}
public function testEnvDefaultJsonApiWithoutAccept(): void
{
putenv('API_FORMAT=jsonapi');
$kernel = $this->kernel();
$req = $this->request('GET', '/_phred/format');
$res = $kernel->handle($req);
$this->assertSame(200, $res->getStatusCode());
$this->assertSame('application/vnd.api+json', $res->getHeaderLine('Content-Type'));
$doc = json_decode((string) $res->getBody(), true);
$this->assertIsArray($doc);
$this->assertArrayHasKey('data', $doc);
$this->assertSame('jsonapi', $doc['data']['format'] ?? null);
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Phred\Tests;
use Nyholm\Psr7\ServerRequest;
use PHPUnit\Framework\TestCase;
final class ErrorHandlingTest extends TestCase
{
public function testRestProblemDetailsOnException(): void
{
$root = dirname(__DIR__);
/** @var object $app */
$app = require $root . '/bootstrap/app.php';
$this->assertInstanceOf(\Phred\Http\Kernel::class, $app);
// Default format is REST (unless ACCEPT requests JSON:API)
$request = new ServerRequest('GET', '/_phred/error');
$response = $app->handle($request);
$this->assertSame(500, $response->getStatusCode());
$this->assertSame('application/problem+json', $response->getHeaderLine('Content-Type'));
$data = json_decode((string) $response->getBody(), true);
$this->assertIsArray($data);
$this->assertArrayHasKey('type', $data);
$this->assertArrayHasKey('title', $data);
$this->assertArrayHasKey('status', $data);
$this->assertArrayHasKey('detail', $data);
$this->assertSame(500, $data['status']);
$this->assertSame('RuntimeException', $data['title']);
$this->assertStringContainsString('Boom', (string) $data['detail']);
}
public function testJsonApiErrorDocumentOnException(): void
{
$root = dirname(__DIR__);
/** @var object $app */
$app = require $root . '/bootstrap/app.php';
$this->assertInstanceOf(\Phred\Http\Kernel::class, $app);
$request = (new ServerRequest('GET', '/_phred/error'))
->withHeader('Accept', 'application/vnd.api+json');
$response = $app->handle($request);
$this->assertSame(500, $response->getStatusCode());
$this->assertSame('application/vnd.api+json', $response->getHeaderLine('Content-Type'));
$data = json_decode((string) $response->getBody(), true);
$this->assertIsArray($data);
$this->assertArrayHasKey('errors', $data);
$this->assertIsArray($data['errors']);
$this->assertNotEmpty($data['errors']);
$err = $data['errors'][0];
$this->assertSame('500', $err['status']);
$this->assertSame('RuntimeException', $err['title']);
$this->assertStringContainsString('Boom', (string) $err['detail']);
}
public function testWhoopsHtmlInDebugMode(): void
{
// Enable debug for this test
putenv('APP_DEBUG=true');
$root = dirname(__DIR__);
/** @var object $app */
$app = require $root . '/bootstrap/app.php';
$this->assertInstanceOf(\Phred\Http\Kernel::class, $app);
$request = (new ServerRequest('GET', '/_phred/error'))
->withHeader('Accept', 'text/html');
$response = $app->handle($request);
$this->assertSame(500, $response->getStatusCode());
$this->assertStringContainsString('text/html', $response->getHeaderLine('Content-Type'));
$html = (string) $response->getBody();
$this->assertNotSame('', $html);
$this->assertStringContainsString('Whoops', $html);
}
}

View file

@ -24,7 +24,7 @@ final class MakeCommandTest extends TestCase
@unlink($target);
}
$cmd = 'php ' . escapeshellarg($root . '/bin/phred') . ' make:command hello:world';
$cmd = 'php ' . escapeshellarg($root . '/bin/phred') . ' create:command hello:world';
$output = shell_exec($cmd) ?: '';
$this->assertStringContainsString('created', $output, 'Expected creation message');