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, + ); + } +}