Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<h1>{$data['name']}</h1>"; },
]);
```

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
Expand Down
16 changes: 16 additions & 0 deletions example/full.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 "<h1>{$name}</h1><ul><li>" . implode('</li><li>', $items) . '</li></ul>';
},
]);

// --- Content Negotiation ---

$r3->get('/json', function () {
Expand Down
39 changes: 33 additions & 6 deletions src/Routes/AbstractRoute.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +37,7 @@
use function strtoupper;
use function substr;
use function ucfirst;
use function usort;

/**
* Base class for all Routes
Expand All @@ -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)
Expand Down Expand Up @@ -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)) {
Expand Down
35 changes: 2 additions & 33 deletions src/Routines/AbstractAccept.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<int, mixed> $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;
}

Expand All @@ -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);

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

/** @param array<int, mixed> $params */
Expand All @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
81 changes: 81 additions & 0 deletions src/Routines/FileExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Respect\Rest\Routines;

use Respect\Rest\DispatchContext;
use SplObjectStorage;

use function str_ends_with;
use function strlen;
use function substr;
use function usort;

final class FileExtension extends CallbackList implements
ProxyableBy,
ProxyableThrough,
IgnorableFileExtension
{
private const string REMAINING_ATTRIBUTE = 'respect.ext.remaining';

/** @var SplObjectStorage<DispatchContext, callable>|null */
private SplObjectStorage|null $negotiated = null;

/** @return array<int, string> */
public function getExtensions(): array
{
return $this->getKeys();
}

/** @param array<int, mixed> $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<int, mixed> $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<DispatchContext, callable> $storage */
$storage = new SplObjectStorage();
$this->negotiated = $storage;
}

$this->negotiated[$context] = $callback;
}
}
2 changes: 2 additions & 0 deletions src/Routines/IgnorableFileExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@

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