Phred/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php

103 lines
3.6 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Phred\Support\Config;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
/**
* Parses a trailing URL extension and hints content negotiation.
*
* - Controlled by env/config `URL_EXTENSION_NEGOTIATION` (bool, default true)
* - Allowed extensions by env/config `URL_EXTENSION_WHITELIST`
* - Pipe-separated list: e.g., "json|php|none" (default: "json|php|none")
* - Behavior:
* - Detects and strips ".ext" at the end of path if ext is whitelisted (except `none` which means no ext)
* - Sets request attribute `phred.format_hint` to ext (json|xml|html) mapping:
* json -> json
* xml -> xml (not implemented yet; reserved for M12)
* php/none -> html
* - Optionally, sets Accept header mapping for downstream negotiation:
* json -> application/json
* xml -> application/xml (reserved)
* html -> text/html
*/
final class UrlExtensionNegotiationMiddleware implements MiddlewareInterface
{
public const ATTR_FORMAT_HINT = 'phred.format_hint';
public function process(Request $request, Handler $handler): ResponseInterface
{
$enabled = filter_var((string) Config::get('URL_EXTENSION_NEGOTIATION', 'true'), FILTER_VALIDATE_BOOLEAN);
if (!$enabled) {
return $handler->handle($request);
}
$whitelistRaw = (string) Config::get('URL_EXTENSION_WHITELIST', 'json|php|none');
$allowed = array_filter(array_map('trim', explode('|', strtolower($whitelistRaw))));
$allowed = $allowed ?: ['json', 'php', 'none'];
$uri = $request->getUri();
$path = $uri->getPath();
$ext = null;
if (preg_match('/\.([a-z0-9]+)$/i', $path, $m)) {
$candidate = strtolower($m[1]);
if (in_array($candidate, $allowed, true)) {
$ext = $candidate;
// strip the extension from the path for routing purposes
$path = substr($path, 0, - (strlen($candidate) + 1));
}
} else {
// no extension → treat as 'none' if allowed
if (in_array('none', $allowed, true)) {
$ext = 'none';
}
}
if ($ext !== null) {
$hint = $this->mapToHint($ext);
if ($hint !== null) {
$request = $request->withAttribute(self::ATTR_FORMAT_HINT, $hint);
// Optionally set an Accept header override for downstream negotiation
$accept = $this->mapToAccept($hint);
if ($accept !== null) {
$request = $request->withHeader('Accept', $accept);
}
}
}
// If we modified the path, update the URI so router matches sans extension
if ($path !== $uri->getPath()) {
$newUri = $uri->withPath($path === '' ? '/' : $path);
$request = $request->withUri($newUri);
}
return $handler->handle($request);
}
private function mapToHint(string $ext): ?string
{
return match ($ext) {
'json' => 'json',
'xml' => 'xml', // reserved for M12
'php', 'none' => 'html',
default => null,
};
}
private function mapToAccept(string $hint): ?string
{
return match ($hint) {
'json' => 'application/json',
'xml' => 'application/xml', // reserved for M12
'html' => 'text/html',
default => null,
};
}
}