• 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
103 lines
3.6 KiB
PHP
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,
|
|
};
|
|
}
|
|
}
|