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
145 changes: 111 additions & 34 deletions docs/1-essentials/01-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,26 +440,120 @@ final readonly class ReceiveInteractionController
}
```

### Group middleware
## Route decorators (route groups)

While Tempest does not provide a way to group middleware, you can easily create your own route attribute that applies or excludes a set of middleware to a route.
Route decorators are Tempest's way to manage routes in bulk; it's a feature similar to route groups in other frameworks. Route decorators are attributes that implement the {b`\Tempest\Router\RouteDecorator`} interface. A route decorator's task is to make changes or add functionality to whether route it's associated with. Tempest comes with a few built-in route decorators, and you can make your own as well.

```php Api.php
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
final readonly class Api implements Route
In most cases, you'll want to add route decorators to a controller class, so that they are applied to all actions of that class:

```php
use Tempest\Router\Prefix;
use Tempest\Router\Get;

#[Prefix('/api')]
final readonly class ApiController
{
public function __construct(
public Method $method,
public string $uri,
public array $middleware = [],
public array $without = [],
) {
$this->uri = "/api/{$uri}";
$this->without[] = [
...$without,
VerifyCsrfMiddleware::class,
SetCookieMiddleware::class
];
#[Get('/books')]
public function books(): Response { /* … */ }

#[Get('/authors')]
public function authors(): Response { /* … */ }
}
```

However, route decorators may also be applied to individual controller actions:

```php
use Tempest\Router\Stateless;
use Tempest\Router\Get;

final readonly class BlogPostController
{
#[Stateless]
#[Get('/rss')]
public function rss(): Response { /* … */ }
}
```

### Built-in route decorators

These route decorators are provided by Tempest:

#### `#[Stateless]`

When you're building API endpoints, RSS feeds, or any other kind of page that does not require any cookie or session data, you may use the {b`#[Tempest\Router\Stateless]`} attribute, which will remove all state-related logic:

```php
use Tempest\Router\Stateless;
use Tempest\Router\Get;

final readonly class BlogPostController
{
#[Stateless]
#[Get('/rss')]
public function rss(): Response { /* … */ }
}
```

#### `#[Prefix]`

Adds a prefix to all associated routes.

```php
use Tempest\Router\Prefix;
use Tempest\Router\Get;

#[Prefix('/api')]
final readonly class ApiController
{
#[Get('/books')]
public function books(): Response { /* … */ }

#[Get('/authors')]
public function authors(): Response { /* … */ }
}
```

#### `#[WithMiddleware]`

Adds middleware to all associated routes.

```php
use Tempest\Router\WithMiddleware;
use Tempest\Router\Get;

#[Middleware(AuthMiddleware::class, AdminMiddleware::class)]
final readonly class AdminController { /* … */ }
```

#### `#[WithoutMiddleware]`

Explicitly removes middleware to all associated routes.

```php
use Tempest\Router\WithoutMiddleware;
use Tempest\Router\Get;

#[WithoutMiddleware(VerifyCsrfMiddleware::class, SetCookieMiddleware::class)]
final readonly class StatelessController { /* … */ }
```

### Custom route decorators

Building your own route decorators is done by implementing the {b`\Tempest\Router\RouteDecorator`} interface and marking your decorator as an attribute.

```php
use Attribute;
use Tempest\Router\RouteDecorator;

#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final readonly class Auth implements RouteDecorator
{
public function decorate(Route $route): Route
{
$route->middleare[] = AuthMiddleware::class;

return $route;
}
}
```
Expand Down Expand Up @@ -631,23 +725,6 @@ final class ErrorResponseProcessor implements ResponseProcessor
}
```

## Stateless routes

When you're building API endpoints, RSS pages, or any other kind of page that does not require any cookie or session data, you may use the `{#[Tempest\Router\Stateless]}` attribute, which will remove all state-related logic:

```php
use Tempest\Router\Stateless;
use Tempest\Router\Get;

final readonly class JsonController
{
#[Stateless]
#[Get('/json')]
public function json(string $path): Response
{ /* … */ }
}
```

## Custom route attributes

It is often a requirement to have a bunch of routes following the same specifications—for instance, using the same middleware, or the same URI prefix.
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/Connect.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Tempest\Http\Method;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
final readonly class Connect implements Route
final class Connect implements Route
{
public Method $method;

Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/Delete.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Tempest\Http\Method;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
final readonly class Delete implements Route
final class Delete implements Route
{
public Method $method;

Expand Down
17 changes: 0 additions & 17 deletions packages/router/src/GenericRouter.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
use Tempest\Http\Request;
use Tempest\Http\Response;
use Tempest\Http\Responses\Ok;
use Tempest\Http\Session\VerifyCsrfMiddleware;
use Tempest\Router\Exceptions\ControllerActionHadNoReturn;
use Tempest\Router\Exceptions\MatchedRouteCouldNotBeResolved;
use Tempest\Router\Routing\Matching\RouteMatcher;
Expand Down Expand Up @@ -83,22 +82,6 @@ private function getCallable(): HttpMiddlewareCallable
)) {
return $callable($request);
}

// Skip middleware that sets cookies or session values when the route is stateless
if (
$matchedRoute->route->handler->hasAttribute(Stateless::class)
&& in_array(
needle: $middlewareClass->getName(),
haystack: [
VerifyCsrfMiddleware::class,
SetCurrentUrlMiddleware::class,
SetCookieMiddleware::class,
],
strict: true,
)
) {
return $callable($request);
}
}

