Phred/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php
Funky Waddle 7d4265d60e Implement M5 service providers, M6 MVC bases, and URL extension negotiation; update docs and tests
• M5: Add ServiceProviderInterface and ProviderRepository; integrate providers into Kernel (register before container build, boot after); add RouteRegistry with clear(); add default core providers (Routing, Template, ORM, Flags, Testing) and AppServiceProvider; add contracts and default drivers (Template/Eyrie, Orm/Pairity, Flags/Flagpole, Testing/Codeception)
• Routing: allow providers to contribute routes; add ProviderRouteTest
• Config: add config/providers.php; extend config/app.php with driver keys; document env keys
• M6: Introduce MVC bases: Controller, APIController (JSON helpers), ViewController (html + renderView helpers), View (transformData + renderer); add ViewWithDefaultTemplate and default-template flow; adjust method signatures to data-first and delegate template override to View
• HTTP: Add UrlExtensionNegotiationMiddleware (opt-in via URL_EXTENSION_NEGOTIATION, whitelist via URL_EXTENSION_WHITELIST with default json|php|none); wire before ContentNegotiationMiddleware
• Tests: add UrlExtensionNegotiationTest and MvcViewTest; ensure RouteRegistry::clear prevents duplicate routes in tests
• Docs: Update README with M5 provider usage, M6 MVC examples and template selection conventions, and URL extension negotiation; mark M5 complete in MILESTONES; add M12 task to provide XML support and enable xml in whitelist by default
2025-12-15 16:08:57 -06:00

103 lines
3.6 KiB
PHP

<?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,
};
}
}