Skip to content

Commit 9d0fc5f

Browse files
authored
feat(router)!: support signed URIs (#1520)
1 parent aca1f9a commit 9d0fc5f

23 files changed

+613
-301
lines changed

docs/1-essentials/01-routing.md

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,10 @@ public function docsRedirect(string $path): Redirect
162162

163163
## Generating URIs
164164

165-
Tempest provides a `\Tempest\uri` function that can be used to generate an URI to a controller method. This function accepts the FQCN of the controller or a callable to a method as its first argument, and named parameters as [the rest of its arguments](https://www.php.net/manual/en/functions.arguments.php#functions.variable-arg-list).
165+
Tempest provides a `\Tempest\uri` function that can be used to generate a URI to a controller method. This function accepts the FQCN of the controller or a callable to a method as its first argument, and named parameters as [the rest of its arguments](https://www.php.net/manual/en/functions.arguments.php#functions.variable-arg-list).
166166

167167
```php
168-
use function Tempest\uri;
168+
use function Tempest\Router\uri;
169169

170170
// Invokable classes can be referenced directly:
171171
uri(HomeController::class);
@@ -181,15 +181,62 @@ uri([AircraftController::class, 'show'], id: $aircraft->id);
181181
```
182182

183183
:::info
184-
Note that Tempest does not have named routes, and currently doesn't plan on adding them. However, if you have an argument for them, feel free to hop on our [Discord server](/discord){:ssg-ignore="true"} to discuss it.
184+
URI-related methods are also available by injecting the {b`Tempest\Router\UriGenerator`} class into your controller.
185185
:::
186186

187-
## Matching the current URI
187+
### Signed URIs
188188

189-
To determine whether the current request matches a specific controller action, Tempest provides the `\Tempest\is_current_uri` function. This function accepts the same arguments as `uri`, and returns a boolean.
189+
A signed URI may be used to ensure that the URI was not modified after it was created. This is useful for implementing login links, or other endpoints that need protection against tampering.
190+
191+
To create a signed URI, you may use the `signed_uri` function. This function accepts the same arguments as `uri`, and returns the URI with a `signature` parameter:
192+
193+
```php
194+
use function Tempest\Router\signed_uri;
195+
196+
signed_uri(
197+
action: [MailingListController::class, 'unsubscribe'],
198+
email: $email
199+
);
200+
```
201+
202+
Alternatively, you may use `temporary_signed_uri` to provide a duration after which the signed URI will expire, providing an extra layer of security.
203+
204+
```php
205+
use function Tempest\Router\temporary_signed_uri;
206+
207+
temporary_signed_uri(
208+
action: PasswordlessAuthenticationController::class,
209+
duration: Duration::minutes(10),
210+
userId: $userId
211+
);
212+
```
213+
214+
To ensure the validity of a signed URL, you should call the `hasValidSignature` method on the {`Tempest\Router\UriGenerator`} class.
215+
216+
```php
217+
final class PasswordlessAuthenticationController
218+
{
219+
public function __construct(
220+
private readonly UriGenerator $uri,
221+
) {}
222+
223+
public function __invoke(Request $request): Response
224+
{
225+
if (! $this->uri->hasValidSignature($request)) {
226+
return new Invalid();
227+
}
228+
229+
// ...
230+
}
231+
}
232+
```
233+
234+
### Matching the current URI
235+
236+
To determine whether the current request matches a specific controller action, Tempest provides the `is_current_uri` function. This function accepts the same arguments as `uri`, and returns a boolean.
190237

191238
```php
192-
use function Tempest\is_current_uri;
239+
use function Tempest\Router\is_current_uri;
193240

194241
// Current URI is: /aircraft/1
195242

@@ -243,7 +290,7 @@ Once you have created a request class, you may simply inject it into a controlle
243290
use Tempest\Router\Post;
244291
use Tempest\Http\Responses\Redirect;
245292
use function Tempest\map;
246-
use function Tempest\uri;
293+
use function Tempest\Router\uri;
247294

248295
final readonly class AirportController
249296
{

docs/4-internals/03-view-spec.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ Tempest will merge all imports at the top of the compiled view, meaning that eac
103103
```html
104104
<?php
105105
use App\PostController;
106-
use function Tempest\uri;
106+
use function Tempest\Router\uri;
107107
?>
108108

109109
{{ uri([PostController::class, 'show'], post: $post->id) }}
@@ -608,7 +608,7 @@ Referencing a symbol within a view will automatically import it at the top of th
608608
```html
609609
<?php
610610
use App\PostController;
611-
use function Tempest\uri;
611+
use function Tempest\Router\uri;
612612
?>
613613

614614
{{ uri([PostController::class, 'show'], post: $post->id) }}

packages/router/src/GenericRouter.php

Lines changed: 0 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
namespace Tempest\Router;
66

7-
use BackedEnum;
87
use Psr\Http\Message\ServerRequestInterface as PsrRequest;
98
use ReflectionClass;
109
use Tempest\Container\Container;
@@ -14,17 +13,12 @@
1413
use Tempest\Http\Request;
1514
use Tempest\Http\Response;
1615
use Tempest\Http\Responses\Ok;
17-
use Tempest\Reflection\ClassReflector;
1816
use Tempest\Router\Exceptions\ControllerActionHadNoReturn;
19-
use Tempest\Router\Exceptions\ControllerMethodHadNoRouteAttribute;
2017
use Tempest\Router\Exceptions\MatchedRouteCouldNotBeResolved;
21-
use Tempest\Router\Routing\Construction\DiscoveredRoute;
2218
use Tempest\Router\Routing\Matching\RouteMatcher;
2319
use Tempest\View\View;
2420

2521
use function Tempest\map;
26-
use function Tempest\Support\Regex\replace;
27-
use function Tempest\Support\str;
2822

2923
final readonly class GenericRouter implements Router
3024
{
@@ -98,79 +92,6 @@ private function getCallable(): HttpMiddlewareCallable
9892
return $callable;
9993
}
10094

101-
public function toUri(array|string $action, ...$params): string
102-
{
103-
if (is_string($action) && str_starts_with($action, '/')) {
104-
$uri = $action;
105-
} else {
106-
[$controllerClass, $controllerMethod] = is_array($action) ? $action : [$action, '__invoke'];
107-
108-
$routeAttribute = new ClassReflector($controllerClass)
109-
->getMethod($controllerMethod)
110-
->getAttribute(Route::class);
111-
112-
if ($routeAttribute === null) {
113-
throw new ControllerMethodHadNoRouteAttribute($controllerClass, $controllerMethod);
114-
}
115-
116-
$uri = $routeAttribute->uri;
117-
}
118-
119-
$uri = str($uri);
120-
$queryParams = [];
121-
122-
foreach ($params as $key => $value) {
123-
if (! $uri->matches(sprintf('/\{%s(\}|:)/', $key))) {
124-
$queryParams[$key] = $value;
125-
126-
continue;
127-
}
128-
129-
if ($value instanceof BackedEnum) {
130-
$value = $value->value;
131-
} elseif ($value instanceof Bindable) {
132-
foreach (new ClassReflector($value)->getPublicProperties() as $property) {
133-
if (! $property->hasAttribute(IsBindingValue::class)) {
134-
continue;
135-
}
136-
137-
$value = $property->getValue($value);
138-
break;
139-
}
140-
}
141-
142-
$uri = $uri->replaceRegex(
143-
'#\{' . $key . DiscoveredRoute::ROUTE_PARAM_CUSTOM_REGEX . '\}#',
144-
(string) $value,
145-
);
146-
}
147-
148-
$uri = $uri->prepend(rtrim($this->appConfig->baseUri, '/'));
149-
150-
if ($queryParams !== []) {
151-
return $uri->append('?' . http_build_query($queryParams))->toString();
152-
}
153-
154-
return $uri->toString();
155-
}
156-
157-
public function isCurrentUri(array|string $action, ...$params): bool
158-
{
159-
$matchedRoute = $this->container->get(MatchedRoute::class);
160-
$candidateUri = $this->toUri($action, ...[...$matchedRoute->params, ...$params]);
161-
$currentUri = $this->toUri([$matchedRoute->route->handler->getDeclaringClass(), $matchedRoute->route->handler->getName()]);
162-
163-
foreach ($matchedRoute->params as $key => $value) {
164-
if ($value instanceof BackedEnum) {
165-
$value = $value->value;
166-
}
167-
168-
$currentUri = replace($currentUri, '/({' . preg_quote($key, '/') . '(?::.*?)?})/', $value);
169-
}
170-
171-
return $currentUri === $candidateUri;
172-
}
173-
17495
private function createResponse(string|array|Response|View $input): Response
17596
{
17697
if ($input instanceof View || is_array($input) || is_string($input)) {

packages/router/src/Router.php

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,12 @@
55
namespace Tempest\Router;
66

77
use Psr\Http\Message\ServerRequestInterface as PsrRequest;
8+
use Tempest\DateTime\DateTime;
9+
use Tempest\DateTime\Duration;
810
use Tempest\Http\Request;
911
use Tempest\Http\Response;
1012

1113
interface Router
1214
{
1315
public function dispatch(Request|PsrRequest $request): Response;
14-
15-
/**
16-
* Creates a valid URI to the given `$action`.
17-
*
18-
* `$action` is one of :
19-
* - Controller FQCN and its method as a tuple
20-
* - Invokable controller FQCN
21-
* - URI string starting with `/`
22-
*
23-
* @param array{class-string, string}|class-string|string $action
24-
*/
25-
public function toUri(array|string $action, ...$params): string;
26-
27-
/**
28-
* Checks if the URI to the given `$action` would match the current route.
29-
*
30-
* `$action` is one of :
31-
* - Controller FQCN and its method as a tuple
32-
* - Invokable controller FQCN
33-
* - URI string starting with `/`
34-
*
35-
* @param array{class-string, string}|class-string|string $action
36-
*/
37-
public function isCurrentUri(array|string $action, ...$params): bool;
3816
}

packages/router/src/Static/StaticGenerateCommand.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@
3434
use Tempest\Vite\Exceptions\ManifestWasNotFound;
3535
use Throwable;
3636

37+
use function Tempest\Router\uri;
3738
use function Tempest\Support\path;
3839
use function Tempest\Support\str;
39-
use function Tempest\uri;
4040

4141
final class StaticGenerateCommand
4242
{

0 commit comments

Comments
 (0)