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:
parent
c691aab9ec
commit
fd1c9d23df
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -128,6 +128,9 @@ tmp/
|
||||||
/config/
|
/config/
|
||||||
/console/
|
/console/
|
||||||
|
|
||||||
|
# Local assistant/session preferences (developer-specific)
|
||||||
|
.junie.json
|
||||||
|
|
||||||
# Codeception outputs
|
# Codeception outputs
|
||||||
tests/_output/
|
tests/_output/
|
||||||
tests/_support/_generated/
|
tests/_support/_generated/
|
||||||
|
|
|
||||||
|
|
@ -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`).~~
|
* ~~Define configuration precedence and document keys (e.g., `API_FORMAT`, `APP_ENV`, `APP_DEBUG`).~~
|
||||||
* ~~Acceptance:~~
|
* ~~Acceptance:~~
|
||||||
* ~~App reads config from `.env`; unit test demonstrates override behavior.~~
|
* ~~App reads config from `.env`; unit test demonstrates override behavior.~~
|
||||||
## M3 — API formats and content negotiation
|
## ~~M3 — API formats and content negotiation~~
|
||||||
* Tasks:
|
* ~~Tasks:~~
|
||||||
* Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header.
|
* ~~Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header.~~
|
||||||
* Bind `ApiResponseFactoryInterface` to `RestResponseFactory` or `JsonApiResponseFactory` based on format.
|
* ~~Bind `ApiResponseFactoryInterface` to `RestResponseFactory` or `JsonApiResponseFactory` based on format.~~
|
||||||
* Provide developer‑facing helpers for common responses (`ok`, `created`, `error`).
|
* ~~Provide developer‑facing helpers for common responses (`ok`, `created`, `error`).~~
|
||||||
* Acceptance:
|
* ~~Acceptance:~~
|
||||||
* Demo endpoints respond correctly as REST or JSON:API depending on `API_FORMAT` and `Accept`.
|
* ~~Demo endpoints respond correctly as REST or JSON:API depending on `API_FORMAT` and `Accept`.~~
|
||||||
## M4 — Error handling and problem details
|
## ~~M4 — Error handling and problem details~~
|
||||||
* Tasks:
|
* ~~Tasks:~~
|
||||||
* Finalize `ProblemDetailsMiddleware` with RFC7807 (REST) and JSON:API error documents.
|
* ~~Finalize `ProblemDetailsMiddleware` with RFC7807 (REST) and JSON:API error documents.~~
|
||||||
* Integrate `filp/whoops` for dev mode (`APP_DEBUG=true`).
|
* ~~Integrate `filp/whoops` for dev mode (`APP_DEBUG=true`).~~
|
||||||
* Map common exceptions to HTTP status codes; include correlation/request IDs in responses/logs.
|
* ~~Map common exceptions to HTTP status codes; include correlation/request IDs in responses/logs.~~
|
||||||
* Acceptance:
|
* ~~Acceptance:~~
|
||||||
* Throwing an exception yields a standards‑compliant error response; debug mode shows Whoops page.
|
* ~~Throwing an exception yields a standards‑compliant error response; debug mode shows Whoops page.~~
|
||||||
## M5 — Dependency Injection and Service Providers
|
## M5 — Dependency Injection and Service Providers
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* Define Service Provider interface and lifecycle (register, boot).
|
* Define Service Provider interface and lifecycle (register, boot).
|
||||||
|
|
|
||||||
38
README.md
38
README.md
|
|
@ -9,13 +9,14 @@ A PHP MVC framework:
|
||||||
* PSR-4 autoloading.
|
* PSR-4 autoloading.
|
||||||
* Installed through Composer (`composer create-project getphred/phred`)
|
* Installed through Composer (`composer create-project getphred/phred`)
|
||||||
* Environment variables (.env) for configuration.
|
* Environment variables (.env) for configuration.
|
||||||
* Supports two API formats
|
* Supports two API formats (with content negotiation)
|
||||||
* pragmatic REST (default)
|
* Pragmatic REST (default)
|
||||||
* JSON:API
|
* JSON:API
|
||||||
* Choose via .env:
|
* Choose via .env:
|
||||||
* `API_FORMAT=rest` (plain JSON responses, RFC7807 error format.)
|
* `API_FORMAT=rest` (plain JSON responses, RFC7807 error format)
|
||||||
* `API_FORMAT=jsonapi` (JSON:API compliant documents and error objects.)
|
* `API_FORMAT=jsonapi` (JSON:API compliant documents and error objects)
|
||||||
* You may also negotiate per request using the `Accept` header.
|
* Or negotiate per request using the `Accept` header:
|
||||||
|
* `Accept: application/vnd.api+json` forces JSON:API for that request
|
||||||
* TESTING environment variables (.env)
|
* TESTING environment variables (.env)
|
||||||
* `TEST_RUNNER=codeception`
|
* `TEST_RUNNER=codeception`
|
||||||
* `TEST_PATH=tests`
|
* `TEST_PATH=tests`
|
||||||
|
|
@ -53,6 +54,24 @@ A PHP MVC framework:
|
||||||
* Invokable controllers (Actions),
|
* Invokable controllers (Actions),
|
||||||
* Single router call per controller,
|
* Single router call per controller,
|
||||||
* `public function __invoke(Request $request)` method entry point on controller class,
|
* `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
|
* VIEWS
|
||||||
* Classes for data manipulation/preparation before rendering Templates,
|
* Classes for data manipulation/preparation before rendering Templates,
|
||||||
* `$this->render(<template_name>, <data_array>);` to render a template.
|
* `$this->render(<template_name>, <data_array>);` to render a template.
|
||||||
|
|
@ -139,6 +158,15 @@ Common keys
|
||||||
- `APP_TIMEZONE` (default `UTC`)
|
- `APP_TIMEZONE` (default `UTC`)
|
||||||
- `API_FORMAT` (`rest` | `jsonapi`; default `rest`)
|
- `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
|
Examples
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
|
|
||||||
44
src/Http/Contracts/ApiResponseFactoryInterface.php
Normal file
44
src/Http/Contracts/ApiResponseFactoryInterface.php
Normal 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;
|
||||||
|
}
|
||||||
20
src/Http/Contracts/ErrorFormatNegotiatorInterface.php
Normal file
20
src/Http/Contracts/ErrorFormatNegotiatorInterface.php
Normal 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;
|
||||||
|
}
|
||||||
14
src/Http/Contracts/ExceptionToStatusMapperInterface.php
Normal file
14
src/Http/Contracts/ExceptionToStatusMapperInterface.php
Normal 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 (400–599), defaulting to 500 when out of range.
|
||||||
|
*/
|
||||||
|
public function map(Throwable $e): int;
|
||||||
|
}
|
||||||
15
src/Http/Contracts/RequestIdProviderInterface.php
Normal file
15
src/Http/Contracts/RequestIdProviderInterface.php
Normal 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;
|
||||||
|
}
|
||||||
19
src/Http/Controllers/FormatController.php
Normal file
19
src/Http/Controllers/FormatController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,8 +42,15 @@ final class Kernel
|
||||||
{
|
{
|
||||||
$psr17 = new Psr17Factory();
|
$psr17 = new Psr17Factory();
|
||||||
$middleware = [
|
$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\RoutingMiddleware($this->dispatcher, $psr17),
|
||||||
new Middleware\DispatchMiddleware($this->container, $psr17),
|
new Middleware\DispatchMiddleware($psr17),
|
||||||
];
|
];
|
||||||
$relay = new Relay($middleware);
|
$relay = new Relay($middleware);
|
||||||
return $relay->handle($request);
|
return $relay->handle($request);
|
||||||
|
|
@ -53,6 +60,15 @@ final class Kernel
|
||||||
{
|
{
|
||||||
$builder = new ContainerBuilder();
|
$builder = new ContainerBuilder();
|
||||||
// Add definitions/bindings here as needed.
|
// 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();
|
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/health', [Controllers\HealthController::class, '__invoke']);
|
||||||
|
$r->addRoute('GET', '/_phred/format', [Controllers\FormatController::class, '__invoke']);
|
||||||
};
|
};
|
||||||
|
|
||||||
return simpleDispatcher($collector);
|
return simpleDispatcher($collector);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Phred\Http\Middleware;
|
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\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
|
@ -12,15 +15,20 @@ use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
|
||||||
class ContentNegotiationMiddleware implements MiddlewareInterface
|
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 const ATTR_API_FORMAT = 'phred.api_format'; // 'rest' | 'jsonapi'
|
||||||
|
|
||||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
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
|
// Optional: allow Accept header to override when JSON:API is explicitly requested
|
||||||
$accept = $request->getHeaderLine('Accept');
|
$neg = $this->negotiator ?? new DefaultErrorFormatNegotiator();
|
||||||
if (str_contains($accept, 'application/vnd.api+json')) {
|
if ($neg->apiFormat($request) === 'jsonapi') {
|
||||||
$format = 'jsonapi';
|
$format = 'jsonapi';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Phred\Http\Middleware;
|
namespace Phred\Http\Middleware;
|
||||||
|
|
||||||
use DI\Container;
|
use DI\ContainerBuilder;
|
||||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||||
|
use Phred\Http\RequestContext;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
|
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
|
@ -13,49 +14,91 @@ use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
final class DispatchMiddleware implements MiddlewareInterface
|
final class DispatchMiddleware implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Container $container,
|
|
||||||
private Psr17Factory $psr17
|
private Psr17Factory $psr17
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(ServerRequest $request, Handler $handler): ResponseInterface
|
public function process(ServerRequest $request, Handler $handler): ResponseInterface
|
||||||
{
|
{
|
||||||
$handlerSpec = $request->getAttribute('phred.route.handler');
|
$handlerSpec = $request->getAttribute('phred.route.handler');
|
||||||
$vars = $request->getAttribute('phred.route.vars', []);
|
$vars = (array) $request->getAttribute('phred.route.vars', []);
|
||||||
|
|
||||||
if (!$handlerSpec) {
|
if (!$handlerSpec) {
|
||||||
$response = $this->psr17->createResponse(500);
|
return $this->jsonError('No route handler', 500);
|
||||||
$response->getBody()->write(json_encode(['error' => 'No route handler'], JSON_UNESCAPED_SLASHES));
|
|
||||||
return $response->withHeader('Content-Type', 'application/json');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve controller from container if it's a class name array [class, method] or string class
|
$requestContainer = $this->buildRequestScopedContainer($request);
|
||||||
if (is_array($handlerSpec) && is_string($handlerSpec[0])) {
|
$callable = $this->resolveCallable($handlerSpec, $requestContainer);
|
||||||
$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
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $callable($request, ...array_values((array) $vars));
|
RequestContext::set($request);
|
||||||
|
try {
|
||||||
|
$response = $this->invokeCallable($callable, $request, $vars);
|
||||||
|
} finally {
|
||||||
|
RequestContext::clear();
|
||||||
|
}
|
||||||
|
|
||||||
if (!$response instanceof ResponseInterface) {
|
if (!$response instanceof ResponseInterface) {
|
||||||
// Normalize simple arrays/strings into JSON/text response for convenience
|
return $this->normalizeToResponse($response);
|
||||||
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 $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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,14 @@ namespace Phred\Http\Middleware;
|
||||||
use Crell\ApiProblem\ApiProblem;
|
use Crell\ApiProblem\ApiProblem;
|
||||||
use Nyholm\Psr7\Response;
|
use Nyholm\Psr7\Response;
|
||||||
use Nyholm\Psr7\Stream;
|
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\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
|
@ -16,8 +23,14 @@ use Throwable;
|
||||||
|
|
||||||
class ProblemDetailsMiddleware implements MiddlewareInterface
|
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
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
|
|
@ -25,60 +38,29 @@ class ProblemDetailsMiddleware implements MiddlewareInterface
|
||||||
try {
|
try {
|
||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$useProblem = filter_var(Config::get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN);
|
$useProblem = $this->shouldUseProblemDetails();
|
||||||
$format = strtolower((string) $request->getAttribute(ContentNegotiationMiddleware::ATTR_API_FORMAT, 'rest'));
|
$format = $this->determineApiFormat($request);
|
||||||
|
$requestId = $this->provideRequestId($request);
|
||||||
|
|
||||||
if ($this->debug) {
|
if ($this->shouldRenderHtml($request)) {
|
||||||
// In debug mode, include trace in detail to aid development.
|
return $this->renderWhoopsHtml($e, $requestId);
|
||||||
$detail = $e->getMessage() . "\n\n" . $e->getTraceAsString();
|
|
||||||
} else {
|
|
||||||
$detail = $e->getMessage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = $this->deriveStatus($e);
|
$detail = $this->computeDetail($e);
|
||||||
|
$status = $this->mapStatus($e);
|
||||||
|
|
||||||
if ($useProblem && $format !== 'jsonapi') {
|
if ($useProblem && $format !== 'jsonapi') {
|
||||||
$problem = new ApiProblem($detail ?: 'An error occurred');
|
return $this->respondProblemDetails($e, $status, $detail, $requestId);
|
||||||
$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);
|
return $this->respondJsonApiOrJson($e, $status, $detail, $format, $requestId);
|
||||||
$stream = Stream::create($json);
|
|
||||||
return (new Response($status, ['Content-Type' => 'application/problem+json']))->withBody($stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function deriveStatus(Throwable $e): int
|
private function deriveStatus(Throwable $e): int
|
||||||
{
|
{
|
||||||
$code = (int) ($e->getCode() ?: 500);
|
// Kept for backward compatibility in case of external references; delegate to default mapper.
|
||||||
if ($code < 400 || $code > 599) {
|
return (new DefaultExceptionToStatusMapper())->map($e);
|
||||||
return 500;
|
|
||||||
}
|
|
||||||
return $code;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function shortClass(object $o): string
|
private function shortClass(object $o): string
|
||||||
|
|
@ -90,4 +72,127 @@ class ProblemDetailsMiddleware implements MiddlewareInterface
|
||||||
}
|
}
|
||||||
return $fqcn;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/Http/RequestContext.php
Normal file
31
src/Http/RequestContext.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/Http/Responses/DelegatingApiResponseFactory.php
Normal file
54
src/Http/Responses/DelegatingApiResponseFactory.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/Http/Responses/JsonApiResponseFactory.php
Normal file
63
src/Http/Responses/JsonApiResponseFactory.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/Http/Responses/RestResponseFactory.php
Normal file
59
src/Http/Responses/RestResponseFactory.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Http/Support/DefaultErrorFormatNegotiator.php
Normal file
22
src/Http/Support/DefaultErrorFormatNegotiator.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Http/Support/DefaultExceptionToStatusMapper.php
Normal file
19
src/Http/Support/DefaultExceptionToStatusMapper.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Http/Support/DefaultRequestIdProvider.php
Normal file
20
src/Http/Support/DefaultRequestIdProvider.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Support/Contracts/ConfigInterface.php
Normal file
13
src/Support/Contracts/ConfigInterface.php
Normal 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;
|
||||||
|
}
|
||||||
17
src/Support/DefaultConfig.php
Normal file
17
src/Support/DefaultConfig.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
tests/ContentNegotiationTest.php
Normal file
69
tests/ContentNegotiationTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
tests/ErrorHandlingTest.php
Normal file
79
tests/ErrorHandlingTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ final class MakeCommandTest extends TestCase
|
||||||
@unlink($target);
|
@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) ?: '';
|
$output = shell_exec($cmd) ?: '';
|
||||||
$this->assertStringContainsString('created', $output, 'Expected creation message');
|
$this->assertStringContainsString('created', $output, 'Expected creation message');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue