diff --git a/src/DispatchEngine.php b/src/DispatchEngine.php new file mode 100644 index 0000000..2510fd7 --- /dev/null +++ b/src/DispatchEngine.php @@ -0,0 +1,334 @@ +responseFactory = $this->router->responseFactory; + + return $this->dispatchContext($context); + } + + public function dispatchContext(DispatchContext $context): DispatchContext + { + $this->router->isAutoDispatched = false; + $this->router->context = $context; + $context->responseFactory ??= $this->router->responseFactory; + + if (!$this->isRoutelessDispatch($context) && $context->route === null) { + $this->routeDispatch($context); + } + + return $context; + } + + public function run(DispatchContext $context): ResponseInterface|null + { + return $this->dispatchContext($context)->response(); + } + + /** + * @param array $routes + * + * @return array + */ + private function getAllowedMethods(array $routes): array + { + $allowedMethods = []; + + foreach ($routes as $route) { + foreach ($route->getAllowedMethods() as $method) { + $allowedMethods[$method] = true; + } + } + + if ($allowedMethods === []) { + return []; + } + + $allowedMethods['OPTIONS'] = true; + + return array_keys($allowedMethods); + } + + private function hasDispatchedOverriddenMethod(DispatchContext $context): bool + { + if (!$this->router->methodOverriding || $context->method() !== 'POST') { + return false; + } + + $parsedBody = $context->request->getParsedBody(); + $queryParams = $context->request->getQueryParams(); + + return (is_array($parsedBody) && isset($parsedBody['_method'])) || isset($queryParams['_method']); + } + + private function isDispatchedToGlobalOptionsMethod(DispatchContext $context): bool + { + return $context->method() === 'OPTIONS' && $context->path() === '*'; + } + + private function isRoutelessDispatch(DispatchContext $context): bool + { + if ($this->hasDispatchedOverriddenMethod($context)) { + $parsedBody = $context->request->getParsedBody(); + $queryParams = $context->request->getQueryParams(); + $bodyMethod = is_array($parsedBody) ? ($parsedBody['_method'] ?? null) : null; + $overrideMethod = $bodyMethod ?? $queryParams['_method'] ?? null; + if ($overrideMethod !== null) { + $context->overrideMethod((string) $overrideMethod); + } + } + + if (!$this->isDispatchedToGlobalOptionsMethod($context)) { + return false; + } + + $allowedMethods = $this->getAllowedMethods($this->router->getRoutes()); + + if ($allowedMethods) { + $context->prepareResponse( + 204, + ['Allow' => $this->getAllowHeaderValue($allowedMethods)], + ); + } else { + $context->prepareResponse(404); + } + + return true; + } + + private function routeDispatch(DispatchContext $context): void + { + $this->applyVirtualHost($context); + + $matchedByPath = $this->getMatchedRoutesByPath($context); + /** @var array $matchedArray */ + $matchedArray = iterator_to_array($matchedByPath); + $allowedMethods = $this->getAllowedMethods($matchedArray); + + if ($context->method() === 'OPTIONS' && $allowedMethods) { + $this->handleOptionsRequest($context, $allowedMethods, $matchedByPath); + } elseif (count($matchedByPath) === 0) { + $context->prepareResponse(404); + } else { + $this->resolveRouteMatch( + $context, + $this->routineMatch($context, $matchedByPath), + $allowedMethods, + ); + } + } + + private function applyVirtualHost(DispatchContext $context): void + { + $virtualHost = $this->router->getVirtualHost(); + if ($virtualHost === null) { + return; + } + + $context->setPath( + preg_replace( + '#^' . preg_quote($virtualHost) . '#', + '', + $context->path(), + ) ?? $context->path(), + ); + } + + /** @param array $params */ + private function configureContext( + DispatchContext $context, + AbstractRoute $route, + array $params = [], + ): DispatchContext { + $context->route = $route; + $context->params = $params; + + return $context; + } + + /** @return SplObjectStorage> */ + private function getMatchedRoutesByPath(DispatchContext $context): SplObjectStorage + { + /** @var SplObjectStorage> $matched */ + $matched = new SplObjectStorage(); + + foreach ($this->router->getRoutes() as $route) { + $params = []; + if (!$this->matchRoute($context, $route, $params)) { + continue; + } + + $matched[$route] = $params; + } + + return $matched; + } + + /** @param array $allowedMethods */ + private function getAllowHeaderValue(array $allowedMethods): string + { + return implode(', ', $allowedMethods); + } + + /** + * @param array $allowedMethods + * @param SplObjectStorage> $matchedByPath + */ + private function handleOptionsRequest( + DispatchContext $context, + array $allowedMethods, + SplObjectStorage $matchedByPath, + ): void { + if ($this->hasExplicitOptionsRoute($matchedByPath)) { + $matchedContext = $this->routineMatch($context, $matchedByPath); + if ($matchedContext instanceof DispatchContext) { + $matchedContext->setResponseHeader('Allow', $this->getAllowHeaderValue($allowedMethods)); + } + + $this->resolveRouteMatch($context, $matchedContext, $allowedMethods); + + return; + } + + $context->prepareResponse( + 204, + ['Allow' => $this->getAllowHeaderValue($allowedMethods)], + ); + } + + /** @param array $allowedMethods */ + private function resolveRouteMatch( + DispatchContext $context, + DispatchContext|bool|null $matchedContext, + array $allowedMethods = [], + ): void { + if ($matchedContext instanceof DispatchContext || $context->hasPreparedResponse()) { + return; + } + + if ($matchedContext === false) { + $context->prepareResponse(400); + + return; + } + + if ($allowedMethods === []) { + return; + } + + $context->prepareResponse( + 405, + ['Allow' => $this->getAllowHeaderValue($allowedMethods)], + ); + } + + private function getMethodMatchRank(DispatchContext $context, AbstractRoute $route): int|null + { + if (stripos($context->method(), '__') === 0) { + return null; + } + + return $route->getMethodMatchRank($context->method()); + } + + /** @param SplObjectStorage> $matchedByPath */ + private function hasExplicitOptionsRoute(SplObjectStorage $matchedByPath): bool + { + foreach ($matchedByPath as $route) { + if ($route->method === 'OPTIONS') { + return true; + } + } + + return false; + } + + /** @param array $params */ + private function matchRoute( + DispatchContext $context, + AbstractRoute $route, + array &$params = [], + ): bool { + if (!$route->match($context, $params)) { + return false; + } + + $context->route = $route; + + return true; + } + + /** @param SplObjectStorage> $matchedByPath */ + private function routineMatch( + DispatchContext $context, + SplObjectStorage $matchedByPath, + ): DispatchContext|bool|null { + $badRequest = false; + + foreach ([0, 1, 2] as $rank) { + foreach ($matchedByPath as $route) { + if ($this->getMethodMatchRank($context, $route) !== $rank) { + continue; + } + + /** @var array $tempParams */ + $tempParams = $matchedByPath[$route]; + $context->clearResponseMeta(); + $context->route = $route; + if ($route->matchRoutines($context, $tempParams)) { + return $this->configureContext( + $context, + $route, + self::cleanUpParams($tempParams), + ); + } + + $badRequest = true; + } + } + + return $badRequest ? false : null; + } + + /** + * @param array $params + * + * @return array + */ + private static function cleanUpParams(array $params): array + { + return array_values( + array_filter( + $params, + static fn(mixed $param): bool => $param !== '', + ), + ); + } +} diff --git a/src/Router.php b/src/Router.php index 4f3bfae..f4fb216 100644 --- a/src/Router.php +++ b/src/Router.php @@ -10,26 +10,16 @@ use Psr\Http\Message\ServerRequestInterface; use ReflectionClass; use Respect\Rest\Routes\AbstractRoute; -use SplObjectStorage; use Throwable; -use function array_filter; -use function array_keys; use function array_pop; -use function array_values; -use function assert; use function class_exists; use function count; -use function implode; use function interface_exists; use function is_array; use function is_callable; use function is_string; -use function iterator_to_array; use function preg_match; -use function preg_quote; -use function preg_replace; -use function stripos; use function substr_count; use function trigger_error; use function usort; @@ -68,6 +58,8 @@ final class Router /** Used by tests for named route attributes */ public mixed $allMembers = null; + private DispatchEngine|null $dispatchEngine = null; + public function __construct( public ResponseFactoryInterface $responseFactory, protected string|null $virtualHost = null, @@ -141,21 +133,12 @@ public function classRoute(string $method, string $path, string $class, array $a public function dispatch(ServerRequestInterface $serverRequest): DispatchContext { - $context = new DispatchContext($serverRequest); - $context->responseFactory = $this->responseFactory; - - return $this->dispatchContext($context); + return $this->dispatchEngine()->dispatch($serverRequest); } public function dispatchContext(DispatchContext $context): DispatchContext { - $context->responseFactory ??= $this->responseFactory; - - if (!$this->isRoutelessDispatch($context) && $context->route === null) { - $this->routeDispatch(); - } - - return $context; + return $this->dispatchEngine()->dispatchContext($context); } public function exceptionRoute(string $className, callable $callback): Routes\Exception @@ -182,43 +165,6 @@ public function factoryRoute(string $method, string $path, string $className, ca return $route; } - /** - * @param array $routes - * - * @return array - */ - public function getAllowedMethods(array $routes): array - { - $allowedMethods = []; - - foreach ($routes as $route) { - foreach ($route->getAllowedMethods() as $method) { - $allowedMethods[$method] = true; - } - } - - if ($allowedMethods === []) { - return []; - } - - $allowedMethods['OPTIONS'] = true; - - return array_keys($allowedMethods); - } - - public function hasDispatchedOverridenMethod(): bool - { - if (!$this->context || !$this->methodOverriding || $this->context->method() !== 'POST') { - return false; - } - - // Read _method from the PSR-7 parsed body or query params - $parsedBody = $this->context->request->getParsedBody(); - $queryParams = $this->context->request->getQueryParams(); - - return (is_array($parsedBody) && isset($parsedBody['_method'])) || isset($queryParams['_method']); - } - public function instanceRoute(string $method, string $path, object $instance): Routes\Instance { $route = new Routes\Instance($method, $path, $instance); @@ -227,74 +173,9 @@ public function instanceRoute(string $method, string $path, object $instance): R return $route; } - public function isDispatchedToGlobalOptionsMethod(): bool - { - return $this->context !== null - && $this->context->method() === 'OPTIONS' - && $this->context->path() === '*'; - } - - public function isRoutelessDispatch(DispatchContext $context): bool - { - $this->isAutoDispatched = false; - $this->context = $context; - - if ($this->hasDispatchedOverridenMethod()) { - $parsedBody = $context->request->getParsedBody(); - $queryParams = $context->request->getQueryParams(); - $bodyMethod = is_array($parsedBody) ? ($parsedBody['_method'] ?? null) : null; - $overrideMethod = $bodyMethod ?? $queryParams['_method'] ?? null; - if ($overrideMethod !== null) { - $context->overrideMethod((string) $overrideMethod); - } - } - - if ($this->isDispatchedToGlobalOptionsMethod()) { - $allowedMethods = $this->getAllowedMethods($this->routes); - - if ($allowedMethods) { - $context->prepareResponse( - 204, - ['Allow' => $this->getAllowHeaderValue($allowedMethods)], - ); - } else { - $context->prepareResponse(404); - } - - return true; - } - - return false; - } - - public function routeDispatch(): void - { - assert($this->context !== null); - $context = $this->context; - $this->applyVirtualHost(); - - $matchedByPath = $this->getMatchedRoutesByPath(); - /** @var array $matchedArray */ - $matchedArray = iterator_to_array($matchedByPath); - $allowedMethods = $this->getAllowedMethods($matchedArray); - - if ($context->method() === 'OPTIONS' && $allowedMethods) { - $this->handleOptionsRequest($allowedMethods, $matchedByPath); - } elseif (count($matchedByPath) === 0) { - $context->prepareResponse(404); - } else { - $this->resolveRouteMatch( - $this->routineMatch($matchedByPath), - $allowedMethods, - ); - } - } - public function run(DispatchContext $context): ResponseInterface|null { - $route = $this->dispatchContext($context); - - return $route->response(); + return $this->dispatchEngine()->run($context); } public function staticRoute(string $method, string $path, mixed $staticValue): Routes\StaticValue @@ -305,194 +186,25 @@ public function staticRoute(string $method, string $path, mixed $staticValue): R return $route; } - public static function compareOcurrences(string $patternA, string $patternB, string $sub): bool - { - return substr_count($patternA, $sub) < substr_count($patternB, $sub); - } - - /** - * @param array $params - * - * @return array - */ - protected static function cleanUpParams(array $params): array - { - return array_values( - array_filter( - $params, - static fn(mixed $param): bool => $param !== '', - ), - ); - } - - protected function applyVirtualHost(): void - { - assert($this->context !== null); - if (!$this->virtualHost) { - return; - } - - $this->context->setPath( - preg_replace( - '#^' . preg_quote($this->virtualHost) . '#', - '', - $this->context->path(), - ) ?? $this->context->path(), - ); - } - - /** @param array $params */ - protected function configureContext( - DispatchContext $context, - AbstractRoute $route, - array $params = [], - ): DispatchContext { - $context->route = $route; - $context->params = $params; - - return $context; - } - - /** @return SplObjectStorage> */ - protected function getMatchedRoutesByPath(): SplObjectStorage + /** @return array */ + public function getRoutes(): array { - assert($this->context !== null); - /** @var SplObjectStorage> $matched */ - $matched = new SplObjectStorage(); - - foreach ($this->routes as $route) { - $params = []; - if (!$this->matchRoute($this->context, $route, $params)) { - continue; - } - - $matched[$route] = $params; - } - - return $matched; + return $this->routes; } - /** @param array $allowedMethods */ - protected function getAllowHeaderValue(array $allowedMethods): string + public function getVirtualHost(): string|null { - return implode(', ', $allowedMethods); + return $this->virtualHost; } - /** - * @param array $allowedMethods - * @param SplObjectStorage> $matchedByPath - */ - protected function handleOptionsRequest(array $allowedMethods, SplObjectStorage $matchedByPath): void + public function dispatchEngine(): DispatchEngine { - if ($this->hasExplicitOptionsRoute($matchedByPath)) { - $matchedContext = $this->routineMatch($matchedByPath); - if ($matchedContext instanceof DispatchContext) { - $matchedContext->setResponseHeader('Allow', $this->getAllowHeaderValue($allowedMethods)); - } - - $this->resolveRouteMatch($matchedContext, $allowedMethods); - - return; - } - - assert($this->context !== null); - $this->context->prepareResponse( - 204, - ['Allow' => $this->getAllowHeaderValue($allowedMethods)], - ); + return $this->dispatchEngine ??= new DispatchEngine($this); } - /** @param array $allowedMethods */ - protected function resolveRouteMatch(DispatchContext|bool|null $matchedContext, array $allowedMethods = []): void - { - assert($this->context !== null); - if ($matchedContext instanceof DispatchContext || $this->context->hasPreparedResponse()) { - return; - } - - if ($matchedContext === false) { - $this->context->prepareResponse(400); - - return; - } - - if ($allowedMethods === []) { - return; - } - - $this->context->prepareResponse( - 405, - ['Allow' => $this->getAllowHeaderValue($allowedMethods)], - ); - } - - protected function getMethodMatchRank(AbstractRoute $route): int|null - { - assert($this->context !== null); - - if (stripos($this->context->method(), '__') === 0) { - return null; - } - - return $route->getMethodMatchRank($this->context->method()); - } - - /** @param SplObjectStorage> $matchedByPath */ - protected function hasExplicitOptionsRoute(SplObjectStorage $matchedByPath): bool - { - foreach ($matchedByPath as $route) { - if ($route->method === 'OPTIONS') { - return true; - } - } - - return false; - } - - /** @param array $params */ - protected function matchRoute( - DispatchContext $context, - AbstractRoute $route, - array &$params = [], - ): bool { - if ($route->match($context, $params)) { - $context->route = $route; - - return true; - } - - return false; - } - - /** @param SplObjectStorage> $matchedByPath */ - protected function routineMatch(SplObjectStorage $matchedByPath): DispatchContext|bool|null + public static function compareOcurrences(string $patternA, string $patternB, string $sub): bool { - assert($this->context !== null); - $badRequest = false; - - foreach ([0, 1, 2] as $rank) { - foreach ($matchedByPath as $route) { - if ($this->getMethodMatchRank($route) !== $rank) { - continue; - } - - /** @var array $tempParams */ - $tempParams = $matchedByPath[$route]; - $this->context->clearResponseMeta(); - $this->context->route = $route; - if ($route->matchRoutines($this->context, $tempParams)) { - return $this->configureContext( - $this->context, - $route, - static::cleanUpParams($tempParams), - ); - } - - $badRequest = true; - } - } - - return $badRequest ? false : null; + return substr_count($patternA, $sub) < substr_count($patternB, $sub); } protected function sortRoutesByComplexity(): void diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 05c4e00..ba506ec 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -328,9 +328,10 @@ public function testMagicConstructorCanRouteToFactoriesThatReturnInstancesOfACla /** * @covers Respect\Rest\Router::dispatchContext - * @covers Respect\Rest\Router::isRoutelessDispatch - * @covers Respect\Rest\Router::isDispatchedToGlobalOptionsMethod - * @covers Respect\Rest\Router::getAllowedMethods + * @covers Respect\Rest\DispatchEngine::dispatchContext + * @covers Respect\Rest\DispatchEngine::isRoutelessDispatch + * @covers Respect\Rest\DispatchEngine::isDispatchedToGlobalOptionsMethod + * @covers Respect\Rest\DispatchEngine::getAllowedMethods * @runInSeparateProcess */ public function testCanRespondToGlobalOptionsMethodAutomatically(): void @@ -352,8 +353,9 @@ public function testCanRespondToGlobalOptionsMethodAutomatically(): void /** * @covers Respect\Rest\Router::dispatchContext - * @covers Respect\Rest\Router::isRoutelessDispatch - * @covers Respect\Rest\Router::isDispatchedToGlobalOptionsMethod + * @covers Respect\Rest\DispatchEngine::dispatchContext + * @covers Respect\Rest\DispatchEngine::isRoutelessDispatch + * @covers Respect\Rest\DispatchEngine::isDispatchedToGlobalOptionsMethod */ public function testGlobalOptionsMethodWithoutRoutesReturns404(): void { @@ -367,8 +369,9 @@ public function testGlobalOptionsMethodWithoutRoutesReturns404(): void /** * @covers Respect\Rest\Router::dispatchContext - * @covers Respect\Rest\Router::isRoutelessDispatch - * @covers Respect\Rest\Router::hasDispatchedOverridenMethod + * @covers Respect\Rest\DispatchEngine::dispatchContext + * @covers Respect\Rest\DispatchEngine::isRoutelessDispatch + * @covers Respect\Rest\DispatchEngine::hasDispatchedOverriddenMethod */ public function testDeveloperCanOverridePostMethodWithQueryStringParameter(): Router { @@ -457,7 +460,8 @@ public function testRouterDoesNotAutoDispatchAfterManualDispatch(): void /** * @covers Respect\Rest\Router::dispatch - * @covers Respect\Rest\Router::routeDispatch + * @covers Respect\Rest\DispatchEngine::dispatch + * @covers Respect\Rest\DispatchEngine::routeDispatch */ public function testReturns404WhenNoRoutesExist(): void { @@ -470,7 +474,8 @@ public function testReturns404WhenNoRoutesExist(): void /** * @covers Respect\Rest\Router::dispatch - * @covers Respect\Rest\Router::routeDispatch + * @covers Respect\Rest\DispatchEngine::dispatch + * @covers Respect\Rest\DispatchEngine::routeDispatch */ public function testReturns404WhenNoRouteMatches(): void { @@ -502,7 +507,7 @@ public function testNamesRoutesUsingAttributes(): void } /** - * @covers Respect\Rest\Router::applyVirtualHost + * @covers Respect\Rest\DispatchEngine::applyVirtualHost * @covers Respect\Rest\Router::appendRoute */ public function testCreateUriShouldBeAwareOfVirtualHost(): void @@ -518,7 +523,7 @@ public function testCreateUriShouldBeAwareOfVirtualHost(): void } /** - * @covers Respect\Rest\Router::handleOptionsRequest + * @covers Respect\Rest\DispatchEngine::handleOptionsRequest * @runInSeparateProcess */ public function testOptionsRequestShouldNotCallOtherHandlers(): void @@ -538,7 +543,7 @@ public function testOptionsRequestShouldNotCallOtherHandlers(): void ); } - /** @covers Respect\Rest\Router::handleOptionsRequest */ + /** @covers Respect\Rest\DispatchEngine::handleOptionsRequest */ public function testOptionsRequestShouldBeDispatchedToCorrectOptionsHandler(): void { $router = new Router(new Psr17Factory()); @@ -560,7 +565,7 @@ public function testOptionsRequestShouldBeDispatchedToCorrectOptionsHandler(): v ); } - /** @covers Respect\Rest\Router::handleOptionsRequest */ + /** @covers Respect\Rest\DispatchEngine::handleOptionsRequest */ public function testOptionsRequestShouldReturnBadRequestWhenExplicitOptionsRouteFailsRoutines(): void { $router = new Router(new Psr17Factory()); @@ -579,15 +584,15 @@ public function testOptionsRequestShouldReturnBadRequestWhenExplicitOptionsRoute self::assertSame('', (string) $response->getBody()); } - /** @covers Respect\Rest\Router::handleOptionsRequest */ + /** @covers Respect\Rest\DispatchEngine::handleOptionsRequest */ public function testOptionsHandlerShouldMaterializeRoutelessResponseWhenNoExplicitRouteSurvives(): void { $router = new Router(new Psr17Factory()); $router->context = new DispatchContext(new ServerRequest('OPTIONS', '/asian')); $router->context->responseFactory = new Psr17Factory(); - $handleOptionsRequest = new ReflectionMethod($router, 'handleOptionsRequest'); - $handleOptionsRequest->invoke($router, ['OPTIONS'], new SplObjectStorage()); + $handleOptionsRequest = new ReflectionMethod($router->dispatchEngine(), 'handleOptionsRequest'); + $handleOptionsRequest->invoke($router->dispatchEngine(), $router->context, ['OPTIONS'], new SplObjectStorage()); $response = $router->context->response();