From 8cf6a7b96a69f2bc5c842dc24e46eb7a5b380853 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Wed, 18 Mar 2026 03:13:06 -0300 Subject: [PATCH] Extract FileExtension routine from Accept content negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated FileExtension routine that handles URL extension mapping (.json, .html, etc.) independently from Accept header negotiation. Only declared extensions are stripped during route matching, so dots in paths like /users/john.doe no longer get mangled. Multiple FileExtension routines can cascade for compound extensions like .json.en — each peels its extension from right to left. Decouple AbstractAccept from IgnorableFileExtension entirely, making it pure header-based content negotiation. Promote IgnorableFileExtension from marker interface to a real interface with getExtensions() so route matching knows exactly which extensions to strip. --- docs/README.md | 32 +++++++ example/full.php | 16 ++++ src/Routes/AbstractRoute.php | 39 +++++++-- src/Routines/AbstractAccept.php | 35 +------- src/Routines/FileExtension.php | 81 ++++++++++++++++++ src/Routines/IgnorableFileExtension.php | 2 + tests/RouterTest.php | 109 +++++++++++++++++++++--- tests/Routes/AbstractRouteTest.php | 50 +++++------ 8 files changed, 283 insertions(+), 81 deletions(-) create mode 100644 src/Routines/FileExtension.php diff --git a/docs/README.md b/docs/README.md index d56ee53..5bbb812 100644 --- a/docs/README.md +++ b/docs/README.md @@ -403,8 +403,40 @@ $r3->any('/listeners/*', function ($user) { /***/ }); Since there are three routes with the `$user` parameter, `when` will verify them all automatically by name. +## File Extensions + +Use the `fileExtension` routine to map URL extensions to response transformations: +```php +$r3->get('/users/*', function($name) { + return ['name' => $name]; +})->fileExtension([ + '.json' => 'json_encode', + '.html' => function($data) { return "

{$data['name']}

"; }, +]); +``` + +Requesting `/users/alganet.json` strips the `.json` extension, passes `alganet` as the +parameter, and applies `json_encode` to the response. + +Only declared extensions are stripped. A URL like `/users/john.doe` with no `.doe` declared +will match normally with `john.doe` as the full parameter. + +### Multiple Extensions + +Multiple `fileExtension` routines can cascade for compound extensions like `.json.en`. +Declare the outermost extension (rightmost in the URL) first: +```php +$r3->get('/page/*', $handler) + ->fileExtension(['.en' => $translateEn, '.pt' => $translatePt]) + ->fileExtension(['.json' => 'json_encode', '.html' => $render]); +``` + +Requesting `/page/about.json.en` strips `.en` (first routine), then `.json` (second routine), +and applies both callbacks in order. + ## Content Negotiation +Content negotiation uses HTTP Accept headers to select the appropriate response format. Respect\Rest supports four distinct types of Accept header content-negotiation: Mimetype, Encoding, Language and Charset: ```php diff --git a/example/full.php b/example/full.php index 96b019c..2258c4f 100644 --- a/example/full.php +++ b/example/full.php @@ -19,6 +19,8 @@ * GET /boom → Exception route * GET /status → Static value * GET /time → PSR-7 injection + * GET /data/users.json → File extension (JSON) + * GET /data/users.html → File extension (HTML) */ require __DIR__ . '/../vendor/autoload.php'; @@ -138,6 +140,20 @@ public function get(string $id): string }; }); +// --- File Extensions --- + +$r3->get('/data/*', function (string $resource) { + return ['resource' => $resource, 'items' => ['a', 'b', 'c']]; +})->fileExtension([ + '.json' => 'json_encode', + '.html' => function (array $data) { + $name = htmlspecialchars($data['resource']); + $items = array_map('htmlspecialchars', $data['items']); + + return "

{$name}

