no-framework-tutorial/14-middleware.md

11 KiB

<< previous | next >>

Middleware

In the last chapter we wrote our RouterClass to implement the middleware interface, and in this chapter I want to explain a bit more about what this interface does and why it is used in many applications.

The Middlewares are basically a number of wrappers that stand between the client and your application. Each request gets passed through all the middlewares, gets handled by our controllers and then the response gets passed back through all the middlewars to the client/emitter. You can check out this Blogpost for a more in depth explanation of the middleware pattern.

So every Middleware can modify the request before it goes on to the next middleware (and finally the handler) and the response after it gets created by our handlers.

So lets take a look at the middleware and the requesthandler interfaces

interface MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface;
}

interface RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface;
}

The RequestHandlerInterface gets only a request and returns a response, the MiddlewareInterface gets a request and a requesthandler and returns a response. So the logical thing for the Middleware is to use the handler to produce the response.

But the middleware could just ignore the handler and produce a response on its own as the interface just requires us to produce a response.

A simple example for that would be a caching middleware. The basic idea is that we want to cache all request from users that are not logged in. This way we can save a lot of processing power in rendering the html and fetching data from the database.

In this scenario we assume that we have an authentication middleware that checks if a user is logged in and decorates the request with an 'isAuthenticated' attribute.

If the 'isAuthenticated' attribute is set to false, we check if we have a cached response and return that, if that response is not already cached, than we let the handler create the response and store that in the cache for a few seconds

interface CacheInterface
{
    public function get(string $key, callable $resolver, int $ttl): mixed;
}

The first parameter is the identifier for the cache, the second is a callable that produces the value and the last one defines the seconds that the cache should keep the item. If the cache doesnt have an item with the given key then it uses the callable to produce the value and stores it for the time specified in ttl.

so lets write our caching middleware:

final class CachingMiddleware implements MiddlewareInterface
{
    public function __construct(private CacheInterface $cache){}
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        if ($request->getAttribute('isAuthenticated', false) && $request->getMethod() === 'GET') {
            $key = $request->getUri()->getPath();
            return $this->cache->get($key, fn() => $handler->handle($request), 10);
        }
        return $handler->handle($request);
    }
}

we can also modify the response after it has been created by our application, for example we could implement a gzip middleware, or for more simple and silly example a middleware that adds a Dank Meme header to all our response so that the browser know that our application is used to serve dank memes:

final class DankMemeMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);
        return $response->withAddedHeader('Meme', 'Dank');
    }
}

but for our application we are going to just add two external middlewares:

composer require middlewares/trailing-slash
composer require middlewares/whoops

The whoops middleware should be the first middleware to be executed so that we catch any errors that are thrown in the application as well as the middleware stack.

Our desired request -> response flow looks something like this:

        Client
        |     ^
        v     |
         Kernel
        |     ^
        v     |
     Whoops Middleware
        |     ^
        v     |
      TrailingSlash
        |     ^
        v     |
        Routing
        |     ^
        v     |
     ContainerResolver
        |     ^
        v     |
     Controller/Action

As every middleware expects a RequestHandlerInterface as its second argument we need some extra code that wraps every middleware as a RequestHandler and chains them together with the ContainerRouteDecoratedResolver as the last Handler.

interface Pipeline
{
    public function dispatch(ServerRequestInterface $request): ResponseInterface;
}

And our implementation looks something like this:

<?php declare(strict_types=1);

namespace Lubian\NoFramework\Http;

use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

use function array_reverse;
use function assert;
use function is_string;

class ContainerPipeline implements Pipeline
{
    /**
     * @param array<MiddlewareInterface|class-string> $middlewares
     */
    public function __construct(
        private array $middlewares,
        private RequestHandlerInterface $tip,
        private ContainerInterface $container,
    ) {
    }

    public function dispatch(ServerRequestInterface $request): ResponseInterface
    {
        $this->buildStack();
        return $this->tip->handle($request);
    }

