Skip to content

Commit c42256f

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 c42256f

8 files changed

Lines changed: 264 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: 13 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,17 @@ 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+
return "<h1>{$data['resource']}</h1><ul><li>" . implode('</li><li>', $data['items']) . '</li></ul>';
151+
},
152+
]);
153+
141154
// --- Content Negotiation ---
142155

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

‎src/Routes/AbstractRoute.php‎

Lines changed: 21 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;
@@ -45,6 +48,7 @@
4548
* @method self authBasic(mixed ...$args)
4649
* @method self by(mixed ...$args)
4750
* @method self contentType(mixed ...$args)
51+
* @method self fileExtension(mixed ...$args)
4852
* @method self lastModified(mixed ...$args)
4953
* @method self through(mixed ...$args)
5054
* @method self userAgent(mixed ...$args)
@@ -166,16 +170,27 @@ public function match(DispatchContext $context, array &$params = []): bool
166170
$params = [];
167171
$matchUri = $context->path();
168172

173+
$allExtensions = [];
169174
foreach ($this->routines as $routine) {
170-
if (!($routine instanceof IgnorableFileExtension)) {
175+
if (!$routine instanceof IgnorableFileExtension) {
171176
continue;
172177
}
173178

174-
$matchUri = preg_replace(
175-
'#(\.[\w\d\-_.~\+]+)*$#',
176-
'',
177-
$context->path(),
178-
) ?? $context->path();
179+
$allExtensions = array_merge($allExtensions, $routine->getExtensions());
180+
}
181+
182+
if ($allExtensions !== []) {
183+
$escaped = array_map(preg_quote(...), $allExtensions);
184+
$pattern = '#(' . implode('|', $escaped) . ')+$#';
185+
$matchUri = preg_replace($pattern, '', $context->path()) ?? $context->path();
186+
187+
$suffix = substr($context->path(), strlen($matchUri));
188+
if ($suffix !== '') {
189+
$context->request = $context->request->withAttribute(
190+
'respect.ext.remaining',
191+
$suffix,
192+
);
193+
}
179194
}
180195

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

‎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)