diff --git a/composer.json b/composer.json index c07d52e..fc1f51f 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,7 @@ "php": "^7.0", "firehed/common": "^1.0", "firehed/input": "^2.0", + "nikic/fast-route": "^1.3", "psr/container": "^1.0", "psr/http-message": "^1.0", "psr/http-server-handler": "^1.0", diff --git a/src/Console/CompileAll.php b/src/Console/CompileAll.php index 0e9e977..a279e4a 100644 --- a/src/Console/CompileAll.php +++ b/src/Console/CompileAll.php @@ -6,6 +6,7 @@ use Firehed\API\Config; use Firehed\API\Dispatcher; use Firehed\API\Interfaces\EndpointInterface; +use Firehed\API\Router; use Firehed\Input\Interfaces\ParserInterface; use Firehed\Common\ClassMapGenerator; use Symfony\Component\Console\Command\Command; @@ -38,18 +39,22 @@ protected function execute(InputInterface $input, OutputInterface $output) $logger->debug('Current directory: {cwd}', ['cwd' => getcwd()]); $logger->debug('Building classmap'); // Build out the endpoint map - (new ClassMapGenerator()) + $endpoints = (new ClassMapGenerator()) ->setPath(getcwd().'/'.$this->config->get('source')) ->setInterface(EndpointInterface::class) ->addCategory('getMethod') ->setMethod('getURI') ->setNamespace($this->config->get(Config::KEY_NAMESPACE)) - ->setOutputFile(Dispatcher::ENDPOINT_LIST) + // ->setOutputFile(Dispatcher::ENDPOINT_LIST) ->generate(); + unset($endpoints['@gener'.'ated']); + + $router = new Router(); + $router->setData($endpoints); + $router->writeCache(); $output->writeln(sprintf( - 'Wrote endpoint map to %s', - Dispatcher::ENDPOINT_LIST + 'Wrote endpoint map' )); $logger->debug('Building parser map'); diff --git a/src/Dispatcher.php b/src/Dispatcher.php index 2372272..55726bc 100644 --- a/src/Dispatcher.php +++ b/src/Dispatcher.php @@ -6,6 +6,7 @@ use BadMethodCallException; use DomainException; +use FastRoute; use Firehed\API\Errors\HandlerInterface; use Firehed\API\Interfaces\HandlesOwnErrorsInterface; use Firehed\Common\ClassMapper; @@ -21,16 +22,16 @@ class Dispatcher implements RequestHandlerInterface { - const ENDPOINT_LIST = '__endpoint_list__.php'; const PARSER_LIST = '__parser_list__.php'; private $authenticationProvider; private $authorizationProvider; private $container; - private $endpointList = self::ENDPOINT_LIST; + private $endpointList; private $error_handler; private $parserList = self::PARSER_LIST; private $psrMiddleware = []; + /** @var ServerRequestInterface */ private $request; private $uri_data; @@ -148,10 +149,10 @@ public function setParserList($parserList): self * * @internal Overrides the standard endpoint list. Used primarily for unit * testing. - * @param array|string $endpointList The endpoint list or its path + * @param array $endpointList The endpoint list * @return self */ - public function setEndpointList($endpointList): self + public function setEndpointList(array $endpointList): self { $this->endpointList = $endpointList; return $this; @@ -278,20 +279,16 @@ private function parseInput(ServerRequestInterface $request): ParsedInput */ private function getEndpoint(ServerRequestInterface $request): Interfaces\EndpointInterface { - list($class, $uri_data) = (new ClassMapper($this->endpointList)) - ->filter(strtoupper($request->getMethod())) - ->search($request->getUri()->getPath()); - if (!$class) { - throw new OutOfBoundsException('Endpoint not found', 404); + $router = new Router(); + if ($this->endpointList !== null) { + $router->setData($this->endpointList); } - // Conceivably, we could use reflection to ensure the found class - // adheres to the interface; in practice, the built route is already - // doing the filtering so this should be redundant. - $this->setUriData(new ParsedInput($uri_data)); - if ($this->container && $this->container->has($class)) { - return $this->container->get($class); + list($fqcn, $uriData) = $router->route($request); + $this->setUriData($uriData); + if ($this->container && $this->container->has($fqcn)) { + return $this->container->get($fqcn); } - return new $class; + return new $fqcn; } private function setUriData(ParsedInput $uri_data): self diff --git a/src/Router.php b/src/Router.php new file mode 100644 index 0000000..c84c3d8 --- /dev/null +++ b/src/Router.php @@ -0,0 +1,91 @@ + [ + * 'SOME REGEX' => 'FQCN', + * ], + * ] + * @param array $routeMap the route map + */ + public function setData(array $routeMap) + { + $this->routeMap = $routeMap; + } + + public function writeCache() + { + $rd = $this->getRouteData(); + file_put_contents(self::CACHE_FILE, sprintf('getRouteData()); + $info = $dispatcher->dispatch( + $request->getMethod(), + $request->getUri()->getPath() + ); + switch ($info[0]) { + case FastRoute\Dispatcher::NOT_FOUND: + throw new OutOfBoundsException('Endpoint not found', 404); + case FastRoute\Dispatcher::METHOD_NOT_ALLOWED: + // allowed methods = $info[1] + throw new OutOfBoundsException('Method not allowed', 405); + case FastRoute\Dispatcher::FOUND: + return [$info[1], new ParsedInput($info[2])]; + default: + // @codeCoverageIgnoreStart + throw new \DomainException('Unexpected Dispatcher route info'); + // @codeCoverageIgnoreEnd + } + } + + private function getRouteData(): array + { + if ($this->routeMap === null) { + if (!file_exists(self::CACHE_FILE)) { + throw new \Exception('Route file missing. Run bin/app compile:all'); + } + return include self::CACHE_FILE; + } + $rc = new FastRoute\RouteCollector( + new FastRoute\RouteParser\Std(), + new FastRoute\DataGenerator\GroupCountBased() + ); + + // Regex-parsing regex: grab named captures + $pattern = '#\(\?P?<(\w+)>(.*)\)#'; + foreach ($this->routeMap as $method => $routes) { + foreach ($routes as $regex => $fqcn) { + $frUri = preg_replace($pattern, '{\1:\2}', $regex); + $stripped = strtr($frUri, ['(' => '', ')' => '']); + // echo "$regex => $frUri => $stripped\n"; + $rc ->addRoute($method, $stripped, $fqcn); + } + } + + return $rc->getData(); + } +} diff --git a/tests/DispatcherTest.php b/tests/DispatcherTest.php index 99f15f5..585a7cd 100644 --- a/tests/DispatcherTest.php +++ b/tests/DispatcherTest.php @@ -360,6 +360,23 @@ public function testUnsupportedContentTypeCanReachErrorHandlers() } } + /** @covers ::dispatch */ + public function testMethodNotAllowed() + { + $req = $this->getMockRequestWithUriPath('/user/1', 'PUT'); + try { + $response = (new Dispatcher()) + ->setEndpointList($this->getEndpointListForFixture()) + ->setParserList($this->getDefaultParserList()) + ->setRequest($req) + ->dispatch(); + $this->fail('An exception should have been thrown'); + } catch (Throwable $e) { + $this->assertInstanceOf(RuntimeException::class, $e); + $this->assertSame(405, $e->getCode()); + } + } + /** * @covers ::dispatch */