Allow for decoupling of middleware from the routes by declaring routes on middleware directly.
Description
I had an idea I've been thinking about for a few weeks. With the decorator announcement I figured I'd at least throw it out there to see if it is something worth considering.
Tempest's mantra is "The framework that gets out of your way". It is done by reinventing how we interact with frameworks, using features like discovery. Middleware feels like it fails in that aspect. It is tightly coupled with routes just like it always has been. This is partially fixed with route decorators that were just announced. By removing responsibilities from the route attributes, but I think we could take middleware a step further.
So my thought is: Why not break the pattern of routes and route decorators defining middleware, and let middleware define the routes it should apply to. The route still goes to the controller like normal, it just picks up any middleware that has a matching pattern along the way.
The Actual Suggestion: Allow for route attributes that are used in controllers, for usage in the middleware class, or make new attributes if needed. Then use discovery to collect the middleware when determining the route. This doesn't replace the existing solutions, just adds another way to decouple code for codebases with messy and very deep route branches.
I'd love to hear any thoughts on this. Thanks for your time!
Benefits
The main benefit is decoupling systems and removing responsibilities from routes. It also adds some flexibility for systems with more complex and very nested routing, without disrupting the current method(s) of declaring middleware in routes.
Could you write some dummy code to illustrate what this would look like?
Sorry about that, I was in a rush and did not do a great job of explaining. Using the example in the docs.
The typical way to add middleware to a route is via the route attribute in the controller.
use Tempest\Router\Get;
use Tempest\Http\Response;
final readonly class ReceiveInteractionController
{
#[Post('/slack/interaction', middleware: [ValidateWebhook::class])]
public function __invoke(): Response
{
// …
}
}
This can get clunky when having very wide and deeply nested routes. So instead of having the middleware declaration in every single controller and route attribute and decorator that you need it. My suggestion is to define the routes that the middleware should apply to, in the middleware.
use Tempest\Router\HttpMiddleware;
use Tempest\Router\HttpMiddlewareCallable;
use Tempest\Http\Request;
use Tempest\Http\Response;
use Tempest\Discovery\SkipDiscovery;
use Tempest\Core\Priority;
#[Post('/slack/interaction')]
#[Post('/all/these/routes/.+')]
#[Put('/all/these/routes/.+')]
#[Delete('/all/these/routes/.+')]
#[Route('/api/somefeature/.+')] // if it doesn't exist already, a catchall for any request type
#[Priority(Priority::LOW)]
final readonly class ValidateWebhook implements HttpMiddleware
{
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
{
$signature = $request->headers->get('X-Slack-Signature');
$timestamp = $request->headers->get('X-Slack-Request-Timestamp');
// …
return $next($request);
}
}
The drawback of course is that you can potentially have a long list of routes in the middleware. It's main benefit isn't apparent if you only need to update a few controllers to add some new middleware. It becomes immediately useful when you need middleware to be applied to whole branches of deeply nested routes. Instead of adding that middleware or decorator to a bunch of classes, or creating an inheritance nightmare with a chain of inherited route classes for each level, you can simply give the base route pattern to the middleware and it will apply to all. The WithoutMiddleware can then be used for those one off routes or controllers that don't need that specific middleware but fall within that branch.
In the real life use case I was thinking about, the routes have over 20 branches off the second level /customer_id/franchise_location_id/20_possible_sub_routes_from_here/.... The deepest branch within those I think is 8 levels (just your average enterprise legacy system lol). If I wanted to apply new middleware to 5 of those 20 route paths. Then I have to traverse each pathway to add that new middleware to the controllers or to each decorator or route class. Compared to adding those 5 regex route paths directly in the middleware class.
Hope that clears it up.
The other idea which should solve the deeply nested and wide routes issue is to allow decorators (or a new class RouteGroupDecorator that behaves like decorators) to have route attributes and auto applied to any route it matches, like in the middleware example above, which would also solve the issue.
use Attribute;
use Tempest\Router\RouteGroupDecorator;
#[Post('/all/these/routes/.+')]
#[Put('/all/these/routes/.+')]
#[Delete('/all/these/routes/.+')]
#[Route('/api/somefeature/.+')] // if it doesn't exist already, a catchall for any request type
final readonly class Auth implements RouteGroupDecorator
{
public function decorate(Route $route): Route
{
$route->middleare[] = AuthMiddleware::class;
return $route;
}
}
Though this may need to get moved to a new feature request since it is a separate idea from the middleware idea.
Interesting, thanks for clarifying! The problem here is that it doesn't really scale when you need to apply multiple middleware to multiple routes, then you'd need to repeat routes or route patterns over and over again.
That being said, I acknowledge that it might be useful to be able to attach behaviour "in bulk" to routes matching a pattern. But I don't think doing it on the middleware level is the way to go. Maybe some kind of route decorator that's being discovered could work?
interface GlobalRouteDecorator extends RouteDecorator
{
public function match(Route $route): bool;
}
class AdminRouteDecorator implements GobalRouteDecorator
{
public function match(Route $route): bool
{
return str_starts_with($route->uri, '/admin');
}
public function decorate(Route $route): Route
{
$route->middleware[] = AuthMiddleware::class;
$route->middleware[] = AdminMiddleware::class;
}
}
That would work. The match method gives more flexibility than just attributes with routes like in my route group decorator example. The downside of the method is that at a glance it seems to be more difficult to cache since it is a method result and not hard coded strings within attributes on the decorator.
For my needs though, I'd be good with either attributes on decorators or a match method on decorators.
To make sure I understand your example, it is just a new interface for decorators?
use Attribute;
use Tempest\Router\GlobalRouteDecorator;
final readonly class Auth implements GlobalRouteDecorator
{
public function decorate(Route $route): Route
{
$route->middleare[] = AuthMiddleware::class;
return $route;
}
public function match(Route $route): bool
{
// Just dummy examples, not real thankfully
return
(str_starts_with($route->uri, '/some/secure/deeply/nested/route') && in_array($route->Method, [ Method::OPTIONS, Method::DELETE, Method::POST ]))
|| (preg_match('/^\/api\/auth\/.*$/', $route->uri) !== false)
|| str_starts_with($route->uri, '/admin')
|| (str_starts_with($route->uri, '/trace') && $route->Method === Method::TRACE);
}
}
Would it simplify anything on your side if the match method was an abstract method within a trait to be used on decorators? Instead of a new interface entirely?
trait SomeTraitNameForDeterminingGroupsOfRoutesTheDecoratorShouldApplyTo
{
abstract public function match(Route $route): bool;
}
Thanks for being so open with this!
To make sure I understand your example, it is just a new interface for decorators?
Yes, to optionally build on top of existing decorator functionality.
Would it simplify anything on your side if the match method was an abstract method within a trait to be used on decorators? Instead of a new interface entirely?
No not really, we always provide interfaces with Tempest, going with abstract trait methods would go against Tempest's codestyle