Skip to content

PSR‐7 & PSR‐15 Bridge

Shesh Ghimire edited this page Dec 24, 2024 · 14 revisions

Intent

Pipeline intends to assist you with PSR-15 (or Similar - removed in v2) implementation of Request Handler and Middleware as a Queue-based Request Handler. This approach is expected to work on the following scenario:

  • The Request Handler's implementing concrete class has a Handler::add($middleware) method (or similar method name) to collect middleware and stack it.
  • Inside the Handler::handle() method, the Pipeline Bridge is used to transform the stacked middleware into pipes.
  • The Pipeline API can then be initialized. Keep in mind to pass the $request to pipeline's Pipeline::use() method. The subject to send to the pipeline will be an initial response instance.
  • When a client invokes the handle method, middleware added using the Handler::add($middleware) method will process in order (First Come First Process basis) they were stacked/queued.
  • The handler can directly return the response to the client or capture the response for further manipulation.

How to implement?

The pipeline bridge converts middlewares coming to the Request Handler into pipes.

Main Request Handler

v1

use Closure;
use Throwable;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\MiddlewareInterface;
use TheWebSolver\Codegarage\Lib\Pipeline;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ServerRequestInterface;
use TheWebSolver\Codegarage\Lib\PipeInterface;
use TheWebSolver\Codegarage\Lib\PipelineBridge;

// Application concrete classes.
use Response;         // Implements Psr\Http\Message\ResponseInterface
use NotFoundResponse; // Implements Psr\Http\Message\ResponseInterface

class MainHandler implements RequestHandlerInterface {
	/** @var PipeInterface[] */
	private array $pipes;

	/**
	 * Fallback can be anything. Possible actions are:
	 * - A lambda function added to the current route.
	 * - A controller method converted to first-class callable. Eg: `$controller->show(...)`.
	 *
	 * @param Closure(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface $fallback
	 */
	public function __construct(private readonly Closure $fallback) {}

	public function add(string|Closure|MiddlewareInterface $middleware): void {
		$this->pipes[] = PipelineBridge::middlewareToPipe($middleware);
	}

	public function handle(ServerRequestInterface $request): ResponseInterface {
		$response = (new Pipeline())
			->use($request)
			->send(new Response())
			->through($this->pipes)
			->sealWith(static fn(Throwable $e): ResponseInterface => (new NotFoundResponse()))
			->thenReturn();

		return $response instanceof NotFoundResponse ? $response : ($this->fallback)($request, $response);
	}
}

v2

use Closure;
use Throwable;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ServerRequestInterface;
use TheWebSolver\Codegarage\Pipeline\Bridge;
use TheWebSolver\Codegarage\Pipeline\Pipeline;
use TheWebSolver\Codegarage\Pipeline\Middleware;
use TheWebSolver\Codegarage\Pipeline\Interfaces\PipeInterface;

// Application concrete classes.
use Response;         // Implements Psr\Http\Message\ResponseInterface
use NotFoundResponse; // Implements Psr\Http\Message\ResponseInterface

class MainHandler implements RequestHandlerInterface {
	/** @var PipeInterface[] */
	private array $pipes;

	/**
	 * Fallback can be anything. Possible actions are:
	 * - A lambda function added to the current route.
	 * - A controller method converted to first-class callable. Eg: `$controller->show(...)`.
	 *
	 * @param Closure(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface $fallback
	 */
	public function __construct(private readonly Closure $fallback) {}

	public function add(string|Closure|MiddlewareInterface $middleware): void {
		$this->pipes[] = Middleware::toPipe($middleware);
	}

	public function handle(ServerRequestInterface $request): ResponseInterface {
        // Recommended.
        try {
            $response = (new Bridge())->for($request, new Response())->through(...$this->pipes)->get();
        } catch ( Throwable $e ) {
            $response = new NotFoundResponse();
        }

         // Not recommended. Alternatively, using pipeline directly.
		$response = (new Pipeline())
			->use($request)
			->send(new Response())
			->through($this->pipes)
			->sealWith(static fn(Throwable $e): ResponseInterface => (new NotFoundResponse()))
			->thenReturn();

		return $response instanceof NotFoundResponse ? $response : ($this->fallback)($request, $response);
	}
}

