From 52f33afb3a2ce315ea5076ee48bf4dca662a8a5d Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Tue, 17 Mar 2026 23:53:25 -0300 Subject: [PATCH] Decouple DispatchEngine from Router and improve type safety Introduce RouteProvider interface so DispatchEngine depends on an abstraction rather than Router directly. Replace assert() calls in DispatchContext and RoutinePipeline by threading AbstractRoute as an explicit parameter. Fix AuthBasic to reject non-Basic auth schemes. Rename virtualHost to basePath. Add tests for Accept negotiation, DispatchEngine and RoutinePipeline. --- src/DispatchContext.php | 57 +++---- src/DispatchEngine.php | 26 ++-- src/RouteProvider.php | 15 ++ src/Router.php | 16 +- src/Routes/AbstractRoute.php | 4 +- src/RoutinePipeline.php | 23 ++- src/Routines/AbstractAccept.php | 44 +++--- src/Routines/AuthBasic.php | 3 +- tests/DispatchContextTest.php | 19 ++- tests/DispatchEngineTest.php | 205 ++++++++++++++++++++++++++ tests/RouterTest.php | 35 +++-- tests/RoutinePipelineTest.php | 131 ++++++++++++++++ tests/Routines/AcceptCharsetTest.php | 89 +++++++++++ tests/Routines/AcceptEncodingTest.php | 77 ++++++++++ tests/Routines/AcceptLanguageTest.php | 104 +++++++++++++ tests/Routines/AcceptTest.php | 151 +++++++++++++++++++ 16 files changed, 895 insertions(+), 104 deletions(-) create mode 100644 src/RouteProvider.php create mode 100644 tests/DispatchEngineTest.php create mode 100644 tests/RoutinePipelineTest.php create mode 100644 tests/Routines/AcceptCharsetTest.php create mode 100644 tests/Routines/AcceptEncodingTest.php create mode 100644 tests/Routines/AcceptLanguageTest.php create mode 100644 tests/Routines/AcceptTest.php diff --git a/src/DispatchContext.php b/src/DispatchContext.php index cfae195..c270928 100644 --- a/src/DispatchContext.php +++ b/src/DispatchContext.php @@ -15,7 +15,6 @@ use Respect\Rest\Routines\Routinable; use Throwable; -use function assert; use function rawurldecode; use function rtrim; use function set_error_handler; @@ -121,17 +120,19 @@ public function prepareResponse(int $status, array $headers = []): void /** Generates the PSR-7 response from the current route */ public function response(): ResponseInterface|null { - try { - if (!$this->route instanceof AbstractRoute) { - if ($this->responseDraft !== null) { - return $this->finalizeResponse($this->responseDraft); - } - - return null; + if (!$this->route instanceof AbstractRoute) { + if ($this->responseDraft !== null) { + return $this->finalizeResponse($this->responseDraft); } - $errorHandler = $this->prepareForErrorForwards(); - $preRoutineResult = $this->routinePipeline()->processBy($this); + return null; + } + + $route = $this->route; + + try { + $errorHandler = $this->prepareForErrorForwards($route); + $preRoutineResult = $this->routinePipeline()->processBy($this, $route); if ($preRoutineResult !== null) { if ($preRoutineResult instanceof AbstractRoute) { @@ -147,14 +148,14 @@ public function response(): ResponseInterface|null } } - $rawResult = $this->route->dispatchTarget($this->method(), $this->params, $this); + $rawResult = $route->dispatchTarget($this->method(), $this->params, $this); if ($rawResult instanceof AbstractRoute) { return $this->forward($rawResult); } - $processedResult = $this->routinePipeline()->processThrough($this, $rawResult); - $errorResponse = $this->forwardErrors($errorHandler); + $processedResult = $this->routinePipeline()->processThrough($this, $route, $rawResult); + $errorResponse = $this->forwardErrors($errorHandler, $route); if ($errorResponse !== null) { return $errorResponse; @@ -162,7 +163,7 @@ public function response(): ResponseInterface|null return $this->finalizeResponse($processedResult); } catch (Throwable $e) { - $exceptionResponse = $this->catchExceptions($e); + $exceptionResponse = $this->catchExceptions($e, $route); if ($exceptionResponse === null) { throw $e; } @@ -172,10 +173,14 @@ public function response(): ResponseInterface|null } /** @param array $params */ - public function routineCall(string $type, string $method, Routinable $routine, array &$params): mixed - { - assert($this->route !== null); - $reflection = $this->route->getTargetReflection($method); + public function routineCall( + string $type, + string $method, + Routinable $routine, + array &$params, + AbstractRoute $route, + ): mixed { + $reflection = $route->getTargetReflection($method); $callbackParameters = []; @@ -212,10 +217,9 @@ public function setResponder(Responder $responder): void } /** @return callable|null The previous error handler, or null */ - protected function prepareForErrorForwards(): callable|null + protected function prepareForErrorForwards(AbstractRoute $route): callable|null { - assert($this->route !== null); - foreach ($this->route->sideRoutes as $sideRoute) { + foreach ($route->sideRoutes as $sideRoute) { if ($sideRoute instanceof Routes\Error) { return set_error_handler( static function ( @@ -235,15 +239,13 @@ static function ( return null; } - protected function forwardErrors(callable|null $errorHandler): ResponseInterface|null + protected function forwardErrors(callable|null $errorHandler, AbstractRoute $route): ResponseInterface|null { if ($errorHandler !== null) { set_error_handler($errorHandler); } - assert($this->route !== null); - - foreach ($this->route->sideRoutes as $sideRoute) { + foreach ($route->sideRoutes as $sideRoute) { if ($sideRoute instanceof Routes\Error && $sideRoute->errors) { return $this->forward($sideRoute); } @@ -252,10 +254,9 @@ protected function forwardErrors(callable|null $errorHandler): ResponseInterface return null; } - protected function catchExceptions(Throwable $e): ResponseInterface|null + protected function catchExceptions(Throwable $e, AbstractRoute $route): ResponseInterface|null { - assert($this->route !== null); - foreach ($this->route->sideRoutes as $sideRoute) { + foreach ($route->sideRoutes as $sideRoute) { if (!$sideRoute instanceof Routes\Exception) { continue; } diff --git a/src/DispatchEngine.php b/src/DispatchEngine.php index 46cc8ea..f5c4075 100644 --- a/src/DispatchEngine.php +++ b/src/DispatchEngine.php @@ -4,6 +4,7 @@ namespace Respect\Rest; +use Closure; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -27,10 +28,12 @@ final class DispatchEngine implements RequestHandlerInterface { private RoutinePipeline $routinePipeline; + /** @param (Closure(DispatchContext): void)|null $onContextReady */ public function __construct( - private Router $router, + private RouteProvider $routeProvider, private ResponseFactoryInterface $responseFactory, private StreamFactoryInterface $streamFactory, + private Closure|null $onContextReady = null, ) { $this->routinePipeline = new RoutinePipeline(); } @@ -59,7 +62,10 @@ public function handle(ServerRequestInterface $request): ResponseInterface public function dispatchContext(DispatchContext $context): DispatchContext { - $this->router->context = $context; + if ($this->onContextReady !== null) { + ($this->onContextReady)($context); + } + $context->setRoutinePipeline($this->routinePipeline); if (!$this->isRoutelessDispatch($context) && $context->route === null) { @@ -104,7 +110,7 @@ private function isRoutelessDispatch(DispatchContext $context): bool return false; } - $allowedMethods = $this->getAllowedMethods($this->router->getRoutes()); + $allowedMethods = $this->getAllowedMethods($this->routeProvider->getRoutes()); if ($allowedMethods) { $context->prepareResponse( @@ -120,7 +126,7 @@ private function isRoutelessDispatch(DispatchContext $context): bool private function routeDispatch(DispatchContext $context): void { - $this->applyVirtualHost($context); + $this->applyBasePath($context); $matchedByPath = $this->getMatchedRoutesByPath($context); /** @var array $matchedArray */ @@ -140,16 +146,16 @@ private function routeDispatch(DispatchContext $context): void } } - private function applyVirtualHost(DispatchContext $context): void + private function applyBasePath(DispatchContext $context): void { - $virtualHost = $this->router->getVirtualHost(); - if ($virtualHost === null) { + $basePath = $this->routeProvider->getBasePath(); + if ($basePath === null) { return; } $context->setPath( preg_replace( - '#^' . preg_quote($virtualHost) . '#', + '#^' . preg_quote($basePath) . '#', '', $context->path(), ) ?? $context->path(), @@ -174,7 +180,7 @@ private function getMatchedRoutesByPath(DispatchContext $context): SplObjectStor /** @var SplObjectStorage> $matched */ $matched = new SplObjectStorage(); - foreach ($this->router->getRoutes() as $route) { + foreach ($this->routeProvider->getRoutes() as $route) { $params = []; if (!$this->matchRoute($context, $route, $params)) { continue; @@ -297,7 +303,7 @@ private function routineMatch( $tempParams = $matchedByPath[$route]; $context->clearResponseMeta(); $context->route = $route; - if ($this->routinePipeline->matches($context, $tempParams)) { + if ($this->routinePipeline->matches($context, $route, $tempParams)) { return $this->configureContext( $context, $route, diff --git a/src/RouteProvider.php b/src/RouteProvider.php new file mode 100644 index 0000000..c91fb72 --- /dev/null +++ b/src/RouteProvider.php @@ -0,0 +1,15 @@ + */ + public function getRoutes(): array; + + public function getBasePath(): string|null; +} diff --git a/src/Router.php b/src/Router.php index 63ba3ec..766e06d 100644 --- a/src/Router.php +++ b/src/Router.php @@ -41,7 +41,7 @@ * @method AbstractRoute patch(string $path, mixed $routeTarget) * @method AbstractRoute any(string $path, mixed $routeTarget) */ -final class Router implements MiddlewareInterface +final class Router implements MiddlewareInterface, RouteProvider { public DispatchContext|null $context = null; @@ -54,12 +54,9 @@ final class Router implements MiddlewareInterface /** @var array */ protected array $sideRoutes = []; - /** Used by tests for named route attributes */ - public mixed $allMembers = null; - private DispatchEngine|null $dispatchEngine = null; - public function __construct(private HttpFactories $httpFactories, protected string|null $virtualHost = null) + public function __construct(private HttpFactories $httpFactories, protected string|null $basePath = null) { } @@ -82,7 +79,7 @@ public function appendRoute(AbstractRoute $route): static { $this->routes[] = $route; $route->sideRoutes = &$this->sideRoutes; - $route->virtualHost = $this->virtualHost; + $route->basePath = $this->basePath; foreach ($this->globalRoutines as $routine) { $route->appendRoutine($routine); @@ -196,9 +193,9 @@ public function getRoutes(): array return $this->routes; } - public function getVirtualHost(): string|null + public function getBasePath(): string|null { - return $this->virtualHost; + return $this->basePath; } public function dispatchEngine(): DispatchEngine @@ -207,6 +204,9 @@ public function dispatchEngine(): DispatchEngine $this, $this->responseFactory(), $this->streamFactory(), + function (DispatchContext $ctx): void { + $this->context = $ctx; + }, ); } diff --git a/src/Routes/AbstractRoute.php b/src/Routes/AbstractRoute.php index 5b0806d..12ce334 100644 --- a/src/Routes/AbstractRoute.php +++ b/src/Routes/AbstractRoute.php @@ -76,7 +76,7 @@ abstract class AbstractRoute /** @var array */ public array $sideRoutes = []; - public string|null $virtualHost = null; + public string|null $basePath = null; public function __construct(string $method, public string $pattern = '') { @@ -157,7 +157,7 @@ public function createUri(mixed ...$params): string $params = preg_replace('#(?virtualHost, ' /') . sprintf($this->regexForReplace, ...$params); + return rtrim((string) $this->basePath, ' /') . sprintf($this->regexForReplace, ...$params); } /** @param array $params */ diff --git a/src/RoutinePipeline.php b/src/RoutinePipeline.php index f4e426a..55f424c 100644 --- a/src/RoutinePipeline.php +++ b/src/RoutinePipeline.php @@ -10,20 +10,17 @@ use Respect\Rest\Routines\ProxyableThrough; use Respect\Rest\Routines\ProxyableWhen; -use function assert; use function is_callable; final class RoutinePipeline { /** @param array $params */ - public function matches(DispatchContext $context, array $params = []): bool + public function matches(DispatchContext $context, AbstractRoute $route, array $params = []): bool { - assert($context->route !== null); - - foreach ($context->route->routines as $routine) { + foreach ($route->routines as $routine) { if ( $routine instanceof ProxyableWhen - && !$context->routineCall('when', $context->method(), $routine, $params) + && !$context->routineCall('when', $context->method(), $routine, $params, $route) ) { return false; } @@ -32,11 +29,9 @@ public function matches(DispatchContext $context, array $params = []): bool return true; } - public function processBy(DispatchContext $context): mixed + public function processBy(DispatchContext $context, AbstractRoute $route): mixed { - assert($context->route !== null); - - foreach ($context->route->routines as $routine) { + foreach ($route->routines as $routine) { if (!$routine instanceof ProxyableBy) { continue; } @@ -46,6 +41,7 @@ public function processBy(DispatchContext $context): mixed $context->method(), $routine, $context->params, + $route, ); if ($result instanceof AbstractRoute || $result instanceof ResponseInterface || $result === false) { @@ -56,11 +52,9 @@ public function processBy(DispatchContext $context): mixed return null; } - public function processThrough(DispatchContext $context, mixed $response): mixed + public function processThrough(DispatchContext $context, AbstractRoute $route, mixed $response): mixed { - assert($context->route !== null); - - foreach ($context->route->routines as $routine) { + foreach ($route->routines as $routine) { if (!($routine instanceof ProxyableThrough)) { continue; } @@ -70,6 +64,7 @@ public function processThrough(DispatchContext $context, mixed $response): mixed $context->method(), $routine, $context->params, + $route, ); if (!is_callable($proxyCallback)) { diff --git a/src/Routines/AbstractAccept.php b/src/Routines/AbstractAccept.php index 12e764c..f1e7d10 100644 --- a/src/Routines/AbstractAccept.php +++ b/src/Routines/AbstractAccept.php @@ -59,27 +59,6 @@ public function through(DispatchContext $context, array $params): mixed return $this->getNegotiatedCallback($context); } - /** - * Convert a $_SERVER-style header constant to a PSR-7 header name. - * - * HTTP_ACCEPT -> Accept - * HTTP_ACCEPT_CHARSET -> Accept-Charset - * HTTP_ACCEPT_ENCODING -> Accept-Encoding - * HTTP_ACCEPT_LANGUAGE -> Accept-Language - * HTTP_USER_AGENT -> User-Agent - */ - protected function getAcceptHeaderName(): string - { - // phpcs:ignore SlevomatCodingStandard.Classes.DisallowLateStaticBindingForConstants - $header = static::ACCEPT_HEADER; - - if (!str_starts_with($header, 'HTTP_')) { - return $header; - } - - return str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($header, 5))))); - } - /** * @param array $params * @@ -172,7 +151,28 @@ protected function authorize(string $requested, string $provided): mixed return $requested == $provided; } - protected function getNegotiatedHeaderType(): string + /** + * Convert a $_SERVER-style header constant to a PSR-7 header name. + * + * HTTP_ACCEPT -> Accept + * HTTP_ACCEPT_CHARSET -> Accept-Charset + * HTTP_ACCEPT_ENCODING -> Accept-Encoding + * HTTP_ACCEPT_LANGUAGE -> Accept-Language + * HTTP_USER_AGENT -> User-Agent + */ + private function getAcceptHeaderName(): string + { + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowLateStaticBindingForConstants + $header = static::ACCEPT_HEADER; + + if (!str_starts_with($header, 'HTTP_')) { + return $header; + } + + return str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($header, 5))))); + } + + private function getNegotiatedHeaderType(): string { return (string) preg_replace( [ diff --git a/src/Routines/AuthBasic.php b/src/Routines/AuthBasic.php index 7e35caa..1664cab 100644 --- a/src/Routines/AuthBasic.php +++ b/src/Routines/AuthBasic.php @@ -9,6 +9,7 @@ use function array_merge; use function base64_decode; use function explode; +use function stripos; use function substr; final class AuthBasic extends AbstractRoutine implements ProxyableBy @@ -25,7 +26,7 @@ public function by(DispatchContext $context, array $params): mixed $authorization = $context->request->getHeaderLine('Authorization'); - if ($authorization !== '') { + if ($authorization !== '' && stripos($authorization, 'Basic ') === 0) { $callbackResponse = ($this->callback)( ...array_merge(explode(':', base64_decode(substr($authorization, 6))), $params), ); diff --git a/tests/DispatchContextTest.php b/tests/DispatchContextTest.php index 81c3bb4..a9fac07 100644 --- a/tests/DispatchContextTest.php +++ b/tests/DispatchContextTest.php @@ -19,6 +19,7 @@ use Respect\Rest\Responder; use Respect\Rest\Routes; use Respect\Rest\Routines; +use RuntimeException; use function array_unique; use function array_walk; @@ -536,10 +537,26 @@ public function test_unsynced_param_comes_as_null(): void }); $context->route->appendRoutine($routine); $dummy = ['bar']; - $context->routineCall('by', 'GET', $routine, $dummy); + $context->routineCall('by', 'GET', $routine, $dummy, $context->route); self::assertEquals([null, 'bar', null], $args); } + public function test_exception_rethrown_when_no_exception_route_matches(): void + { + $context = $this->newContext(new ServerRequest('GET', '/')); + $context->route = $this->getMockForRoute( + 'GET', + '/', + static function (): never { + throw new RuntimeException('boom'); + }, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('boom'); + $context->response(); + } + /** * @param Routes\AbstractRoute&MockObject $route * diff --git a/tests/DispatchEngineTest.php b/tests/DispatchEngineTest.php new file mode 100644 index 0000000..130d45f --- /dev/null +++ b/tests/DispatchEngineTest.php @@ -0,0 +1,205 @@ +factory = new Psr17Factory(); + $this->httpFactories = new HttpFactories($this->factory, $this->factory); + } + + public function testMatchingRouteConfiguresContext(): void + { + $engine = $this->engine([ + new StaticValue('GET', '/hello', 'world'), + ]); + + $context = $engine->dispatch(new ServerRequest('GET', '/hello')); + + self::assertNotNull($context->route); + $response = $context->response(); + self::assertNotNull($response); + self::assertSame('world', (string) $response->getBody()); + } + + public function testNoMatchReturns404(): void + { + $engine = $this->engine([ + new StaticValue('GET', '/exists', 'ok'), + ]); + + $context = $engine->dispatch(new ServerRequest('GET', '/not-found')); + + self::assertTrue($context->hasPreparedResponse()); + $response = $context->response(); + self::assertNotNull($response); + self::assertSame(404, $response->getStatusCode()); + } + + public function testWrongMethodReturns405WithAllowHeader(): void + { + $engine = $this->engine([ + new StaticValue('GET', '/resource', 'ok'), + ]); + + $context = $engine->dispatch(new ServerRequest('DELETE', '/resource')); + + self::assertTrue($context->hasPreparedResponse()); + $response = $context->response(); + self::assertNotNull($response); + self::assertSame(405, $response->getStatusCode()); + self::assertStringContainsString('GET', $response->getHeaderLine('Allow')); + } + + public function testGlobalOptionsReturns204WithAllMethods(): void + { + $engine = $this->engine([ + new StaticValue('GET', '/a', 'ok'), + new StaticValue('POST', '/b', 'ok'), + ]); + + $context = $engine->dispatch(new ServerRequest('OPTIONS', '*')); + + self::assertTrue($context->hasPreparedResponse()); + $response = $context->response(); + self::assertNotNull($response); + self::assertSame(204, $response->getStatusCode()); + $allow = $response->getHeaderLine('Allow'); + self::assertStringContainsString('GET', $allow); + self::assertStringContainsString('POST', $allow); + self::assertStringContainsString('OPTIONS', $allow); + } + + public function testOptionsOnSpecificPathReturns204(): void + { + $engine = $this->engine([ + new StaticValue('GET', '/resource', 'ok'), + new StaticValue('POST', '/resource', 'ok'), + ]); + + $context = $engine->dispatch(new ServerRequest('OPTIONS', '/resource')); + + self::assertTrue($context->hasPreparedResponse()); + $response = $context->response(); + self::assertNotNull($response); + self::assertSame(204, $response->getStatusCode()); + $allow = $response->getHeaderLine('Allow'); + self::assertStringContainsString('GET', $allow); + self::assertStringContainsString('POST', $allow); + } + + public function testBasePathPrefixIsStripped(): void + { + $provider = $this->createStub(RouteProvider::class); + $provider->method('getRoutes')->willReturn([ + new StaticValue('GET', '/resource', 'found'), + ]); + $provider->method('getBasePath')->willReturn('/api'); + + $engine = new DispatchEngine( + $provider, + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + + $context = $engine->dispatch(new ServerRequest('GET', '/api/resource')); + + $response = $context->response(); + self::assertNotNull($response); + self::assertSame('found', (string) $response->getBody()); + } + + public function testHandleReturnsPsr7Response(): void + { + $engine = $this->engine([ + new StaticValue('GET', '/hello', 'world'), + ]); + + $response = $engine->handle(new ServerRequest('GET', '/hello')); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('world', (string) $response->getBody()); + } + + public function testHandleReturns500OnException(): void + { + $engine = $this->engine([ + new Callback('GET', '/boom', static function (): never { + throw new RuntimeException('fail'); + }), + ]); + + $response = $engine->handle(new ServerRequest('GET', '/boom')); + + self::assertSame(500, $response->getStatusCode()); + } + + public function testOnContextReadyCallbackIsInvoked(): void + { + $captured = null; + $provider = $this->createStub(RouteProvider::class); + $provider->method('getRoutes')->willReturn([ + new StaticValue('GET', '/test', 'ok'), + ]); + $provider->method('getBasePath')->willReturn(null); + + $engine = new DispatchEngine( + $provider, + $this->httpFactories->responses, + $this->httpFactories->streams, + static function ($context) use (&$captured): void { + $captured = $context; + }, + ); + + $context = $engine->dispatch(new ServerRequest('GET', '/test')); + + self::assertSame($context, $captured); + } + + public function testGlobalOptions404WhenNoRoutes(): void + { + $engine = $this->engine([]); + + $context = $engine->dispatch(new ServerRequest('OPTIONS', '*')); + + self::assertTrue($context->hasPreparedResponse()); + $response = $context->response(); + self::assertNotNull($response); + self::assertSame(404, $response->getStatusCode()); + } + + /** @param array $routes */ + private function engine(array $routes): DispatchEngine + { + $provider = $this->createStub(RouteProvider::class); + $provider->method('getRoutes')->willReturn($routes); + $provider->method('getBasePath')->willReturn(null); + + return new DispatchEngine( + $provider, + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + } +} diff --git a/tests/RouterTest.php b/tests/RouterTest.php index f1aaaf4..41e2f7c 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -15,11 +15,11 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use ReflectionMethod; -use ReflectionObject; use Respect\Rest\DispatchContext; use Respect\Rest\HttpFactories; use Respect\Rest\Routable; use Respect\Rest\Router; +use Respect\Rest\Routes\AbstractRoute; use Respect\Rest\Routines; use Respect\Rest\Test\Stubs\HeadFactoryController; use Respect\Rest\Test\Stubs\HeadTest as HeadTestStub; @@ -400,9 +400,9 @@ public function testPostRequestDoesNotOverrideMethodFromRequestBody(): void /** * @covers Respect\Rest\Router::dispatchContext * @covers Respect\Rest\Router::routeDispatch - * @covers Respect\Rest\Router::applyVirtualHost + * @covers Respect\Rest\Router::applyBasePath */ - public function testDeveloperCanSetUpAVirtualHostPathOnConstructor(): void + public function testDeveloperCanSetUpABasePathOnConstructor(): void { $router = self::newRouter('/store'); $router->get('/products', 'Some Products!'); @@ -413,7 +413,7 @@ public function testDeveloperCanSetUpAVirtualHostPathOnConstructor(): void self::assertSame( 'Some Products!', $response, - 'Router should match using the virtual host combined URI', + 'Router should match using the base path combined URI', ); } @@ -450,14 +450,13 @@ public function testReturns404WhenNoRouteMatches(): void public function testNamesRoutesUsingAttributes(): void { $router = self::newRouter(); - $router->allMembers = $router->any('/members', 'John, Carl'); + $allMembers = $router->any('/members', 'John, Carl'); + self::assertInstanceOf(AbstractRoute::class, $allMembers); + $r = $router->dispatch(new ServerRequest('GET', '/members'))->response(); self::assertNotNull($r); $response = (string) $r->getBody(); - $ref = new ReflectionObject($router); - self::assertTrue($ref->hasProperty('allMembers'), 'There must be an attribute set for that key'); - self::assertEquals( 'John, Carl', $response, @@ -466,18 +465,18 @@ public function testNamesRoutesUsingAttributes(): void } /** - * @covers Respect\Rest\DispatchEngine::applyVirtualHost + * @covers Respect\Rest\DispatchEngine::applyBasePath * @covers Respect\Rest\Router::appendRoute */ - public function testCreateUriShouldBeAwareOfVirtualHost(): void + public function testCreateUriShouldBeAwareOfBasePath(): void { $router = self::newRouter('/my/virtual/host'); $catsRoute = $router->any('/cats/*', 'Meow'); - $virtualHostUri = $catsRoute->createUri('mittens'); + $basePathUri = $catsRoute->createUri('mittens'); self::assertEquals( '/my/virtual/host/cats/mittens', - $virtualHostUri, - 'Virtual host should be prepended to the path on createUri()', + $basePathUri, + 'Base path should be prepended to the path on createUri()', ); } @@ -1483,7 +1482,7 @@ public function testContentTypeDoesNotLeakUnsupportedStatusIntoLaterMatch(): voi self::assertSame('alice', (string) $response->getBody()); } - public function testVirtualHost(): void + public function testBasePath(): void { $router = self::newRouter('/myvh'); $ok = false; @@ -1494,7 +1493,7 @@ public function testVirtualHost(): void self::assertTrue($ok); } - public function testVirtualHostEmpty(): void + public function testBasePathEmpty(): void { $router = self::newRouter('/myvh'); $ok = false; @@ -1505,7 +1504,7 @@ public function testVirtualHostEmpty(): void self::assertTrue($ok); } - public function testVirtualHostIndex(): void + public function testBasePathIndex(): void { $router = self::newRouter('/myvh/index.php'); $ok = false; @@ -2426,11 +2425,11 @@ private static function responseBody(DispatchContext $request): string return (string) $response->getBody(); } - private static function newRouter(string|null $virtualHost = null): Router + private static function newRouter(string|null $basePath = null): Router { $factory = new Psr17Factory(); - return new Router(new HttpFactories($factory, $factory), $virtualHost); + return new Router(new HttpFactories($factory, $factory), $basePath); } private static function newContextForRouter(Router $router, ServerRequestInterface $serverRequest): DispatchContext diff --git a/tests/RoutinePipelineTest.php b/tests/RoutinePipelineTest.php new file mode 100644 index 0000000..77d1c69 --- /dev/null +++ b/tests/RoutinePipelineTest.php @@ -0,0 +1,131 @@ +httpFactories = new HttpFactories($factory, $factory); + $this->pipeline = new RoutinePipeline(); + } + + public function testMatchesReturnsTrueWithNoWhenRoutines(): void + { + $route = new Callback('GET', '/test', static fn(): string => 'ok'); + $context = $this->newContext(); + $context->route = $route; + $params = []; + + self::assertTrue($this->pipeline->matches($context, $route, $params)); + } + + public function testMatchesReturnsFalseWhenWhenRoutineReturnsFalse(): void + { + $route = new Callback('GET', '/test', static fn(): string => 'ok'); + $route->appendRoutine(new When(static fn(): bool => false)); + $context = $this->newContext(); + $context->route = $route; + $params = []; + + self::assertFalse($this->pipeline->matches($context, $route, $params)); + } + + public function testMatchesReturnsTrueWhenWhenRoutineReturnsTrue(): void + { + $route = new Callback('GET', '/test', static fn(): string => 'ok'); + $route->appendRoutine(new When(static fn(): bool => true)); + $context = $this->newContext(); + $context->route = $route; + $params = []; + + self::assertTrue($this->pipeline->matches($context, $route, $params)); + } + + public function testProcessByReturnsNullWithNoByRoutines(): void + { + $route = new Callback('GET', '/test', static fn(): string => 'ok'); + $context = $this->newContext(); + $context->route = $route; + + self::assertNull($this->pipeline->processBy($context, $route)); + } + + public function testProcessByReturnsResponseWhenByReturnsResponse(): void + { + $route = new Callback('GET', '/test', static fn(): string => 'ok'); + $response = $this->httpFactories->responses->createResponse(401); + $route->appendRoutine(new By(static fn() => $response)); + $context = $this->newContext(); + $context->route = $route; + + $result = $this->pipeline->processBy($context, $route); + + self::assertInstanceOf(ResponseInterface::class, $result); + self::assertSame(401, $result->getStatusCode()); + } + + public function testProcessByReturnsFalseWhenByReturnsFalse(): void + { + $route = new Callback('GET', '/test', static fn(): string => 'ok'); + $route->appendRoutine(new By(static fn(): bool => false)); + $context = $this->newContext(); + $context->route = $route; + + self::assertFalse($this->pipeline->processBy($context, $route)); + } + + public function testProcessThroughChainsCallableResults(): void + { + $route = new Callback('GET', '/test', static fn(): string => 'ok'); + $route->appendRoutine(new Through(static fn() => static fn(string $v): string => $v . '-A')); + $route->appendRoutine(new Through(static fn() => static fn(string $v): string => $v . '-B')); + $context = $this->newContext(); + $context->route = $route; + + $result = $this->pipeline->processThrough($context, $route, 'start'); + + self::assertSame('start-A-B', $result); + } + + public function testProcessThroughSkipsNonCallableResults(): void + { + $route = new Callback('GET', '/test', static fn(): string => 'ok'); + $route->appendRoutine(new Through(static fn(): null => null)); + $context = $this->newContext(); + $context->route = $route; + + $result = $this->pipeline->processThrough($context, $route, 'unchanged'); + + self::assertSame('unchanged', $result); + } + + private function newContext(): DispatchContext + { + return new DispatchContext( + new ServerRequest('GET', '/test'), + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + } +} diff --git a/tests/Routines/AcceptCharsetTest.php b/tests/Routines/AcceptCharsetTest.php new file mode 100644 index 0000000..51fc863 --- /dev/null +++ b/tests/Routines/AcceptCharsetTest.php @@ -0,0 +1,89 @@ +httpFactories = new HttpFactories($factory, $factory); + $this->routine = new AcceptCharset([ + 'utf-8' => static fn(): string => 'utf8-content', + 'iso-8859-1' => static fn(): string => 'latin1-content', + ]); + } + + public function testExactCharsetMatch(): void + { + $params = []; + $context = $this->newContext('Accept-Charset', 'utf-8'); + + self::assertTrue($this->routine->when($context, $params)); + } + + public function testWildcardMatchesAnyCharset(): void + { + $params = []; + $context = $this->newContext('Accept-Charset', '*'); + + self::assertTrue($this->routine->when($context, $params)); + } + + public function testNotAcceptableOnMismatch(): void + { + $params = []; + $context = $this->newContext('Accept-Charset', 'windows-1252'); + + self::assertFalse($this->routine->when($context, $params)); + self::assertTrue($context->hasPreparedResponse()); + self::assertSame(406, $context->response()?->getStatusCode()); + } + + public function testMissingHeaderDefaultsToWildcard(): void + { + $params = []; + $context = new DispatchContext( + new ServerRequest('GET', '/'), + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + + self::assertTrue($this->routine->when($context, $params)); + } + + public function testQualityFactorOrdering(): void + { + $params = []; + $context = $this->newContext('Accept-Charset', 'iso-8859-1;q=0.5, utf-8;q=0.9'); + + self::assertTrue($this->routine->when($context, $params)); + + $callback = $this->routine->through($context, $params); + self::assertNotNull($callback); + self::assertSame('utf8-content', $callback('ignored')); + } + + private function newContext(string $header, string $value): DispatchContext + { + return new DispatchContext( + (new ServerRequest('GET', '/'))->withHeader($header, $value), + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + } +} diff --git a/tests/Routines/AcceptEncodingTest.php b/tests/Routines/AcceptEncodingTest.php new file mode 100644 index 0000000..d461686 --- /dev/null +++ b/tests/Routines/AcceptEncodingTest.php @@ -0,0 +1,77 @@ +httpFactories = new HttpFactories($factory, $factory); + $this->routine = new AcceptEncoding([ + 'gzip' => static fn(): string => 'gzipped', + 'identity' => static fn(): string => 'plain', + ]); + } + + public function testExactEncodingMatch(): void + { + $params = []; + $context = $this->newContext('Accept-Encoding', 'gzip'); + + self::assertTrue($this->routine->when($context, $params)); + } + + public function testWildcardMatchesAnyEncoding(): void + { + $params = []; + $context = $this->newContext('Accept-Encoding', '*'); + + self::assertTrue($this->routine->when($context, $params)); + } + + public function testNotAcceptableOnMismatch(): void + { + $params = []; + $context = $this->newContext('Accept-Encoding', 'br'); + + self::assertFalse($this->routine->when($context, $params)); + self::assertTrue($context->hasPreparedResponse()); + self::assertSame(406, $context->response()?->getStatusCode()); + } + + public function testMissingHeaderDefaultsToWildcard(): void + { + $params = []; + $context = new DispatchContext( + new ServerRequest('GET', '/'), + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + + self::assertTrue($this->routine->when($context, $params)); + } + + private function newContext(string $header, string $value): DispatchContext + { + return new DispatchContext( + (new ServerRequest('GET', '/'))->withHeader($header, $value), + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + } +} diff --git a/tests/Routines/AcceptLanguageTest.php b/tests/Routines/AcceptLanguageTest.php new file mode 100644 index 0000000..587ada3 --- /dev/null +++ b/tests/Routines/AcceptLanguageTest.php @@ -0,0 +1,104 @@ +httpFactories = new HttpFactories($factory, $factory); + $this->routine = new AcceptLanguage([ + 'en' => static fn(): string => 'english', + 'en-US' => static fn(): string => 'american english', + 'pt-BR' => static fn(): string => 'brazilian portuguese', + ]); + } + + public function testExactLanguageMatch(): void + { + $params = []; + $context = $this->newContext('Accept-Language', 'en-US'); + + self::assertTrue($this->routine->when($context, $params)); + } + + public function testPrefixMatchEnToEnUS(): void + { + $routine = new AcceptLanguage([ + 'en-US' => static fn(): string => 'american english', + ]); + $params = []; + $context = $this->newContext('Accept-Language', 'en'); + + self::assertTrue($routine->when($context, $params)); + } + + public function testNotAcceptableOnMismatch(): void + { + $params = []; + $context = $this->newContext('Accept-Language', 'fr'); + + self::assertFalse($this->routine->when($context, $params)); + self::assertTrue($context->hasPreparedResponse()); + self::assertSame(406, $context->response()?->getStatusCode()); + } + + public function testXPrefixIsStripped(): void + { + $routine = new AcceptLanguage([ + 'klingon' => static fn(): string => 'tlhIngan', + ]); + $params = []; + $context = $this->newContext('Accept-Language', 'x-klingon'); + + self::assertTrue($routine->when($context, $params)); + } + + public function testMissingHeaderDefaultsToWildcard(): void + { + $params = []; + $context = new DispatchContext( + new ServerRequest('GET', '/'), + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + + self::assertTrue($this->routine->when($context, $params)); + } + + public function testQualityFactorOrdering(): void + { + $params = []; + $context = $this->newContext('Accept-Language', 'en;q=0.5, pt-BR;q=0.9'); + + self::assertTrue($this->routine->when($context, $params)); + + $callback = $this->routine->through($context, $params); + self::assertNotNull($callback); + self::assertSame('brazilian portuguese', $callback('ignored')); + } + + private function newContext(string $header, string $value): DispatchContext + { + return new DispatchContext( + (new ServerRequest('GET', '/'))->withHeader($header, $value), + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + } +} diff --git a/tests/Routines/AcceptTest.php b/tests/Routines/AcceptTest.php new file mode 100644 index 0000000..ea9c415 --- /dev/null +++ b/tests/Routines/AcceptTest.php @@ -0,0 +1,151 @@ +httpFactories = new HttpFactories($factory, $factory); + $this->accept = new Accept([ + 'text/html' => static fn(): string => 'html', + 'application/json' => static fn(): string => 'json', + 'text/plain' => static fn(): string => 'plain', + ]); + } + + public function testExactMimeMatch(): void + { + $params = []; + $context = $this->newContext('Accept', 'application/json'); + + self::assertTrue($this->accept->when($context, $params)); + self::assertSame('application/json', $context->response()?->getHeaderLine('Content-Type')); + } + + public function testWildcardMatchesFirstProvided(): void + { + $params = []; + $context = $this->newContext('Accept', '*/*'); + + self::assertTrue($this->accept->when($context, $params)); + } + + public function testSubtypeWildcard(): void + { + $params = []; + $context = $this->newContext('Accept', 'text/*'); + + self::assertTrue($this->accept->when($context, $params)); + } + + public function testQualityFactorOrdering(): void + { + $params = []; + $context = $this->newContext('Accept', 'text/html;q=0.5, application/json;q=0.9'); + + self::assertTrue($this->accept->when($context, $params)); + + $callback = $this->accept->through($context, $params); + self::assertNotNull($callback); + self::assertSame('json', $callback('ignored')); + } + + public function testNotAcceptableWhenNoMatch(): void + { + $params = []; + $context = $this->newContext('Accept', 'image/png'); + + self::assertFalse($this->accept->when($context, $params)); + self::assertTrue($context->hasPreparedResponse()); + self::assertSame(406, $context->response()?->getStatusCode()); + } + + public function testMissingAcceptHeaderDefaultsToWildcard(): void + { + $params = []; + $context = new DispatchContext( + new ServerRequest('GET', '/'), + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + + self::assertTrue($this->accept->when($context, $params)); + } + + public function testContentLocationDefaultHeaderSetOnNegotiation(): void + { + $params = []; + $context = $this->newContext('Accept', 'text/html'); + + self::assertTrue($this->accept->when($context, $params)); + self::assertArrayHasKey('Content-Location', $context->defaultResponseHeaders); + } + + public function testThroughReturnsNegotiatedCallback(): void + { + $params = []; + $context = $this->newContext('Accept', 'text/html'); + + $this->accept->when($context, $params); + $callback = $this->accept->through($context, $params); + + self::assertNotNull($callback); + self::assertSame('html', $callback('ignored')); + } + + public function testThroughReturnsNullWhenNoNegotiation(): void + { + $params = []; + $context = $this->newContext('Accept', 'image/png'); + + $this->accept->when($context, $params); + $callback = $this->accept->through($context, $params); + + self::assertNull($callback); + } + + public function testNonHttpPrefixedHeaderIsUsedDirectly(): void + { + $routine = new class ([ + 'gzip' => static fn(): string => 'compressed', + ]) extends AbstractAccept { + public const string ACCEPT_HEADER = 'X-Custom-Accept'; + }; + + $params = []; + $context = new DispatchContext( + (new ServerRequest('GET', '/'))->withHeader('X-Custom-Accept', 'gzip'), + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + + self::assertTrue($routine->when($context, $params)); + } + + private function newContext(string $header, string $value): DispatchContext + { + return new DispatchContext( + (new ServerRequest('GET', '/'))->withHeader($header, $value), + $this->httpFactories->responses, + $this->httpFactories->streams, + ); + } +}