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}
- " . implode('
- ', $items) . '
';
+ },
+]);
+
// --- 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