From bb65390d0f6c9bb0ace37a5f059617c116a697d1 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 14 Apr 2026 20:35:59 +0200 Subject: [PATCH 1/6] style: php-cs-fixer --- doc/example/example1.php | 3 ++- doc/example/example2.php | 5 +++-- src/Exception.php | 8 +++++--- src/Middleware/Gzip.php | 4 ++-- src/ResponseWriterWeb.php | 4 ++-- test/unit/SimpleTest.php | 2 +- 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/doc/example/example1.php b/doc/example/example1.php index e7bbdb6..9145c96 100644 --- a/doc/example/example1.php +++ b/doc/example/example1.php @@ -1,4 +1,5 @@ run($request); \ No newline at end of file +$runner->run($request); diff --git a/doc/example/example2.php b/doc/example/example2.php index aa72848..9cc4fe1 100644 --- a/doc/example/example2.php +++ b/doc/example/example2.php @@ -1,4 +1,5 @@ withGlobalVariables()->build(); $middlewares = [ - new Responder($responseFactory, $streamFactory) + new Responder($responseFactory, $streamFactory), ]; $handler = new RampageRequestHandler($responseFactory, $streamFactory, $middlewares); $runner = new Runner($handler, new ResponseWriterWeb()); -$runner->run($request); \ No newline at end of file +$runner->run($request); diff --git a/src/Exception.php b/src/Exception.php index af6b09c..8b64c5f 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -1,8 +1,8 @@ - * @author Ralf Lang + * @author Ralf Lang * @category Horde * @copyright 2008-2021 Horde LLC * @license http://www.horde.org/licenses/bsd BSD diff --git a/src/ResponseWriterWeb.php b/src/ResponseWriterWeb.php index dd95c13..5fd5432 100644 --- a/src/ResponseWriterWeb.php +++ b/src/ResponseWriterWeb.php @@ -1,7 +1,7 @@ - * @author Ralf Lang + * @author Ralf Lang * @category Horde * @copyright 2008-2017 Horde LLC * @license http://www.horde.org/licenses/bsd BSD diff --git a/test/unit/SimpleTest.php b/test/unit/SimpleTest.php index efaab9a..6fa48a5 100644 --- a/test/unit/SimpleTest.php +++ b/test/unit/SimpleTest.php @@ -10,7 +10,7 @@ use Psr\Http\Message\ResponseInterface; /** - * @author Ralf Lang + * @author Ralf Lang * @license http://www.horde.org/licenses/bsd LGPL BSD-3-Clause * @category Horde * @package Http_Server From 5f8adf340e682502b96cdc4b7a0ec9b8e3445ea2 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 14 Apr 2026 20:38:07 +0200 Subject: [PATCH 2/6] chore: Bump minimum php version to 8.1 because we want object instantiation in constructors --- .horde.yml | 11 +++++------ composer.json | 9 ++------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/.horde.yml b/.horde.yml index 67420dc..a230686 100644 --- a/.horde.yml +++ b/.horde.yml @@ -31,7 +31,7 @@ provides: psr/http-server-handler-implementation: ^1 dependencies: required: - php: ^8 + php: ^8.1 composer: psr/http-server-middleware: ^1 psr/http-server-handler: ^1 @@ -41,9 +41,8 @@ dependencies: ext: mbstring: '*' zlib: '*' - dev: - composer: - phpunit/phpunit: ^12 - friendsofphp/php-cs-fixer: ^3 - phpstan/phpstan: ^2 vendor: horde +keywords: + - middleware + - requesthandler + - psr15 diff --git a/composer.json b/composer.json index e3500e2..89c8730 100644 --- a/composer.json +++ b/composer.json @@ -11,20 +11,15 @@ "role": "lead" } ], - "time": "2026-03-07", + "time": "2026-04-14", "repositories": [], "require": { - "php": "^8", + "php": "^8.1", "psr/http-server-middleware": "^1", "psr/http-server-handler": "^1", "horde/exception": "^3 || dev-FRAMEWORK_6_0", "horde/http": "^3 || dev-FRAMEWORK_6_0" }, - "require-dev": { - "phpunit/phpunit": "^12", - "friendsofphp/php-cs-fixer": "^3", - "phpstan/phpstan": "^2" - }, "suggest": { "ext-mbstring": "*", "ext-zlib": "*" From a6b4b1970f44f24bcc2bfe4f3bb38b54755100d7 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 14 Apr 2026 20:48:15 +0200 Subject: [PATCH 3/6] feat: Default constructor arguments hint at horde homegrown implementations --- src/DefaultHandlerTrait.php | 8 ++++++-- src/Middleware/Gzip.php | 6 ++++-- src/Middleware/Responder.php | 8 ++++++-- src/RampageRequestHandler.php | 8 +++++--- src/RequestBuilder.php | 9 ++++++--- src/Runner.php | 6 ++++-- 6 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/DefaultHandlerTrait.php b/src/DefaultHandlerTrait.php index 360b2df..a3d0fde 100644 --- a/src/DefaultHandlerTrait.php +++ b/src/DefaultHandlerTrait.php @@ -2,6 +2,8 @@ namespace Horde\Http\Server; +use Horde\Http\ResponseFactory; +use Horde\Http\StreamFactory; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\RequestInterface; @@ -19,8 +21,10 @@ trait DefaultHandlerTrait protected ResponseFactoryInterface $responseFactory; protected StreamFactoryInterface $streamFactory; - public function __construct(ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory) - { + public function __construct( + ResponseFactoryInterface $responseFactory = new ResponseFactory(), + StreamFactoryInterface $streamFactory = new StreamFactory(), + ) { $this->responseFactory = $responseFactory; $this->streamFactory = $streamFactory; } diff --git a/src/Middleware/Gzip.php b/src/Middleware/Gzip.php index 409b7ca..48a9a01 100644 --- a/src/Middleware/Gzip.php +++ b/src/Middleware/Gzip.php @@ -14,6 +14,7 @@ namespace Horde\Http\Server; +use Horde\Http\StreamFactory; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; @@ -35,8 +36,9 @@ class Gzip implements MiddlewareInterface { private StreamFactoryInterface $streamFactory; - public function __construct(StreamFactoryInterface $streamFactory) - { + public function __construct( + StreamFactoryInterface $streamFactory = new StreamFactory(), + ) { $this->streamFactory = $streamFactory; } diff --git a/src/Middleware/Responder.php b/src/Middleware/Responder.php index e0491a5..44b20c3 100644 --- a/src/Middleware/Responder.php +++ b/src/Middleware/Responder.php @@ -2,6 +2,8 @@ namespace Horde\Http\Server\Middleware; +use Horde\Http\ResponseFactory; +use Horde\Http\StreamFactory; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; @@ -22,8 +24,10 @@ class Responder implements MiddlewareInterface protected ResponseFactoryInterface $responseFactory; protected StreamFactoryInterface $streamFactory; - public function __construct(ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory) - { + public function __construct( + ResponseFactoryInterface $responseFactory = new ResponseFactory(), + StreamFactoryInterface $streamFactory = new StreamFactory(), + ) { $this->responseFactory = $responseFactory; $this->streamFactory = $streamFactory; } diff --git a/src/RampageRequestHandler.php b/src/RampageRequestHandler.php index 62230e3..dfffb23 100644 --- a/src/RampageRequestHandler.php +++ b/src/RampageRequestHandler.php @@ -2,6 +2,8 @@ namespace Horde\Http\Server; +use Horde\Http\ResponseFactory; +use Horde\Http\StreamFactory; use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Message\ServerRequestInterface; @@ -52,10 +54,10 @@ class RampageRequestHandler implements RequestHandlerInterface * @param RequestHandlerInterface|null $payloadHandler */ public function __construct( - ResponseFactoryInterface $responseFactory, - StreamFactoryInterface $streamFactory, + ResponseFactoryInterface $responseFactory = new ResponseFactory(), + StreamFactoryInterface $streamFactory = new StreamFactory(), iterable $middlewares = [], - ?RequestHandlerInterface $payloadHandler = null + ?RequestHandlerInterface $payloadHandler = null, ) { // Needed for the fallback response in case of no payload $this->responseFactory = $responseFactory; diff --git a/src/RequestBuilder.php b/src/RequestBuilder.php index be5b1d0..fe302de 100644 --- a/src/RequestBuilder.php +++ b/src/RequestBuilder.php @@ -2,6 +2,9 @@ namespace Horde\Http\Server; +use Horde\Http\RequestFactory; +use Horde\Http\StreamFactory; +use Horde\Http\UriFactory; use Psr\Http\Message\ServerRequestFactoryInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; @@ -23,9 +26,9 @@ class RequestBuilder private UriFactoryInterface $uriFactory; public function __construct( - ServerRequestFactoryInterface $requestFactory, - StreamFactoryInterface $streamFactory, - UriFactoryInterface $uriFactory + ServerRequestFactoryInterface $requestFactory = new RequestFactory(), + StreamFactoryInterface $streamFactory = new StreamFactory(), + UriFactoryInterface $uriFactory = new UriFactory(), ) { $this->requestFactory = $requestFactory; $this->streamFactory = $streamFactory; diff --git a/src/Runner.php b/src/Runner.php index 53f2147..99693fd 100644 --- a/src/Runner.php +++ b/src/Runner.php @@ -19,8 +19,10 @@ class Runner protected RequestHandlerInterface $handler; protected ResponseWriterInterface $responseWriter; - public function __construct(RequestHandlerInterface $handler, ResponseWriterInterface $responseWriter) - { + public function __construct( + RequestHandlerInterface $handler, + ResponseWriterInterface $responseWriter = new ResponseWriterWeb(), + ) { $this->handler = $handler; $this->responseWriter = $responseWriter; } From 3fb9fbcc0811f773d5149c37ad06ed6ff448f968 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Wed, 13 May 2026 20:39:46 +0200 Subject: [PATCH 4/6] feat(handler): lazy middleware resolution via PSR-11 container Add optional ContainerInterface as last constructor parameter. The addMiddleware/setPayloadHandler methods now accept string class names. nextMiddleware() resolves strings via container on demand if set. New resolvePayloadHandler() for lazy controller resolution. Add psr/container dependency. --- composer.json | 6 ++- src/RampageRequestHandler.php | 81 +++++++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 21 deletions(-) diff --git a/composer.json b/composer.json index 89c8730..06d790a 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "repositories": [], "require": { "php": "^8.1", + "psr/container": "^1.1 || ^2.0", "psr/http-server-middleware": "^1", "psr/http-server-handler": "^1", "horde/exception": "^3 || dev-FRAMEWORK_6_0", @@ -45,5 +46,6 @@ "branch-alias": { "dev-FRAMEWORK_6_0": "1.x-dev" } - } -} \ No newline at end of file + }, + "minimum-stability": "dev" +} diff --git a/src/RampageRequestHandler.php b/src/RampageRequestHandler.php index dfffb23..a0f6257 100644 --- a/src/RampageRequestHandler.php +++ b/src/RampageRequestHandler.php @@ -1,15 +1,19 @@ */ private array $middlewares = []; - - private ?RequestHandlerInterface $payloadHandler; + private string|RequestHandlerInterface|null $payloadHandler; /** * Constructor * * @param ResponseFactoryInterface $responseFactory * @param StreamFactoryInterface $streamFactory - * @param MiddlewareInterface[] $middlewares - * @param RequestHandlerInterface|null $payloadHandler + * @param iterable $middlewares + * @param string|RequestHandlerInterface|null $payloadHandler + * @param ContainerInterface|null $container PSR-11 container for lazy middleware resolution */ public function __construct( ResponseFactoryInterface $responseFactory = new ResponseFactory(), StreamFactoryInterface $streamFactory = new StreamFactory(), iterable $middlewares = [], - ?RequestHandlerInterface $payloadHandler = null, + string|RequestHandlerInterface|null $payloadHandler = null, + ?ContainerInterface $container = null, ) { - // Needed for the fallback response in case of no payload $this->responseFactory = $responseFactory; $this->streamFactory = $streamFactory; - // We accept any iterable but cast it to array - $this->middlewares = (array) $middlewares; + $this->middlewares = [...$middlewares]; $this->payloadHandler = $payloadHandler; + $this->container = $container; } /** * Add another middleware to the queue just before the payload handler */ - public function addMiddleware(MiddlewareInterface $middleware): void + public function addMiddleware(string|MiddlewareInterface $middleware): void { $this->middlewares[] = $middleware; } @@ -78,14 +79,32 @@ public function addMiddleware(MiddlewareInterface $middleware): void /** * Configure the payload handler */ - public function setPayloadHandler(RequestHandlerInterface $handler): void + public function setPayloadHandler(string|RequestHandlerInterface $handler): void { $this->payloadHandler = $handler; } public function nextMiddleware(): ?MiddlewareInterface { - return array_shift($this->middlewares); + $entry = array_shift($this->middlewares); + if ($entry === null) { + return null; + } + if ($entry instanceof MiddlewareInterface) { + return $entry; + } + if ($this->container === null) { + throw new RuntimeException( + sprintf('Cannot resolve middleware "%s": no container provided.', $entry) + ); + } + $resolved = $this->container->get($entry); + if (!$resolved instanceof MiddlewareInterface) { + throw new RuntimeException( + sprintf('Container returned non-middleware for "%s": got %s', $entry, get_debug_type($resolved)) + ); + } + return $resolved; } /** @@ -97,7 +116,7 @@ public function nextMiddleware(): ?MiddlewareInterface * If the middlewares created no response, * the payload handler will. * - * Finally the we will return a response ourselves. + * Finally we will return a response ourselves. */ public function handle(ServerRequestInterface $request): ResponseInterface { @@ -105,8 +124,9 @@ public function handle(ServerRequestInterface $request): ResponseInterface if ($middleware) { return $middleware->process($request, $this); } - if ($this->payloadHandler) { - return $this->payloadHandler->handle($request); + $payloadHandler = $this->resolvePayloadHandler(); + if ($payloadHandler) { + return $payloadHandler->handle($request); } // Fallback response $code = 404; @@ -115,4 +135,27 @@ public function handle(ServerRequestInterface $request): ResponseInterface return $this->responseFactory->createResponse($code, $reason)->withBody($body); } + + private function resolvePayloadHandler(): ?RequestHandlerInterface + { + if ($this->payloadHandler === null) { + return null; + } + if ($this->payloadHandler instanceof RequestHandlerInterface) { + return $this->payloadHandler; + } + if ($this->container === null) { + throw new RuntimeException( + sprintf('Cannot resolve payload handler "%s": no container provided.', $this->payloadHandler) + ); + } + $resolved = $this->container->get($this->payloadHandler); + if (!$resolved instanceof RequestHandlerInterface) { + throw new RuntimeException( + sprintf('Container returned non-handler for "%s": got %s', $this->payloadHandler, get_debug_type($resolved)) + ); + } + $this->payloadHandler = $resolved; + return $resolved; + } } From 0529f3232dde779e474c6e66054ca6d2035b062d Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Wed, 13 May 2026 21:34:33 +0200 Subject: [PATCH 5/6] test: Add middleware and payload handler tests --- test/bootstrap.php | 27 +++ test/unit/DefaultHandlerTraitTest.php | 53 ++++++ test/unit/Middleware/GzipTest.php | 86 +++++++++ test/unit/Middleware/JsonBodyParserTest.php | 164 +++++++++++++++++ test/unit/Middleware/MockTest.php | 31 ++++ test/unit/Middleware/ResponderTest.php | 41 +++++ test/unit/PayloadHandlerTest.php | 42 +++++ .../RampageRequestHandlerDefaultsTest.php | 56 ++++++ test/unit/RequestBuilderTest.php | 174 ++++++++++++++++++ test/unit/RunnerTest.php | 53 ++++++ 10 files changed, 727 insertions(+) create mode 100644 test/bootstrap.php create mode 100644 test/unit/DefaultHandlerTraitTest.php create mode 100644 test/unit/Middleware/GzipTest.php create mode 100644 test/unit/Middleware/JsonBodyParserTest.php create mode 100644 test/unit/Middleware/MockTest.php create mode 100644 test/unit/Middleware/ResponderTest.php create mode 100644 test/unit/PayloadHandlerTest.php create mode 100644 test/unit/RampageRequestHandlerDefaultsTest.php create mode 100644 test/unit/RequestBuilderTest.php create mode 100644 test/unit/RunnerTest.php diff --git a/test/bootstrap.php b/test/bootstrap.php new file mode 100644 index 0000000..68bf624 --- /dev/null +++ b/test/bootstrap.php @@ -0,0 +1,27 @@ +assertInstanceOf(BareHandler::class, $handler); + } + + public function testConstructWithExplicitFactories(): void + { + $handler = new BareHandler(new ResponseFactory(), new StreamFactory()); + $this->assertInstanceOf(BareHandler::class, $handler); + } + + public function testHandleFallbackReturns200WithEmptyBody(): void + { + $handler = new BareHandler(); + $requestFactory = new RequestFactory(); + $request = $requestFactory->createServerRequest('GET', 'https://example.org'); + + $response = $handler->handle($request); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('', (string) $response->getBody()); + } +} diff --git a/test/unit/Middleware/GzipTest.php b/test/unit/Middleware/GzipTest.php new file mode 100644 index 0000000..bfbb4fb --- /dev/null +++ b/test/unit/Middleware/GzipTest.php @@ -0,0 +1,86 @@ +assertInstanceOf(Gzip::class, $gzip); + } + + public function testConstructWithExplicitFactory(): void + { + $gzip = new Gzip(new StreamFactory()); + $this->assertInstanceOf(Gzip::class, $gzip); + } + + public function testProcessSetsGzipHeader(): void + { + $gzip = new Gzip(); + $responseFactory = new ResponseFactory(); + $streamFactory = new StreamFactory(); + + $body = $streamFactory->createStream('Hello World'); + $body->rewind(); + $innerResponse = $responseFactory->createResponse(200)->withBody($body); + + $request = $this->createStub(ServerRequestInterface::class); + $handler = $this->createStub(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($innerResponse); + + $response = $gzip->process($request, $handler); + + $this->assertSame('gzip', $response->getHeaderLine('Content-Encoding')); + } + + public function testProcessReadsBodyContent(): void + { + $gzip = new Gzip(); + $responseFactory = new ResponseFactory(); + $streamFactory = new StreamFactory(); + + $body = $streamFactory->createStream('Test content'); + $body->rewind(); + $innerResponse = $responseFactory->createResponse(200)->withBody($body); + + $request = $this->createStub(ServerRequestInterface::class); + $handler = $this->createStub(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($innerResponse); + + $response = $gzip->process($request, $handler); + + $this->assertSame('Test content', (string) $response->getBody()); + } + + public function testProcessPreservesStatusCode(): void + { + $gzip = new Gzip(); + $responseFactory = new ResponseFactory(); + $streamFactory = new StreamFactory(); + + $body = $streamFactory->createStream('data'); + $body->rewind(); + $innerResponse = $responseFactory->createResponse(201)->withBody($body); + + $request = $this->createStub(ServerRequestInterface::class); + $handler = $this->createStub(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($innerResponse); + + $response = $gzip->process($request, $handler); + + $this->assertSame(201, $response->getStatusCode()); + } +} diff --git a/test/unit/Middleware/JsonBodyParserTest.php b/test/unit/Middleware/JsonBodyParserTest.php new file mode 100644 index 0000000..c8c0982 --- /dev/null +++ b/test/unit/Middleware/JsonBodyParserTest.php @@ -0,0 +1,164 @@ +streamFactory = new StreamFactory(); + $this->dummyResponse = (new ResponseFactory())->createResponse(200); + } + + public function testParsesJsonBody(): void + { + $parser = new JsonBodyParser(); + $body = $this->streamFactory->createStream('{"name":"test","value":42}'); + + $request = $this->createMock(ServerRequestInterface::class); + $request->expects($this->atLeastOnce()) + ->method('getHeaderLine') + ->with('Content-Type') + ->willReturn('application/json'); + $request->expects($this->atLeastOnce()) + ->method('getBody') + ->willReturn($body); + $request->expects($this->once()) + ->method('withParsedBody') + ->with(['name' => 'test', 'value' => 42]) + ->willReturn($request); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once()) + ->method('handle') + ->willReturn($this->dummyResponse); + + $parser->process($request, $handler); + } + + public function testSkipsNonJsonContentType(): void + { + $parser = new JsonBodyParser(); + + $request = $this->createMock(ServerRequestInterface::class); + $request->expects($this->atLeastOnce()) + ->method('getHeaderLine') + ->with('Content-Type') + ->willReturn('text/html'); + $request->expects($this->never())->method('withParsedBody'); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once()) + ->method('handle') + ->willReturn($this->dummyResponse); + + $parser->process($request, $handler); + } + + public function testSkipsInvalidJson(): void + { + $parser = new JsonBodyParser(); + $body = $this->streamFactory->createStream('{invalid json}'); + + $request = $this->createMock(ServerRequestInterface::class); + $request->expects($this->atLeastOnce()) + ->method('getHeaderLine') + ->with('Content-Type') + ->willReturn('application/json'); + $request->expects($this->atLeastOnce()) + ->method('getBody') + ->willReturn($body); + $request->expects($this->never())->method('withParsedBody'); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once()) + ->method('handle') + ->willReturn($this->dummyResponse); + + $parser->process($request, $handler); + } + + public function testSkipsEmptyBody(): void + { + $parser = new JsonBodyParser(); + $body = $this->streamFactory->createStream(''); + + $request = $this->createMock(ServerRequestInterface::class); + $request->expects($this->atLeastOnce()) + ->method('getHeaderLine') + ->with('Content-Type') + ->willReturn('application/json'); + $request->expects($this->atLeastOnce()) + ->method('getBody') + ->willReturn($body); + $request->expects($this->never())->method('withParsedBody'); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once()) + ->method('handle') + ->willReturn($this->dummyResponse); + + $parser->process($request, $handler); + } + + public function testSkipsJsonString(): void + { + $parser = new JsonBodyParser(); + $body = $this->streamFactory->createStream('"just a string"'); + + $request = $this->createMock(ServerRequestInterface::class); + $request->expects($this->atLeastOnce()) + ->method('getHeaderLine') + ->with('Content-Type') + ->willReturn('application/json'); + $request->expects($this->atLeastOnce()) + ->method('getBody') + ->willReturn($body); + $request->expects($this->never())->method('withParsedBody'); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once()) + ->method('handle') + ->willReturn($this->dummyResponse); + + $parser->process($request, $handler); + } + + public function testSkipsJsonInteger(): void + { + $parser = new JsonBodyParser(); + $body = $this->streamFactory->createStream('42'); + + $request = $this->createMock(ServerRequestInterface::class); + $request->expects($this->atLeastOnce()) + ->method('getHeaderLine') + ->with('Content-Type') + ->willReturn('application/json'); + $request->expects($this->atLeastOnce()) + ->method('getBody') + ->willReturn($body); + $request->expects($this->never())->method('withParsedBody'); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once()) + ->method('handle') + ->willReturn($this->dummyResponse); + + $parser->process($request, $handler); + } +} diff --git a/test/unit/Middleware/MockTest.php b/test/unit/Middleware/MockTest.php new file mode 100644 index 0000000..0b1d137 --- /dev/null +++ b/test/unit/Middleware/MockTest.php @@ -0,0 +1,31 @@ +createResponse(418); + $mock = new Mock($expected); + + $request = $this->createStub(ServerRequestInterface::class); + $handler = $this->createStub(RequestHandlerInterface::class); + + $response = $mock->process($request, $handler); + + $this->assertSame($expected, $response); + $this->assertSame(418, $response->getStatusCode()); + } +} diff --git a/test/unit/Middleware/ResponderTest.php b/test/unit/Middleware/ResponderTest.php new file mode 100644 index 0000000..65da907 --- /dev/null +++ b/test/unit/Middleware/ResponderTest.php @@ -0,0 +1,41 @@ +assertInstanceOf(Responder::class, $responder); + } + + public function testConstructWithExplicitFactories(): void + { + $responder = new Responder(new ResponseFactory(), new StreamFactory()); + $this->assertInstanceOf(Responder::class, $responder); + } + + public function testProcessReturns200(): void + { + $responder = new Responder(); + $request = $this->createStub(ServerRequestInterface::class); + $handler = $this->createStub(RequestHandlerInterface::class); + + $response = $responder->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('ResponderMiddleware', (string) $response->getBody()); + } +} diff --git a/test/unit/PayloadHandlerTest.php b/test/unit/PayloadHandlerTest.php new file mode 100644 index 0000000..e3bf16d --- /dev/null +++ b/test/unit/PayloadHandlerTest.php @@ -0,0 +1,42 @@ +assertInstanceOf(PayloadHandler::class, $handler); + } + + public function testConstructWithExplicitFactories(): void + { + $handler = new PayloadHandler(new ResponseFactory(), new StreamFactory()); + $this->assertInstanceOf(PayloadHandler::class, $handler); + } + + public function testHandleReturns200WithPayloadBody(): void + { + $handler = new PayloadHandler(); + $requestFactory = new RequestFactory(); + $request = $requestFactory->createServerRequest('GET', 'https://example.org'); + + $response = $handler->handle($request); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('Payload', (string) $response->getBody()); + } +} diff --git a/test/unit/RampageRequestHandlerDefaultsTest.php b/test/unit/RampageRequestHandlerDefaultsTest.php new file mode 100644 index 0000000..95b24ee --- /dev/null +++ b/test/unit/RampageRequestHandlerDefaultsTest.php @@ -0,0 +1,56 @@ +assertInstanceOf(RampageRequestHandler::class, $handler); + } + + public function testConstructWithDefaultsHandlesRequest(): void + { + $handler = new RampageRequestHandler(); + $requestFactory = new RequestFactory(); + $request = $requestFactory->createServerRequest('GET', 'https://example.org'); + + $response = $handler->handle($request); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(404, $response->getStatusCode()); + } + + public function testConstructWithMiddlewaresIterable(): void + { + $responseFactory = new ResponseFactory(); + $middleware = $this->createStub(MiddlewareInterface::class); + $middleware->method('process')->willReturn($responseFactory->createResponse(201)); + + $handler = new RampageRequestHandler( + middlewares: [$middleware], + ); + + $requestFactory = new RequestFactory(); + $request = $requestFactory->createServerRequest('GET', 'https://example.org'); + $response = $handler->handle($request); + + $this->assertSame(201, $response->getStatusCode()); + } +} diff --git a/test/unit/RequestBuilderTest.php b/test/unit/RequestBuilderTest.php new file mode 100644 index 0000000..66815b2 --- /dev/null +++ b/test/unit/RequestBuilderTest.php @@ -0,0 +1,174 @@ +originalServer = $_SERVER; + $this->originalCookie = $_COOKIE; + $this->originalGet = $_GET; + $this->originalPost = $_POST; + $this->originalFiles = $_FILES; + } + + protected function tearDown(): void + { + $_SERVER = $this->originalServer; + $_COOKIE = $this->originalCookie; + $_GET = $this->originalGet; + $_POST = $this->originalPost; + $_FILES = $this->originalFiles; + } + + private function setMinimalServerVars(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_SCHEME'] = 'https'; + $_SERVER['HTTP_HOST'] = 'example.org'; + $_SERVER['SERVER_PORT'] = '443'; + $_SERVER['REQUEST_URI'] = '/test'; + $_SERVER['QUERY_STRING'] = ''; + $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; + $_COOKIE = []; + $_GET = []; + $_POST = []; + $_FILES = []; + } + + public function testConstructWithDefaults(): void + { + $builder = new RequestBuilder(); + $this->assertInstanceOf(RequestBuilder::class, $builder); + } + + public function testBuildReturnsServerRequest(): void + { + $this->setMinimalServerVars(); + $builder = new RequestBuilder(); + $request = $builder->withGlobalVariables()->build(); + + $this->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertSame('GET', $request->getMethod()); + } + + public function testWithGlobalVariablesPopulatesUri(): void + { + $this->setMinimalServerVars(); + $_SERVER['REQUEST_URI'] = '/path/to/resource'; + $_SERVER['QUERY_STRING'] = 'foo=bar'; + + $builder = new RequestBuilder(); + $request = $builder->withGlobalVariables()->build(); + + $this->assertSame('/path/to/resource', $request->getUri()->getPath()); + $this->assertSame('foo=bar', $request->getUri()->getQuery()); + } + + public function testWithGlobalVariablesHttpsScheme(): void + { + $this->setMinimalServerVars(); + $builder = new RequestBuilder(); + $request = $builder->withGlobalVariables()->build(); + + $this->assertSame('https', $request->getUri()->getScheme()); + } + + public function testWithGlobalVariablesHttpsFromEnvVar(): void + { + $this->setMinimalServerVars(); + unset($_SERVER['REQUEST_SCHEME']); + $_SERVER['HTTPS'] = 'on'; + + $builder = new RequestBuilder(); + $request = $builder->withGlobalVariables()->build(); + + $this->assertSame('https', $request->getUri()->getScheme()); + } + + public function testWithGlobalVariablesHttpsOff(): void + { + $this->setMinimalServerVars(); + unset($_SERVER['REQUEST_SCHEME']); + $_SERVER['HTTPS'] = 'off'; + + $builder = new RequestBuilder(); + $request = $builder->withGlobalVariables()->build(); + + $this->assertSame('http', $request->getUri()->getScheme()); + } + + public function testWithGlobalVariablesDefaultsToHttps(): void + { + $this->setMinimalServerVars(); + unset($_SERVER['REQUEST_SCHEME'], $_SERVER['HTTPS']); + + $builder = new RequestBuilder(); + $request = $builder->withGlobalVariables()->build(); + + $this->assertSame('https', $request->getUri()->getScheme()); + } + + public function testWithHeadersStringValue(): void + { + $this->setMinimalServerVars(); + $builder = new RequestBuilder(); + $result = $builder->withGlobalVariables()->withHeaders([ + 'X-Custom' => 'value', + ]); + + $this->assertSame($builder, $result); + $request = $builder->build(); + $this->assertSame('value', $request->getHeaderLine('X-Custom')); + } + + public function testWithHeadersArrayValue(): void + { + $this->setMinimalServerVars(); + $builder = new RequestBuilder(); + $builder->withGlobalVariables()->withHeaders([ + 'Accept' => ['text/html', 'application/json'], + ]); + + $request = $builder->build(); + $this->assertSame('text/html, application/json', $request->getHeaderLine('Accept')); + } + + public function testWithHeadersThrowsOnNonStringValue(): void + { + $this->setMinimalServerVars(); + $builder = new RequestBuilder(); + $builder->withGlobalVariables(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Header value must be a string or an array of strings'); + $builder->withHeaders(['X-Bad' => 123]); + } + + public function testWithHeadersThrowsOnNonStringInArray(): void + { + $this->setMinimalServerVars(); + $builder = new RequestBuilder(); + $builder->withGlobalVariables(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Header value must be a string or an array of strings'); + $builder->withHeaders(['X-Bad' => ['valid', 42]]); + } +} diff --git a/test/unit/RunnerTest.php b/test/unit/RunnerTest.php new file mode 100644 index 0000000..a78dd02 --- /dev/null +++ b/test/unit/RunnerTest.php @@ -0,0 +1,53 @@ +createStub(RequestHandlerInterface::class); + $runner = new Runner($handler); + $this->assertInstanceOf(Runner::class, $runner); + } + + public function testConstructWithExplicitResponseWriter(): void + { + $handler = $this->createStub(RequestHandlerInterface::class); + $writer = $this->createStub(ResponseWriterInterface::class); + $runner = new Runner($handler, $writer); + $this->assertInstanceOf(Runner::class, $runner); + } + + public function testRunDelegatesToHandlerAndWriter(): void + { + $responseFactory = new ResponseFactory(); + $response = $responseFactory->createResponse(200); + + $request = $this->createStub(ServerRequestInterface::class); + + $handler = $this->createStub(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($response); + + $writer = $this->createMock(ResponseWriterInterface::class); + $writer->expects($this->once()) + ->method('writeResponse') + ->with($response); + + $runner = new Runner($handler, $writer); + $runner->run($request); + } +} From 4a8e99dc02e06f91256ff4d9cadc084c04655d39 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Wed, 13 May 2026 21:40:18 +0200 Subject: [PATCH 6/6] chore: Add optional dependency on psr/container --- .horde.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.horde.yml b/.horde.yml index a230686..43a68dc 100644 --- a/.horde.yml +++ b/.horde.yml @@ -38,6 +38,9 @@ dependencies: horde/exception: ^3 horde/http: ^3 optional: + composer: + psr/container: ^2.02 + horde/injector: '*' ext: mbstring: '*' zlib: '*'