From bd2c75897b2551d1afa91767561c0cf7db11594b Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 27 Mar 2024 18:24:40 +0100 Subject: [PATCH 1/3] PSR-15 compatibility Adds support for classes implementing the `MiddlewareInterface` of the [PSR-15](https://www.php-fig.org/psr/psr-15/) recommendation. See related [discussion]( https://github.com/clue/framework-x/discussions/185) --- composer.json | 3 +- docs/api/middleware.md | 36 ++++++++++++++++++ src/App.php | 42 ++++++++++---------- src/Container.php | 5 +++ src/Io/MiddlewareHandler.php | 10 ++++- src/Io/PsrAwaitRequestHandler.php | 34 +++++++++++++++++ src/Io/PsrMiddlewareAdapter.php | 33 ++++++++++++++++ src/Io/RouteHandler.php | 7 +++- tests/Io/MiddlewareHandlerTest.php | 61 ++++++++++++++++++++++++++++++ tests/Io/RouteHandlerTest.php | 30 +++++++++++++++ 10 files changed, 237 insertions(+), 24 deletions(-) create mode 100644 src/Io/PsrAwaitRequestHandler.php create mode 100644 src/Io/PsrMiddlewareAdapter.php diff --git a/composer.json b/composer.json index e41dcb5..3a852d7 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "phpstan/phpstan": "1.10.47 || 1.4.10", "phpunit/phpunit": "^9.6 || ^7.5", "psr/container": "^2 || ^1", - "react/promise-timer": "^1.10" + "react/promise-timer": "^1.10", + "psr/http-server-middleware": "^1" }, "autoload": { "psr-4": { diff --git a/docs/api/middleware.md b/docs/api/middleware.md index b72ec21..cd8c318 100644 --- a/docs/api/middleware.md +++ b/docs/api/middleware.md @@ -514,6 +514,42 @@ This is commonly used for cache handling and response body transformations (comp > [Generator-based coroutines](../async/coroutines.md) anymore. > See [fibers](../async/fibers.md) for more details. +## PSR-15 middleware + +Middleware handlers can also be classes implementing the `MiddlewareInterface` from the [PSR-15](https://www.php-fig.org/psr/psr-15/) recommendation: + +```php title="src/PsrMiddleware.php" +get('/user', new PsrMiddleware()); +``` + +This is especially useful in order to integrate one of the many existing PSR-15 implementations as global middleware. + ## Global middleware Additionally, you can also add middleware to the [`App`](app.md) object itself diff --git a/src/App.php b/src/App.php index 2e000b2..c68f214 100644 --- a/src/App.php +++ b/src/App.php @@ -3,12 +3,14 @@ namespace FrameworkX; use FrameworkX\Io\MiddlewareHandler; +use FrameworkX\Io\PsrMiddlewareAdapter; use FrameworkX\Io\ReactiveHandler; use FrameworkX\Io\RedirectHandler; use FrameworkX\Io\RouteHandler; use FrameworkX\Io\SapiHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; use React\Http\Message\Response; use React\Promise\Deferred; use React\Promise\PromiseInterface; @@ -37,7 +39,7 @@ class App * $app = new App($middleware1, $middleware2); * ``` * - * @param callable|class-string ...$middleware + * @param callable|MiddlewareInterface|class-string ...$middleware */ public function __construct(...$middleware) { @@ -73,6 +75,8 @@ public function __construct(...$middleware) if (!$handlers) { $needsErrorHandler = $needsAccessLog = $container; } + } elseif ($handler instanceof MiddlewareInterface) { + $handlers[] = new PsrMiddlewareAdapter($handler); } elseif (!\is_callable($handler)) { $handlers[] = $container->callable($handler); } else { @@ -118,8 +122,8 @@ public function __construct(...$middleware) /** * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|MiddlewareInterface|class-string $handler + * @param callable|MiddlewareInterface|class-string ...$handlers */ public function get(string $route, $handler, ...$handlers): void { @@ -128,8 +132,8 @@ public function get(string $route, $handler, ...$handlers): void /** * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|MiddlewareInterface|class-string $handler + * @param callable|MiddlewareInterface|class-string ...$handlers */ public function head(string $route, $handler, ...$handlers): void { @@ -138,8 +142,8 @@ public function head(string $route, $handler, ...$handlers): void /** * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|MiddlewareInterface|class-string $handler + * @param callable|MiddlewareInterface|class-string ...$handlers */ public function post(string $route, $handler, ...$handlers): void { @@ -148,8 +152,8 @@ public function post(string $route, $handler, ...$handlers): void /** * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|MiddlewareInterface|class-string $handler + * @param callable|MiddlewareInterface|class-string ...$handlers */ public function put(string $route, $handler, ...$handlers): void { @@ -158,8 +162,8 @@ public function put(string $route, $handler, ...$handlers): void /** * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|MiddlewareInterface|class-string $handler + * @param callable|MiddlewareInterface|class-string ...$handlers */ public function patch(string $route, $handler, ...$handlers): void { @@ -168,8 +172,8 @@ public function patch(string $route, $handler, ...$handlers): void /** * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|MiddlewareInterface|class-string $handler + * @param callable|MiddlewareInterface|class-string ...$handlers */ public function delete(string $route, $handler, ...$handlers): void { @@ -178,8 +182,8 @@ public function delete(string $route, $handler, ...$handlers): void /** * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|MiddlewareInterface|class-string $handler + * @param callable|MiddlewareInterface|class-string ...$handlers */ public function options(string $route, $handler, ...$handlers): void { @@ -193,8 +197,8 @@ public function options(string $route, $handler, ...$handlers): void /** * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|MiddlewareInterface|class-string $handler + * @param callable|MiddlewareInterface|class-string ...$handlers */ public function any(string $route, $handler, ...$handlers): void { @@ -205,8 +209,8 @@ public function any(string $route, $handler, ...$handlers): void * * @param string[] $methods * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|MiddlewareInterface|class-string $handler + * @param callable|MiddlewareInterface|class-string ...$handlers */ public function map(array $methods, string $route, $handler, ...$handlers): void { diff --git a/src/Container.php b/src/Container.php index 2058379..8cc4860 100644 --- a/src/Container.php +++ b/src/Container.php @@ -2,8 +2,10 @@ namespace FrameworkX; +use FrameworkX\Io\PsrMiddlewareAdapter; use Psr\Container\ContainerInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; /** * @final @@ -83,6 +85,9 @@ public function callable(string $class): callable $e ); } + if ($handler instanceof MiddlewareInterface) { + $handler = new PsrMiddlewareAdapter($handler); + } // Check `$handler` references a class name that is callable, i.e. has an `__invoke()` method. // This initial version is intentionally limited to checking the method name only. diff --git a/src/Io/MiddlewareHandler.php b/src/Io/MiddlewareHandler.php index dd62f01..2ae249b 100644 --- a/src/Io/MiddlewareHandler.php +++ b/src/Io/MiddlewareHandler.php @@ -3,6 +3,7 @@ namespace FrameworkX\Io; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; /** * @internal @@ -12,12 +13,17 @@ class MiddlewareHandler /** @var list $handlers */ private $handlers; - /** @param list $handlers */ + /** @param list $handlers */ public function __construct(array $handlers) { assert(count($handlers) >= 2); - $this->handlers = $handlers; + $this->handlers = array_map(function ($handler) { + if ($handler instanceof MiddlewareInterface) { + return new PsrMiddlewareAdapter($handler); + } + return $handler; + }, $handlers); } /** @return mixed */ diff --git a/src/Io/PsrAwaitRequestHandler.php b/src/Io/PsrAwaitRequestHandler.php new file mode 100644 index 0000000..d4ea3f1 --- /dev/null +++ b/src/Io/PsrAwaitRequestHandler.php @@ -0,0 +1,34 @@ +next = $next; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + if ($this->next === null) { + return new Response(); + } + return await(resolve(($this->next)($request))); + } +} diff --git a/src/Io/PsrMiddlewareAdapter.php b/src/Io/PsrMiddlewareAdapter.php new file mode 100644 index 0000000..efb0c46 --- /dev/null +++ b/src/Io/PsrMiddlewareAdapter.php @@ -0,0 +1,33 @@ +middleware = $middleware; + } + + /** @return PromiseInterface */ + public function __invoke(ServerRequestInterface $request, callable $next = null): PromiseInterface + { + return async(function () use ($request, $next) { + return $this->middleware->process($request, new PsrAwaitRequestHandler($next)); + })($request, $next); + } +} diff --git a/src/Io/RouteHandler.php b/src/Io/RouteHandler.php index 2b0cf1c..181d97f 100644 --- a/src/Io/RouteHandler.php +++ b/src/Io/RouteHandler.php @@ -11,6 +11,7 @@ use FrameworkX\ErrorHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; use React\Promise\PromiseInterface; /** @@ -40,8 +41,8 @@ public function __construct(Container $container = null) /** * @param string[] $methods * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|MiddlewareInterface|class-string $handler + * @param callable|MiddlewareInterface|class-string ...$handlers */ public function map(array $methods, string $route, $handler, ...$handlers): void { @@ -60,6 +61,8 @@ public function map(array $methods, string $route, $handler, ...$handlers): void unset($handlers[$i]); } elseif ($handler instanceof AccessLogHandler || $handler === AccessLogHandler::class) { throw new \TypeError('AccessLogHandler may currently only be passed as a global middleware'); + } elseif ($handler instanceof MiddlewareInterface) { + $handlers[$i] = new PsrMiddlewareAdapter($handler); } elseif (!\is_callable($handler)) { $handlers[$i] = $container->callable($handler); } diff --git a/tests/Io/MiddlewareHandlerTest.php b/tests/Io/MiddlewareHandlerTest.php index 7606d52..49551eb 100644 --- a/tests/Io/MiddlewareHandlerTest.php +++ b/tests/Io/MiddlewareHandlerTest.php @@ -6,8 +6,13 @@ use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; use React\Http\Message\Response; use React\Http\Message\ServerRequest; +use React\Promise\Deferred; +use React\Promise\PromiseInterface; +use function React\Async\await; class MiddlewareHandlerTest extends TestCase { @@ -136,4 +141,60 @@ function (ServerRequestInterface $request) { $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); $this->assertEquals("OK\n", (string) $response->getBody()); } + + public function testPsrMiddleware(): void + { + $handler = new MiddlewareHandler([ + function (ServerRequestInterface $request, callable $next) { + $response = $next($request); + $decorate = static function (ResponseInterface $response) { + return $response->withAddedHeader('X-Middleware', '1'); + }; + if ($response instanceof PromiseInterface) { + return $response->then(function ($response) use ($decorate) { + assert($response instanceof ResponseInterface); + return $decorate($response); + }); + } + if ($response instanceof \Generator) { + return (function () use ($response, $decorate) { + return $decorate(yield from $response); + })(); + } + return $decorate($response); + }, + new class implements MiddlewareInterface { + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $handler->handle($request)->withAddedHeader('X-Middleware', '2'); + } + }, + function (ServerRequestInterface $request, callable $next) { + $response = $next($request); + assert($response instanceof ResponseInterface); + $deferred = new Deferred(); + $deferred->resolve($response->withAddedHeader('X-Middleware', '3')); + return $deferred->promise(); + }, + function (ServerRequestInterface $request) { + return new Response( + 200, + [ + 'Content-Type' => 'text/html' + ], + "OK\n" + ); + } + ]); + + $request = new ServerRequest('GET', 'http://localhost/'); + + $response = await($handler($request)); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals([3, 2, 1], $response->getHeader('X-Middleware')); + } } diff --git a/tests/Io/RouteHandlerTest.php b/tests/Io/RouteHandlerTest.php index abf4482..8a5b8fb 100644 --- a/tests/Io/RouteHandlerTest.php +++ b/tests/Io/RouteHandlerTest.php @@ -9,6 +9,8 @@ use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; use React\Http\Message\Response; use React\Http\Message\ServerRequest; @@ -152,6 +154,34 @@ public function testHandleRequestWithGetRequestReturnsResponseFromMatchingHandle $this->assertSame($response, $ret); } + public function testHandleRequestWithGetRequestReturnsResponseFromMatchingPsr15MiddlewareHandler(): void + { + $request = new ServerRequest('GET', 'http://example.com/'); + $response = new Response(200, [], ''); + + $handler = new RouteHandler(); + $handler->map(['GET'], '/', new class ($response) implements MiddlewareInterface { + + /** + * @var Response + */ + private $response; + + public function __construct(Response $response) + { + $this->response = $response; + } + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $this->response; + } + }); + + $ret = $handler($request); + + $this->assertSame($response, $ret); + } + public function testHandleRequestWithGetRequestReturnsResponseFromMatchingHandlerClass(): void { $request = new ServerRequest('GET', 'http://example.com/'); From cc83db413c19a2e08abba8ddaccac650d44d4802 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 27 Mar 2024 18:37:10 +0100 Subject: [PATCH 2/3] Cosmetic tweaks to satisfy PHPStan checks --- tests/Io/MiddlewareHandlerTest.php | 5 +++-- tests/Io/RouteHandlerTest.php | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/Io/MiddlewareHandlerTest.php b/tests/Io/MiddlewareHandlerTest.php index 49551eb..97fb68c 100644 --- a/tests/Io/MiddlewareHandlerTest.php +++ b/tests/Io/MiddlewareHandlerTest.php @@ -188,8 +188,9 @@ function (ServerRequestInterface $request) { ]); $request = new ServerRequest('GET', 'http://localhost/'); - - $response = await($handler($request)); + /** @var PromiseInterface $responsePromise */ + $responsePromise = $handler($request); + $response = await($responsePromise); /** @var ResponseInterface $response */ $this->assertInstanceOf(ResponseInterface::class, $response); diff --git a/tests/Io/RouteHandlerTest.php b/tests/Io/RouteHandlerTest.php index 8a5b8fb..5dc9ba7 100644 --- a/tests/Io/RouteHandlerTest.php +++ b/tests/Io/RouteHandlerTest.php @@ -13,6 +13,8 @@ use Psr\Http\Server\RequestHandlerInterface; use React\Http\Message\Response; use React\Http\Message\ServerRequest; +use React\Promise\PromiseInterface; +use function React\Async\await; class RouteHandlerTest extends TestCase { @@ -177,9 +179,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } }); - $ret = $handler($request); + /** @var PromiseInterface $responsePromise */ + $responsePromise = $handler($request); - $this->assertSame($response, $ret); + $this->assertSame($response, await($responsePromise)); } public function testHandleRequestWithGetRequestReturnsResponseFromMatchingHandlerClass(): void From 681845f2e70e6e10a7c5fd63122cf125ad696f66 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 27 Mar 2024 18:52:59 +0100 Subject: [PATCH 3/3] more test coverage --- tests/Io/PsrAwaitRequestHandlerTest.php | 38 +++++++++++++++++++ tests/Io/PsrMiddlewareAdapterTest.php | 50 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/Io/PsrAwaitRequestHandlerTest.php create mode 100644 tests/Io/PsrMiddlewareAdapterTest.php diff --git a/tests/Io/PsrAwaitRequestHandlerTest.php b/tests/Io/PsrAwaitRequestHandlerTest.php new file mode 100644 index 0000000..df9fff3 --- /dev/null +++ b/tests/Io/PsrAwaitRequestHandlerTest.php @@ -0,0 +1,38 @@ +resolve($response); + $handler = new PsrAwaitRequestHandler(function () use ($deferred) { + return $deferred->promise(); + }); + + $request = new ServerRequest('GET', 'http://localhost/'); + $this->assertSame($response, $handler->handle($request)); + } + + public function testHandleWithoutNextReturnsEmptyResponse(): void + { + $handler = new PsrAwaitRequestHandler(); + + $request = new ServerRequest('GET', 'http://localhost/'); + $response = $handler->handle($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEmpty($response->getBody()->getContents()); + } +} diff --git a/tests/Io/PsrMiddlewareAdapterTest.php b/tests/Io/PsrMiddlewareAdapterTest.php new file mode 100644 index 0000000..d6171f4 --- /dev/null +++ b/tests/Io/PsrMiddlewareAdapterTest.php @@ -0,0 +1,50 @@ +resolve($response); + + $psrMiddleware = new class ($response) implements MiddlewareInterface { + + /** + * @var Response + */ + private $response; + + public function __construct(Response $response) + { + $this->response = $response; + } + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $this->response; + } + }; + + $handler = new PsrMiddlewareAdapter($psrMiddleware); + + $request = new ServerRequest('GET', 'http://localhost/'); + /** @var PromiseInterface $responsePromise */ + $responsePromise = $handler($request); + $this->assertSame($response, await($responsePromise)); + } +}