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