/** @var HttpMiddleware $middleware */
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/Get.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Tempest\Http\Method;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
final readonly class Get implements Route
final class Get implements Route
{
public Method $method;

Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/Head.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Tempest\Http\Method;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
final readonly class Head implements Route
final class Head implements Route
{
public Method $method;

Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/MatchedRoute.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use Tempest\Router\Routing\Construction\DiscoveredRoute;

final readonly class MatchedRoute
final class MatchedRoute
{
public function __construct(
public DiscoveredRoute $route,
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Tempest\Http\Method;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
final readonly class Options implements Route
final class Options implements Route
{
public Method $method;

Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/Patch.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Tempest\Http\Method;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
final readonly class Patch implements Route
final class Patch implements Route
{
public Method $method;

Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/Post.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Tempest\Http\Method;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
final readonly class Post implements Route
final class Post implements Route
{
public Method $method;

Expand Down
25 changes: 25 additions & 0 deletions packages/router/src/Prefix.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Tempest\Router;

use Attribute;

use function Tempest\Support\path;

/**
* Add a prefix to the route's URI
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final readonly class Prefix implements RouteDecorator
{
public function __construct(
private string $prefix,
) {}

public function decorate(Route $route): Route
{
$route->uri = path($this->prefix, $route->uri)->toString();

return $route;
}
}
2 changes: 1 addition & 1 deletion packages/router/src/Put.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Tempest\Http\Method;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
final readonly class Put implements Route
final class Put implements Route
{
public Method $method;

Expand Down
4 changes: 4 additions & 0 deletions packages/router/src/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,23 @@ interface Route
{
public Method $method {
get;
set;
}

public string $uri {
get;
set;
}

/** @var class-string<HttpMiddleware>[] */
public array $middleware {
get;
set;
}

/** @var class-string<HttpMiddleware>[] */
public array $without {
get;
set;
}
}
8 changes: 8 additions & 0 deletions packages/router/src/RouteDecorator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Tempest\Router;

interface RouteDecorator
{
public function decorate(Route $route): Route;
}
12 changes: 9 additions & 3 deletions packages/router/src/RouteDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,21 @@ public function discover(DiscoveryLocation $location, ClassReflector $class): vo
$routeAttributes = $method->getAttributes(Route::class);

foreach ($routeAttributes as $routeAttribute) {
$this->discoveryItems->add($location, [$method, $routeAttribute]);
$decorators = [
...$method->getDeclaringClass()->getAttributes(RouteDecorator::class),
...$method->getAttributes(RouteDecorator::class),
];

$route = DiscoveredRoute::fromRoute($routeAttribute, $decorators, $method);

$this->discoveryItems->add($location, $route);
}
}
}

public function apply(): void
{
foreach ($this->discoveryItems as [$method, $routeAttribute]) {
$route = DiscoveredRoute::fromRoute($routeAttribute, $method);
foreach ($this->discoveryItems as $route) {
$this->configurator->addRoute($route);
}

Expand Down
9 changes: 7 additions & 2 deletions packages/router/src/Routing/Construction/DiscoveredRoute.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,21 @@
use Tempest\Reflection\MethodReflector;
use Tempest\Router\Route;

final readonly class DiscoveredRoute implements Route
final class DiscoveredRoute implements Route
{
public const string DEFAULT_MATCHING_GROUP = '[^/]++';

public const string ROUTE_PARAM_NAME_REGEX = '(\w*)';

public const string ROUTE_PARAM_CUSTOM_REGEX = '(?::([^{}]*(?:\{(?-1)\}[^{}]*)*))?';

public static function fromRoute(Route $route, MethodReflector $methodReflector): self
/** @param \Tempest\Router\RouteDecorator[] $decorators */
public static function fromRoute(Route $route, array $decorators, MethodReflector $methodReflector): self
{
foreach ($decorators as $decorator) {
$route = $decorator->decorate($route);
}

return new self(
$route->uri,
$route->method,
Expand Down
Loading