Skip to content

Commit 94d7d69

Browse files
committed
Extract FileExtension routine from Accept content negotiation
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.
1 parent 089f417 commit 94d7d69

8 files changed

Lines changed: 283 additions & 81 deletions

File tree

docs/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,40 @@ $r3->any('/listeners/*', function ($user) { /***/ });
403403
Since there are three routes with the `$user` parameter, `when` will
404404
verify them all automatically by name.
405405

406+
## File Extensions
407+
408+
Use the `fileExtension` routine to map URL extensions to response transformations:
409+
```php
410+
$r3->get('/users/*', function($name) {
411+
return ['name' => $name];
412+
})->fileExtension([
413+
'.json' => 'json_encode',
414+
'.html' => function($data) { return "<h1>{$data['name']}</h1>"; },
415+
]);
416+
```
417+
418+
Requesting `/users/alganet.json` strips the `.json` extension, passes `alganet` as the
419+
parameter, and applies `json_encode` to the response.
420+
421+
Only declared extensions are stripped. A URL like `/users/john.doe` with no `.doe` declared
422+
will match normally with `john.doe` as the full parameter.
423+
424+
### Multiple Extensions
425+
426+
Multiple `fileExtension` routines can cascade for compound extensions like `.json.en`.
427+
Declare the outermost extension (rightmost in the URL) first:
428+
```php
429+
$r3->get('/page/*', $handler)
430+
->fileExtension(['.en' => $translateEn, '.pt' => $translatePt])
431+
->fileExtension(['.json' => 'json_encode', '.html' => $render]);
432+
```
433+
434+
Requesting `/page/about.json.en` strips `.en` (first routine), then `.json` (second routine),
435+
and applies both callbacks in order.
436+
406437
## Content Negotiation
407438