    private function buildStack(): void
    {
        foreach (array_reverse($this->middlewares) as $middleware) {
            $next = $this->tip;
            if ($middleware instanceof MiddlewareInterface) {
                $this->tip = $this->wrapMiddleware($middleware, $next);
            }
            if (is_string($middleware)) {
                $this->tip = $this->wrapResolvedMiddleware($middleware, $next);
            }
        }
    }

    private function wrapResolvedMiddleware(string $middleware, RequestHandlerInterface $next): RequestHandlerInterface
    {
        return new class ($middleware, $next, $this->container) implements RequestHandlerInterface {
            public function __construct(
                private readonly string $middleware,
                private readonly RequestHandlerInterface $handler,
                private readonly ContainerInterface $container,
            ) {
            }

            public function handle(ServerRequestInterface $request): ResponseInterface
            {
                $middleware = $this->container->get($this->middleware);
                assert($middleware instanceof MiddlewareInterface);
                return $middleware->process($request, $this->handler);
            }
        };
    }

    private function wrapMiddleware(MiddlewareInterface $middleware, RequestHandlerInterface $next): RequestHandlerInterface
    {
        return new class ($middleware, $next) implements RequestHandlerInterface {
            public function __construct(
                private readonly MiddlewareInterface $middleware,
                private readonly RequestHandlerInterface $handler,
            ) {
            }

            public function handle(ServerRequestInterface $request): ResponseInterface
            {
                return $this->middleware->process($request, $this->handler);
            }
        };
    }
}

Here we define our constructor to require two arguments: an array of middlewares and a requesthandler as the final code that should produce our response.

In the buildStack() method we wrap every middleware as a RequestHandler with the current tip property as the $next argument and store that itself as the current tip.

There are of course a lot of more sophisticated ways to build a pipeline/dispatcher that you can check out at the middlewares github

Lets add a simple factory to our dependencies.php file that creates our middlewarepipeline Lets create a simple Factory that loads an Array of Middlewares from the Config folder and uses that to build our pipeline

<?php declare(strict_types=1);

namespace Lubian\NoFramework\Factory;

use Lubian\NoFramework\Http\ContainerPipeline;
use Lubian\NoFramework\Http\Pipeline;
use Lubian\NoFramework\Http\RoutedRequestHandler;
use Lubian\NoFramework\Settings;
use Psr\Container\ContainerInterface;

class PipelineProvider
{
    public function __construct(
        private Settings $settings,
        private RoutedRequestHandler $tip,
        private ContainerInterface $container,
    ) {
    }

    public function getPipeline(): Pipeline
    {
        $middlewares = require $this->settings->middlewaresFile;
        return new ContainerPipeline($middlewares, $this->tip, $this->container);
    }
}

And configure the container to use the Factory to create the Pipeline:

    ...,
    Pipeline::class => fn (PipelineProvider $p) => $p->getPipeline(),
    ...

And of course a new file called middlewares.php in our config folder:

<?php declare(strict_types=1);

use Lubian\NoFramework\Http\RouteMiddleware;
use Middlewares\TrailingSlash;
use Middlewares\Whoops;

return [
    Whoops::class,
    TrailingSlash::class,
    RouteMiddleware::class,
];

And we need to add the pipeline to our Kernel class. I will leave that as an exercise to you, a simple hint that i can give you is that the handle()-method of the Kernel should look like this:

public function handle(ServerRequestInterface $request): ResponseInterface
{
    return $this->pipeline->dispatch($request);
}

Lets try if you can make the kernel work with our created Pipeline implementation. For the future we could improve our pipeline a little bit, so that it can accept a class-string of a middleware and resolves that with the help of a dependency container, if you want you can do that as well.

A quick note about docblocks: You might have noticed, that I rarely add docblocks to my the code in the examples, and when I do it seems kind of random. My philosophy is that I only add docblocks when there is no way to automatically get the exact type from the code itself. For me docblocks only serve two purposes: help my IDE to understand what it choices it has for code completion and to help the static analysis to better understand the code. There is a great blogpost about the cost and value of DocBlocks, although it is written in 2018 at a time before PHP 7.4 was around everything written there is still valid today.

<< previous | next >>