'; + }, +]); + // --- Content Negotiation --- $r3->get('/json', function () { diff --git a/src/Routes/AbstractRoute.php b/src/Routes/AbstractRoute.php index 94029d8..822520c 100644 --- a/src/Routes/AbstractRoute.php +++ b/src/Routes/AbstractRoute.php @@ -14,10 +14,13 @@ use Respect\Rest\Routines\Routinable; use Respect\Rest\Routines\Unique; +use function array_map; +use function array_merge; use function array_pop; use function array_shift; use function end; use function explode; +use function implode; use function is_a; use function is_string; use function ltrim; @@ -34,6 +37,7 @@ use function strtoupper; use function substr; use function ucfirst; +use function usort; /** * Base class for all Routes @@ -45,6 +49,7 @@ * @method self authBasic(mixed ...$args) * @method self by(mixed ...$args) * @method self contentType(mixed ...$args) + * @method self fileExtension(mixed ...$args) * @method self lastModified(mixed ...$args) * @method self through(mixed ...$args) * @method self userAgent(mixed ...$args) @@ -166,16 +171,38 @@ public function match(DispatchContext $context, array &$params = []): bool $params = []; $matchUri = $context->path(); + $allExtensions = []; foreach ($this->routines as $routine) { - if (!($routine instanceof IgnorableFileExtension)) { + if (!$routine instanceof IgnorableFileExtension) { continue; } - $matchUri = preg_replace( - '#(\.[\w\d\-_.~\+]+)*$#', - '', - $context->path(), - ) ?? $context->path(); + $allExtensions = array_merge($allExtensions, $routine->getExtensions()); + } + + if ($allExtensions !== []) { + usort($allExtensions, static fn(string $a, string $b): int => strlen($b) <=> strlen($a)); + $escaped = array_map(static fn(string $e): string => preg_quote($e, '#'), $allExtensions); + $extPattern = '#(' . implode('|', $escaped) . ')$#'; + + $suffix = ''; + $stripping = true; + while ($stripping) { + $stripped = preg_replace($extPattern, '', $matchUri, 1, $count); + if ($count > 0 && $stripped !== null && $stripped !== $matchUri) { + $suffix = substr($matchUri, strlen($stripped)) . $suffix; + $matchUri = $stripped; + } else { + $stripping = false; + } + } + + if ($suffix !== '') { + $context->request = $context->request->withAttribute( + 'respect.ext.remaining', + $suffix, + ); + } } if (!preg_match($this->regexForMatch, $matchUri, $params)) { diff --git a/src/Routines/AbstractAccept.php b/src/Routines/AbstractAccept.php index f1e7d10..528880e 100644 --- a/src/Routines/AbstractAccept.php +++ b/src/Routines/AbstractAccept.php @@ -7,14 +7,12 @@ use Respect\Rest\DispatchContext; use function array_keys; -use function array_pop; use function array_slice; use function arsort; use function explode; use function preg_replace; use function str_replace; use function str_starts_with; -use function stripos; use function strpos; use function strtolower; use function substr; @@ -26,30 +24,13 @@ abstract class AbstractAccept extends AbstractCallbackMediator implements ProxyableBy, ProxyableThrough, - Unique, - IgnorableFileExtension + Unique { public const string ACCEPT_HEADER = ''; - protected string $requestUri = ''; - /** @param array $params */ public function by(DispatchContext $context, array $params): mixed { - $unsyncedParams = $context->params; - $extensions = $this->filterKeysContain('.'); - - if (empty($extensions) || empty($unsyncedParams)) { - return null; - } - - $unsyncedParams[] = str_replace( - $extensions, - '', - array_pop($unsyncedParams), - ); - $context->params = $unsyncedParams; - return null; } @@ -66,8 +47,6 @@ public function through(DispatchContext $context, array $params): mixed */ protected function identifyRequested(DispatchContext $context, array $params): array { - $this->requestUri = $context->path(); - $headerName = $this->getAcceptHeaderName(); $acceptHeader = $context->request->getHeaderLine($headerName); @@ -93,7 +72,7 @@ protected function identifyRequested(DispatchContext $context, array $params): a /** @return array */ protected function considerProvisions(string $requested): array { - return $this->getKeys(); // no need to split see authorize + return $this->getKeys(); } /** @param array $params */ @@ -105,10 +84,6 @@ protected function notifyApproved( ): void { $this->rememberNegotiatedCallback($context, $this->getCallback($provided)); - if (strpos($provided, '.') !== false) { - return; - } - $headerType = $this->getNegotiatedHeaderType(); $contentHeader = 'Content-Type'; @@ -138,16 +113,10 @@ protected function notifyDeclined( protected function authorize(string $requested, string $provided): mixed { - // negotiate on file extension - if (strpos($provided, '.') !== false) { - return stripos($this->requestUri, $provided) !== false; - } - if ($requested === '*') { return true; } - // normal matching requirements return $requested == $provided; } diff --git a/src/Routines/FileExtension.php b/src/Routines/FileExtension.php new file mode 100644 index 0000000..ea720b4 --- /dev/null +++ b/src/Routines/FileExtension.php @@ -0,0 +1,81 @@ +|null */ + private SplObjectStorage|null $negotiated = null; + + /** @return array */ + public function getExtensions(): array + { + return $this->getKeys(); + } + + /** @param array $params */ + public function by(DispatchContext $context, array $params): mixed + { + $remaining = (string) $context->request->getAttribute(self::REMAINING_ATTRIBUTE, ''); + + if ($remaining === '') { + return null; + } + + $keys = $this->getKeys(); + usort($keys, static fn(string $a, string $b): int => strlen($b) <=> strlen($a)); + + foreach ($keys as $ext) { + if (!str_ends_with($remaining, $ext)) { + continue; + } + + $remaining = substr($remaining, 0, -strlen($ext)); + $context->request = $context->request->withAttribute( + self::REMAINING_ATTRIBUTE, + $remaining, + ); + $this->remember($context, $this->getCallback($ext)); + + return null; + } + + return null; + } + + /** @param array $params */ + public function through(DispatchContext $context, array $params): mixed + { + if (!$this->negotiated instanceof SplObjectStorage || !$this->negotiated->offsetExists($context)) { + return null; + } + + return $this->negotiated[$context]; + } + + private function remember(DispatchContext $context, callable $callback): void + { + if (!$this->negotiated instanceof SplObjectStorage) { + /** @var SplObjectStorage $storage */ + $storage = new SplObjectStorage(); + $this->negotiated = $storage; + } + + $this->negotiated[$context] = $callback; + } +} diff --git a/src/Routines/IgnorableFileExtension.php b/src/Routines/IgnorableFileExtension.php index c9ad80a..6048306 100644 --- a/src/Routines/IgnorableFileExtension.php +++ b/src/Routines/IgnorableFileExtension.php @@ -6,4 +6,6 @@ interface IgnorableFileExtension { + /** @return array Extensions this routine handles, e.g. ['.json', '.html'] */ + public function getExtensions(): array; } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 93dcc71..4a16465 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -1110,26 +1110,24 @@ public function testAcceptEncoding(): void self::assertEquals(strrev('foobar'), $r); } - public function testAcceptUrl(): void + public function testFileExtensionUrl(): void { - $serverRequest = (new ServerRequest('get', '/users/alganet.json')) - ->withHeader('Accept', '*/*'); + $serverRequest = new ServerRequest('get', '/users/alganet.json'); $request = self::newContextForRouter($this->router, $serverRequest); $this->router->get('/users/*', static function ($screenName) { return range(0, 10); - })->accept(['.json' => 'json_encode']); + })->fileExtension(['.json' => 'json_encode']); $r = self::responseBody($this->router->dispatchContext($request)); self::assertEquals(json_encode(range(0, 10)), $r); } - public function testAcceptUrlNoParameters(): void + public function testFileExtensionUrlNoParameters(): void { - $serverRequest = (new ServerRequest('get', '/users.json')) - ->withHeader('Accept', '*/*'); + $serverRequest = new ServerRequest('get', '/users.json'); $request = self::newContextForRouter($this->router, $serverRequest); $this->router->get('/users', static function () { return range(0, 10); - })->accept(['.json' => 'json_encode']); + })->fileExtension(['.json' => 'json_encode']); $r = self::responseBody($this->router->dispatchContext($request)); self::assertEquals(json_encode(range(0, 10)), $r); } @@ -1146,6 +1144,51 @@ public function testFileExtension(): void self::assertEquals(json_encode(range(10, 20)), $r); } + public function testFileExtensionCascading(): void + { + $translateEn = static function ($d) { + return $d . ':en'; + }; + $encodeJson = static function ($d) { + return '{' . $d . '}'; + }; + + $router = self::newRouter(); + $router->get('/page/*', static function (string $slug) { + return $slug; + }) + ->fileExtension(['.en' => $translateEn, '.pt' => $translateEn]) + ->fileExtension(['.json' => $encodeJson, '.html' => $encodeJson]); + + $response = $router->dispatch(new ServerRequest('GET', '/page/about.json.en'))->response(); + self::assertNotNull($response); + self::assertSame('{about:en}', (string) $response->getBody()); + } + + public function testFileExtensionLenientUnknownExtension(): void + { + $router = self::newRouter(); + $router->get('/users/*', static function (string $name) { + return $name; + })->fileExtension(['.json' => 'json_encode']); + + $response = $router->dispatch(new ServerRequest('GET', '/users/john.doe'))->response(); + self::assertNotNull($response); + self::assertSame('john.doe', (string) $response->getBody()); + } + + public function testFileExtensionNoExtensionInUrl(): void + { + $router = self::newRouter(); + $router->get('/users/*', static function (string $name) { + return $name; + })->fileExtension(['.json' => 'json_encode']); + + $response = $router->dispatch(new ServerRequest('GET', '/users/alganet'))->response(); + self::assertNotNull($response); + self::assertSame('alganet', (string) $response->getBody()); + } + public function testAcceptGeneric2(): void { $serverRequest = (new ServerRequest('get', '/users/alganet')) @@ -1828,6 +1871,46 @@ public function testDispatchEngineHandlePreservesExceptionRoutes(): void self::assertSame('caught', (string) $response->getBody()); } + public function testExceptionRouteRespectsGlobalAcceptRoutine(): void + { + $router = self::newRouter(); + $router->always('Accept', [ + 'application/json' => static function ($data) { + return json_encode(['error' => $data]); + }, + ]); + $router->get('/', static function (): never { + throw new InvalidArgumentException('boom'); + }); + $router->exceptionRoute(InvalidArgumentException::class, static function (InvalidArgumentException $e) { + return $e->getMessage(); + }); + + $request = (new ServerRequest('GET', '/'))->withHeader('Accept', 'application/json'); + $response = $router->dispatch($request)->response(); + + self::assertNotNull($response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('{"error":"boom"}', (string) $response->getBody()); + } + + public function testExceptionRouteReturningEmptyStringDoesNotRethrow(): void + { + $router = self::newRouter(); + $router->get('/', static function (): never { + throw new InvalidArgumentException('boom'); + }); + $router->exceptionRoute(InvalidArgumentException::class, static function () { + return ''; + }); + + $response = $router->dispatch(new ServerRequest('GET', '/'))->response(); + + self::assertNotNull($response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('', (string) $response->getBody()); + } + public function testRouterImplementsMiddlewareInterface(): void { self::assertInstanceOf(MiddlewareInterface::class, $this->router); @@ -2415,14 +2498,14 @@ public static function provider_content_type_extension(): array } #[DataProvider('provider_content_type_extension')] - public function test_do_not_set_automatic_content_type_header_for_extensions(string $ctype, string $ext): void + public function test_file_extension_does_not_set_content_type_header(string $ctype, string $ext): void { $r = self::newRouter(); - $r->get('/auto', '')->accept([$ext => 'json_encode']); + $r->get('/auto', '')->fileExtension([$ext => 'json_encode']); - $r = $r->dispatch(new ServerRequest('get', '/auto' . $ext))->response(); - // Extension-based accept should not set Content-Type header - self::assertNotNull($r); + $response = $r->dispatch(new ServerRequest('get', '/auto' . $ext))->response(); + self::assertNotNull($response); + self::assertFalse($response->hasHeader('Content-Type')); } /** @covers \Respect\Rest\Routes\AbstractRoute */ diff --git a/tests/Routes/AbstractRouteTest.php b/tests/Routes/AbstractRouteTest.php index 186c596..5b2e001 100644 --- a/tests/Routes/AbstractRouteTest.php +++ b/tests/Routes/AbstractRouteTest.php @@ -10,7 +10,6 @@ use PHPUnit\Framework\TestCase; use Respect\Rest\Responder; use Respect\Rest\Router; -use Respect\Rest\Routes\Factory; /** @covers Respect\Rest\Routes\AbstractRoute */ final class AbstractRouteTest extends TestCase @@ -19,49 +18,42 @@ final class AbstractRouteTest extends TestCase public static function extensions_provider(): array { return [ - ['test.json', 'test'], - ['test.bz2', 'test'], - ['test.json~user', 'test'], - ['test.hal+json', 'test'], - ['test.en.html', 'test'], - ['test.vnd.amazon.ebook', 'test'], - ['test.vnd.hp-hps', 'test'], - ['test.json-patch', 'test'], - ['test.my_funny.ext', 'test'], + ['.json', 'test.json', 'test'], + ['.bz2', 'test.bz2', 'test'], + ['.html', 'test.en.html', 'test.en'], + ['.ext', 'test.my_funny.ext', 'test.my_funny'], ]; } /** @covers Respect\Rest\Routes\AbstractRoute::match */ #[DataProvider('extensions_provider')] - public function testIgnoreFileExtensions(string $with, string $without): void + public function testIgnoreFileExtensions(string $ext, string $with, string $without): void { $r = new Router('', new Psr17Factory()); + + // Route without FileExtension: dots preserved in params $r->get('/route1/*', static function ($match) { return $match; }); + + // Route with FileExtension: declared extension stripped $r->get('/route2/*', static function ($match) { return $match; - }) - ->accept([ - '.json-home' => static function ($data) { - /** @phpstan-ignore-next-line */ - return Factory::respond('.json-home', $data); - }, - '*' => static function ($data) { - return $data . '.accepted'; - }, - ]); + })->fileExtension([ + $ext => static function ($data) { + return $data . '.transformed'; + }, + ]); - $serverRequest1 = (new ServerRequest('get', '/route1/' . $with))->withHeader('Accept', '*'); - $resp1 = $r->dispatch($serverRequest1)->response(); + // route1: extension NOT stripped (no IgnorableFileExtension routine) + $resp1 = $r->dispatch(new ServerRequest('get', '/route1/' . $with))->response(); self::assertNotNull($resp1); - $response = (string) $resp1->getBody(); - self::assertEquals($with, $response); - $serverRequest2 = (new ServerRequest('get', '/route2/' . $with))->withHeader('Accept', '*'); - $resp2 = $r->dispatch($serverRequest2)->response(); + self::assertEquals($with, (string) $resp1->getBody()); + + // route2: declared extension stripped, callback applied + $resp2 = $r->dispatch(new ServerRequest('get', '/route2/' . $with))->response(); self::assertNotNull($resp2); - $response = (string) $resp2->getBody(); - self::assertEquals($without . '.accepted', $response); + self::assertEquals($without . '.transformed', (string) $resp2->getBody()); } public function testWrapResponseNormalizesArrayResults(): void