439+
Content negotiation uses HTTP Accept headers to select the appropriate response format.
408440
Respect\Rest supports four distinct types of Accept header content-negotiation:
409441
Mimetype, Encoding, Language and Charset:
410442
```php

example/full.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
* GET /boom → Exception route
2020
* GET /status → Static value
2121
* GET /time → PSR-7 injection
22+
* GET /data/users.json → File extension (JSON)
23+
* GET /data/users.html → File extension (HTML)
2224
*/
2325

2426
require __DIR__ . '/../vendor/autoload.php';
@@ -138,6 +140,20 @@ public function get(string $id): string
138140
};
139141
});
140142

143+
// --- File Extensions ---
144+
145+
$r3->get('/data/*', function (string $resource) {
146+
return ['resource' => $resource, 'items' => ['a', 'b', 'c']];
147+
})->fileExtension([
148+
'.json' => 'json_encode',
149+
'.html' => function (array $data) {
150+
$name = htmlspecialchars($data['resource']);
151+
$items = array_map('htmlspecialchars', $data['items']);
152+
153+
return "<h1>{$name}</h1><ul><li>" . implode('</li><li>', $items) . '</li></ul>';
154+
},
155+
]);
156+
141157
// --- Content Negotiation ---
142158

143159
$r3->get('/json', function () {

src/Routes/AbstractRoute.php

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414
use Respect\Rest\Routines\Routinable;
1515
use Respect\Rest\Routines\Unique;
1616

17+
use function array_map;
18+
use function array_merge;
1719
use function array_pop;
1820
use function array_shift;
1921
use function end;
2022
use function explode;
23+
use function implode;
2124
use function is_a;
2225
use function is_string;
2326
use function ltrim;
@@ -34,6 +37,7 @@
3437
use function strtoupper;
3538
use function substr;
3639
use function ucfirst;
40+
use function usort;
3741

3842
/**
3943
* Base class for all Routes
@@ -45,6 +49,7 @@
4549
* @method self authBasic(mixed ...$args)
4650
* @method self by(mixed ...$args)
4751
* @method self contentType(mixed ...$args)
52+
* @method self fileExtension(mixed ...$args)
4853
* @method self lastModified(mixed ...$args)
4954
* @method self through(mixed ...$args)
5055
* @method self userAgent(mixed ...$args)
@@ -166,16 +171,38 @@ public function match(DispatchContext $context, array &$params = []): bool
166171
$params = [];
167172
$matchUri = $context->path();
168173

174+
$allExtensions = [];
169175
foreach ($this->routines as $routine) {
170-
if (!($routine instanceof IgnorableFileExtension)) {
176+
if (!$routine instanceof IgnorableFileExtension) {
171177
continue;
172178
}
173179

174-
$matchUri = preg_replace(
175-
'#(\.[\w\d\-_.~\+]+)*$#',
176-
'',
177-
$context->path(),
178-
) ?? $context->path();
180+
$allExtensions = array_merge($allExtensions, $routine->getExtensions());
181+
}
182+
183+
if ($allExtensions !== []) {
184+
usort($allExtensions, static fn(string $a, string $b): int => strlen($b) <=> strlen($a));
185+
$escaped = array_map(static fn(string $e): string => preg_quote($e, '#'), $allExtensions);
186+
$extPattern = '#(' . implode('|', $escaped) . ')$#';
187+
188+
$suffix = '';
189+
$stripping = true;
190+
while ($stripping) {
191+
$stripped = preg_replace($extPattern, '', $matchUri, 1, $count);
192+
if ($count > 0 && $stripped !== null && $stripped !== $matchUri) {
193+
$suffix = substr($matchUri, strlen($stripped)) . $suffix;
194+
$matchUri = $stripped;
195+
} else {
196+
$stripping = false;
197+
}
198+
}
199+
200+
if ($suffix !== '') {
201+
$context->request = $context->request->withAttribute(
202+
'respect.ext.remaining',
203+
$suffix,
204+
);
205+
}
179206
}
180207

181208
if (!preg_match($this->regexForMatch, $matchUri, $params)) {

src/Routines/AbstractAccept.php

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,12 @@
77
use Respect\Rest\DispatchContext;
88

99
use function array_keys;
10-
use function array_pop;
1110
use function array_slice;
1211
use function arsort;
1312
use function explode;
1413
use function preg_replace;
1514
use function str_replace;
1615
use function str_starts_with;
17-
use function stripos;
1816
use function strpos;
1917
use function strtolower;
2018
use function substr;
@@ -26,30 +24,13 @@
2624
abstract class AbstractAccept extends AbstractCallbackMediator implements
2725
ProxyableBy,
2826
ProxyableThrough,
29-
Unique,
30-
IgnorableFileExtension
27+
Unique
3128
{
3229
public const string ACCEPT_HEADER = '';
3330

34-
protected string $requestUri = '';
35-
3631
/** @param array<int, mixed> $params */
3732
public function by(DispatchContext $context, array $params): mixed
3833
{
39-
$unsyncedParams = $context->params;
40-
$extensions = $this->filterKeysContain('.');
41-
42-
if (empty($extensions) || empty($unsyncedParams)) {
43-
return null;
44-
}
45-
46-
$unsyncedParams[] = str_replace(
47-
$extensions,
48-
'',
49-
array_pop($unsyncedParams),
50-
);
51-
$context->params = $unsyncedParams;
52-
5334
return null;
5435
}
5536

@@ -66,8 +47,6 @@ public function through(DispatchContext $context, array $params): mixed
6647
*/
6748
protected function identifyRequested(DispatchContext $context, array $params): array
6849
{
69-
$this->requestUri = $context->path();
70-
7150
$headerName = $this->getAcceptHeaderName();
7251
$acceptHeader = $context->request->getHeaderLine($headerName);
7352

@@ -93,7 +72,7 @@ protected function identifyRequested(DispatchContext $context, array $params): a
9372
/** @return array<int, string> */
9473
protected function considerProvisions(string $requested): array
9574
{
96-
return $this->getKeys(); // no need to split see authorize
75+
return $this->getKeys();
9776
}
9877

9978
/** @param array<int, mixed> $params */
@@ -105,10 +84,6 @@ protected function notifyApproved(
10584
): void {
10685
$this->rememberNegotiatedCallback($context, $this->getCallback($provided));
10786

108-
if (strpos($provided, '.') !== false) {
109-
return;
110-
}
111-
11287
$headerType = $this->getNegotiatedHeaderType();
11388

11489
$contentHeader = 'Content-Type';
@@ -138,16 +113,10 @@ protected function notifyDeclined(
138113

139114
protected function authorize(string $requested, string $provided): mixed
140115
{
141-
// negotiate on file extension
142-
if (strpos($provided, '.') !== false) {
143-
return stripos($this->requestUri, $provided) !== false;
144-
}
145-
146116
if ($requested === '*') {
147117
return true;
148118
}
149119

150-
// normal matching requirements
151120
return $requested == $provided;
152121
}
153122

src/Routines/FileExtension.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\Rest\Routines;
6+
7+
use Respect\Rest\DispatchContext;
8+
use SplObjectStorage;
9+
10+
use function str_ends_with;
11+
use function strlen;
12+
use function substr;
13+
use function usort;
14+
15+
final class FileExtension extends CallbackList implements
16+
ProxyableBy,
17+
ProxyableThrough,
18+
IgnorableFileExtension
19+
{
20+
private const string REMAINING_ATTRIBUTE = 'respect.ext.remaining';
21+
22+
/** @var SplObjectStorage<DispatchContext, callable>|null */
23+
private SplObjectStorage|null $negotiated = null;
24+
25+
/** @return array<int, string> */
26+
public function getExtensions(): array
27+
{
28+
return $this->getKeys();
29+
}
30+
31+
/** @param array<int, mixed> $params */
32+
public function by(DispatchContext $context, array $params): mixed
33+
{
34+
$remaining = (string) $context->request->getAttribute(self::REMAINING_ATTRIBUTE, '');
35+
36+
if ($remaining === '') {
37+
return null;
38+
}
39+
40+
$keys = $this->getKeys();
41+
usort($keys, static fn(string $a, string $b): int => strlen($b) <=> strlen($a));
42+
43+
foreach ($keys as $ext) {
44+
if (!str_ends_with($remaining, $ext)) {
45+
continue;
46+
}
47+
48+
$remaining = substr($remaining, 0, -strlen($ext));
49+
$context->request = $context->request->withAttribute(
50+
self::REMAINING_ATTRIBUTE,
51+
$remaining,
52+
);
53+
$this->remember($context, $this->getCallback($ext));
54+
55+
return null;
56+
}
57+
58+
return null;
59+
}
60+
61+
/** @param array<int, mixed> $params */
62+
public function through(DispatchContext $context, array $params): mixed
63+
{
64+
if (!$this->negotiated instanceof SplObjectStorage || !$this->negotiated->offsetExists($context)) {
65+
return null;
66+
}
67+
68+
return $this->negotiated[$context];
69+
}
70+
71+
private function remember(DispatchContext $context, callable $callback): void
72+
{
73+
if (!$this->negotiated instanceof SplObjectStorage) {
74+
/** @var SplObjectStorage<DispatchContext, callable> $storage */
75+
$storage = new SplObjectStorage();
76+
$this->negotiated = $storage;
77+
}
78+
79+
$this->negotiated[$context] = $callback;
80+
}
81+
}

src/Routines/IgnorableFileExtension.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@
66

77
interface IgnorableFileExtension
88
{
9+
/** @return array<int, string> Extensions this routine handles, e.g. ['.json', '.html'] */
10+
public function getExtensions(): array;
911
}

0 commit comments

Comments
 (0)