Application Bootstrap

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ServerRequestInterface;

// Application concrete classes.
use AuthMiddleware;       // Implements Psr\Http\Server\MiddlewareInterface
use RouteMiddleware;      // Implements Psr\Http\Server\MiddlewareInterface
use ServerRequestFactory; // May implement PSR-17 Psr\Http\Message\ServerRequestFactoryInterface

// Fallback Action that accepts pipeline response as the second argument. This implementation
// is just a demonstration and you may bootstrap it in your application-specific way.
$fallback = static fn(
	ServerRequestInterface $request,
	ResponseInterface $response
): ResponseInterface => $response->withStatus(code: 200);

// The main handler that uses Pipeline Bridge to handle stacked middlewares.
$handler = new MainHandler($fallback);

// Add middlewares as required by your application. Can accept either a Middleware instance.
$handler->add(new AuthMiddleware());
// Or a middleware classname.
$handler->add(RouteMiddleware::class);
// Or a Closure that has the same signature to be compatible with the "MiddlewareInterface::process()" method.
$handler->add(
	static fn(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
		=> $handler->handle($request)
);

// Get the final response.
$response = $handler->handle(ServerRequestFactory::fromGlobals());

How is it implemented?

The pipeline bridge follows Queue Based Request Handler design principle. This approach expects the main handler to stack all middleware, pipe it through the pipeline, and get the final response. This behavior can be achieved with a setter method like: Handler::add() or Handler::addMiddleware(). This makes the main handler:

  • agnostic of the PSR-7 implementation currently being used by the application.
  • provide separation of concern between all stacked middlewares as they do not know each other's composition.
  • process and get responses in order middlewares were stacked.
  • provide better Developer Experience (DX) as well as loose coupling. The developer implements how fallback should work, how exceptions are handled, and what the final response should be.
  • friction-less. It does not have a lot of moving parts. Everything is composed, handled, and executed from within the main handler's ::handle() method.

Bridge

The Pipe has been designed in a way that bridges between the Pipeline and Middleware processing. The response captured when a pipe is executed is forwarded to the next middleware in the pipeline queue. The double pass approach is implemented where each middleware is assigned an internal Request Handler whose only job is to return that response whenever $handler->handle($request) is executed inside MiddlewareInterface::process() method.

It is designed in a similar way as described in Decoration Based Request Handler (see codes where $responsePrototype and $innerHandler is created) but works in a different way. In our case, bridging follows below semantics:

  1. $responsePrototype will be the subject that is being sent through various pipes.
  2. $innerHandler will be an anonymous class implementing RequestHandlerInterface that accepts the $responsePrototype as its constructor arg.
  3. Whenever pipe is executed, internally it invokes MiddlewareInterface::process() passing:
    • the $request sent to the pipeline using Pipeline::use() method, and
    • the newly instantiated $innerHandler whose only job is to return the response, which in our case will be the subject being sent through pipes.

The Pipeline::use() optionally accepts a second argument. It must be a concrete classname that implements RequestHandlerInterface and can accept the $responsePrototype via its constructor. This is done so you can bring your own internal Request Handler (whatever the case may be).

See MockHandler class inside PsrStubs file that is used for testing purposes, and BridgeTest::testPSRBridge() method to learn how it is implemented.

Adapter

Removed in v2

Sometimes, your app might be using the Request/Middleware/Response design system but may not be compatible with PSR-15 implementation. In such cases, Pipeline Bridge also accepts a Middleware Adapter that follows the same design and/or signature. This middleware adapter also accepts a Closure instance that has the same method signature.

Here, incompatible means covariance/contravariance issue, completely different parameter type hinting, and/or return type hinting. But it has the exact same number of parameters: $request & $handler.

In such a case, there is a setter method in Pipeline Bridge:

PipelineBridge::setMiddlewareAdapter(
	interface: MiddlewareAdapterInterface::class,
	className: MiddlewareAdapter::class
);

See PsrStubs file to learn more & BridgeTest::testPSRBridge() method to learn how it is implemented.

Clone this wiki locally