From cfd36831c701f3a764dabdf51b015d18aa61b7b6 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 16 Feb 2023 17:38:13 +0000 Subject: [PATCH] fixes and tests router --- README.md | 49 ++- bin/membrane-router | 13 + src/Console/Commands/CacheOpenAPI.php | 41 ++- src/Reader/OpenAPIFileReader.php | 6 +- tests/Console/Commands/CacheOpenAPITest.php | 354 +++++++++++--------- 5 files changed, 278 insertions(+), 185 deletions(-) create mode 100755 bin/membrane-router diff --git a/README.md b/README.md index c8cf573..9cee9cb 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,54 @@ and [Nicolas Grekas](https://nicolas-grekas.medium.com/making-symfonys-router-77 ## Routing Priorities -- [Static urls MUST be prioritized over dynamic urls](https://spec.openapis.org/oas/v3.1.0#paths-object) -- Longer urls are prioritized over shorter urls -- Hosted servers will be prioritized over hostless servers +- [Static urls MUST be prioritized over dynamic urls](https://spec.openapis.org/oas/v3.1.0#paths-object). +- Longer urls are prioritized over shorter urls. +- Hosted servers will be prioritized over hostless servers. + +# Installation + +```text +composer require membrane/openapi-router +``` # Quick Start -Run the following console command to cache the routes from your OpenAPI +To read routes dynamically, you can do the following: + +```php +readFromAbsoluteFilePath('/app/petstore.yaml'); +$routeCollection = (new RouteCollector())->collect($openApi); + +$router = new Router($routeCollection); +$requestedOperationId = $router->route('http://petstore.swagger.io/v1/pets', 'get'); + +echo $requestedOperationId; // listPets +``` + +# Caching Routes + +Run the following console command to cache the routes from your OpenAPI, to avoid reading your OpenAPI file everytime: + ```text membrane:router:generate-routes ``` + + +```php +route('http://petstore.swagger.io/v1/pets', 'get'); + +echo $requestedOperationId; // listPets +``` diff --git a/bin/membrane-router b/bin/membrane-router new file mode 100755 index 0000000..c76d77a --- /dev/null +++ b/bin/membrane-router @@ -0,0 +1,13 @@ +#!/usr/bin/env php +add(new CacheOpenAPI()); + +$application->run(); diff --git a/src/Console/Commands/CacheOpenAPI.php b/src/Console/Commands/CacheOpenAPI.php index da5d884..d200898 100644 --- a/src/Console/Commands/CacheOpenAPI.php +++ b/src/Console/Commands/CacheOpenAPI.php @@ -12,6 +12,7 @@ use Membrane\OpenAPIRouter\Router\ValueObject\RouteCollection; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\FormatterHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -22,7 +23,7 @@ )] class CacheOpenAPI extends Command { - protected function configure() + protected function configure(): void { self::addArgument( 'openAPI', @@ -32,7 +33,7 @@ protected function configure() self::addArgument( 'destination', InputArgument::OPTIONAL, - 'The absolute filepath for the generated route collection', + 'The filepath for the generated route collection', __DIR__ . '/../../../cache/routes.php' ); } @@ -40,27 +41,30 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { $openAPIFilePath = $input->getArgument('openAPI'); - $destination = $input->getArgument('destination'); + assert(is_string($openAPIFilePath)); + $existingFilePath = $destination = $input->getArgument('destination'); + assert(is_string($existingFilePath) && is_string($destination)); + + while (!file_exists($existingFilePath)) { + $existingFilePath = dirname($existingFilePath); + } + if (!is_writable($existingFilePath)) { + $this->outputErrorBlock(sprintf('%s cannot be written to', $existingFilePath), $output); + return Command::FAILURE; + } try { - assert(is_string($openAPIFilePath)); $openApi = (new OpenAPIFileReader())->readFromAbsoluteFilePath($openAPIFilePath); } catch (CannotReadOpenAPI $e) { - $output->writeln($e->getMessage()); - return Command::INVALID; - } - - assert(is_string($destination)); - if (is_writable($destination)) { - echo sprintf('%s is an invalid filename', $destination); - return Command::INVALID; + $this->outputErrorBlock($e->getMessage(), $output); + return Command::FAILURE; } try { $routeCollection = (new RouteCollector())->collect($openApi); } catch (CannotRouteOpenAPI | CannotProcessOpenAPI $e) { - $output->writeln($e->getMessage()); - return Command::INVALID; + $this->outputErrorBlock($e->getMessage(), $output); + return Command::FAILURE; } $routes = sprintf( @@ -68,8 +72,17 @@ protected function execute(InputInterface $input, OutputInterface $output) RouteCollection::class, var_export($routeCollection->routes, true) ); + + + mkdir(dirname($destination), recursive: true); file_put_contents($destination, $routes); return Command::SUCCESS; } + + private function outputErrorBlock(string $message, OutputInterface $output): void + { + $formattedMessage = (new FormatterHelper())->formatBlock($message, 'error', true); + $output->writeLn(sprintf("\n%s\n", $formattedMessage)); + } } diff --git a/src/Reader/OpenAPIFileReader.php b/src/Reader/OpenAPIFileReader.php index 45b729d..2f008b1 100644 --- a/src/Reader/OpenAPIFileReader.php +++ b/src/Reader/OpenAPIFileReader.php @@ -21,9 +21,9 @@ class OpenAPIFileReader public function __construct() { $this->supportedFileTypes = [ - 'json' => fn($p) => Reader::readFromJsonFile(fileName: $p, resolveReferences: false), - 'yaml' => fn($p) => Reader::readFromYamlFile(fileName: $p, resolveReferences: false), - 'yml' => fn($p) => Reader::readFromYamlFile(fileName: $p, resolveReferences: false), + 'json' => fn($p) => Reader::readFromJsonFile(fileName: $p), + 'yaml' => fn($p) => Reader::readFromYamlFile(fileName: $p), + 'yml' => fn($p) => Reader::readFromYamlFile(fileName: $p), ]; } diff --git a/tests/Console/Commands/CacheOpenAPITest.php b/tests/Console/Commands/CacheOpenAPITest.php index 2a701fa..d7da2f4 100644 --- a/tests/Console/Commands/CacheOpenAPITest.php +++ b/tests/Console/Commands/CacheOpenAPITest.php @@ -36,18 +36,36 @@ public function setUp(): void $this->root = vfsStream::setup('cache'); } + #[Test] + public function outputsErrorForReadonlyFilePaths(): void + { + $correctApiPath = __DIR__ . '/../../fixtures/docs/petstore-expanded.json'; + chmod(vfsStream::url('cache'), 0444); + $readonlyDestination = vfsStream::url('cache'); + $sut = new CommandTester(new CacheOpenAPI()); + + $sut->execute(['openAPI' => $correctApiPath, 'destination' => $readonlyDestination]); + + self::assertSame(Command::FAILURE, $sut->getStatusCode()); + + self::assertSame( + sprintf('%s cannot be written to', vfsStream::url('cache')), + trim($sut->getDisplay(true)) + ); + } + public function failedExecutionProvider(): array { return [ 'cannot read from relative filename' => [ '/../../fixtures/docs/petstore-expanded.json', vfsStream::url('cache') . '/routes.php', - Command::INVALID, + Command::FAILURE, ], 'cannot route from an api with no routes' => [ __DIR__ . '/../../fixtures/simple.json', vfsStream::url('cache') . '/routes.php', - Command::INVALID, + Command::FAILURE, ], ]; } @@ -65,200 +83,208 @@ public function executeTest(string $openAPI, string $destination, int $expectedS public function successfulExecutionProvider(): array { - return [ - 'successfully routes petstore-expanded.json' => [ - __DIR__ . '/../../fixtures/docs/petstore-expanded.json', - vfsStream::url('cache') . '/routes.php', - Command::SUCCESS, - new RouteCollection([ - 'hosted' => [ + $petStoreRoutes = new RouteCollection([ + 'hosted' => [ + 'static' => [ + 'http://petstore.swagger.io/api' => [ 'static' => [ - 'http://petstore.swagger.io/api' => [ - 'static' => [ - '/pets' => [ - 'get' => 'findPets', - 'post' => 'addPet', - ], + '/pets' => [ + 'get' => 'findPets', + 'post' => 'addPet', + ], + ], + 'dynamic' => [ + 'regex' => '#^(?|/pets/([^/]+)(*MARK:/pets/{id}))$#', + 'paths' => [ + '/pets/{id}' => [ + 'get' => 'find pet by id', + 'delete' => 'deletePet', ], - 'dynamic' => [ - 'regex' => '#^(?|/pets/([^/]+)(*MARK:/pets/{id}))$#', - 'paths' => [ - '/pets/{id}' => [ - 'get' => 'find pet by id', - 'delete' => 'deletePet', - ], - ], + ], + ], + ], + ], + 'dynamic' => [ + 'regex' => '#^(?|)#', + 'servers' => [], + ], + ], + 'hostless' => [ + 'static' => [], + 'dynamic' => [ + 'regex' => '#^(?|)#', + 'servers' => [], + ], + ], + ]); + $weirdAndWonderfulRoutes = new RouteCollection([ + 'hosted' => [ + 'static' => [ + 'http://weirdest.com' => [ + 'static' => [ + '/however' => [ + 'put' => 'put-however', + 'post' => 'post-however', + ], + ], + 'dynamic' => [ + 'regex' => '#^(?|/and/([^/]+)(*MARK:/and/{name}))$#', + 'paths' => [ + '/and/{name}' => [ + 'get' => 'get-and', ], ], ], + ], + 'http://weirder.co.uk' => [ + 'static' => [ + '/however' => [ + 'get' => 'get-however', + ], + ], 'dynamic' => [ - 'regex' => '#^(?|)#', - 'servers' => [], + 'regex' => '#^(?|/and/([^/]+)(*MARK:/and/{name}))$#', + 'paths' => [ + '/and/{name}' => [ + 'put' => 'put-and', + 'post' => 'post-and', + ], + ], ], ], - 'hostless' => [ - 'static' => [], + 'http://wonderful.io' => [ + 'static' => [ + '/or' => [ + 'post' => 'post-or', + ], + '/xor' => [ + 'delete' => 'delete-xor', + ], + ], 'dynamic' => [ - 'regex' => '#^(?|)#', - 'servers' => [], + 'regex' => '#^(?|)$#', + 'paths' => [], ], ], - ]) - ], - 'successfully routes the WeirdAndWonderful.json' => [ - __DIR__ . '/../../fixtures/WeirdAndWonderful.json', - vfsStream::url('cache') . '/routes.php', - Command::SUCCESS, - new RouteCollection([ - 'hosted' => [ + 'http://wonderful.io/and' => [ 'static' => [ - 'http://weirdest.com' => [ - 'static' => [ - '/however' => [ - 'put' => 'put-however', - 'post' => 'post-however', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|/and/([^/]+)(*MARK:/and/{name}))$#', - 'paths' => [ - '/and/{name}' => [ - 'get' => 'get-and', - ], - ], - ], + '/or' => [ + 'post' => 'post-or', ], - 'http://weirder.co.uk' => [ - 'static' => [ - '/however' => [ - 'get' => 'get-however', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|/and/([^/]+)(*MARK:/and/{name}))$#', - 'paths' => [ - '/and/{name}' => [ - 'put' => 'put-and', - 'post' => 'post-and', - ], - ], - ], + '/xor' => [ + 'delete' => 'delete-xor', ], - 'http://wonderful.io' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], + ], + 'dynamic' => [ + 'regex' => '#^(?|)$#', + 'paths' => [], + ], + ], + 'http://wonderful.io/or' => [ + 'static' => [ + '/or' => [ + 'post' => 'post-or', ], - 'http://wonderful.io/and' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], + '/xor' => [ + 'delete' => 'delete-xor', ], - 'http://wonderful.io/or' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], + ], + 'dynamic' => [ + 'regex' => '#^(?|)$#', + 'paths' => [], + ], + ], + ], + 'dynamic' => [ + 'regex' => '#^(?|http://weird.io/([^/]+)(*MARK:http://weird.io/{conjunction}))#', + 'servers' => [ + 'http://weird.io/{conjunction}' => [ + 'static' => [ + '/or' => [ + 'post' => 'post-or', ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], + '/xor' => [ + 'delete' => 'delete-xor', ], ], + 'dynamic' => [ + 'regex' => '#^(?|)$#', + 'paths' => [], + ], ], - 'dynamic' => [ - 'regex' => '#^(?|http://weird.io/([^/]+)(*MARK:http://weird.io/{conjunction}))#', - 'servers' => [ - 'http://weird.io/{conjunction}' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], - ], + ], + ], + ], + 'hostless' => [ + 'static' => [ + '' => [ + 'static' => [ + '/or' => [ + 'post' => 'post-or', + ], + '/xor' => [ + 'delete' => 'delete-xor', ], ], + 'dynamic' => [ + 'regex' => '#^(?|)$#', + 'paths' => [], + ], ], - 'hostless' => [ + '/v1' => [ 'static' => [ - '' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], + '/or' => [ + 'post' => 'post-or', ], - '/v1' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], + '/xor' => [ + 'delete' => 'delete-xor', ], ], 'dynamic' => [ - 'regex' => '#^(?|/([^/]+)(*MARK:/{version}))#', - 'servers' => [ - '/{version}' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], + 'regex' => '#^(?|)$#', + 'paths' => [], + ], + ], + ], + 'dynamic' => [ + 'regex' => '#^(?|/([^/]+)(*MARK:/{version}))#', + 'servers' => [ + '/{version}' => [ + 'static' => [ + '/or' => [ + 'post' => 'post-or', ], + '/xor' => [ + 'delete' => 'delete-xor', + ], + ], + 'dynamic' => [ + 'regex' => '#^(?|)$#', + 'paths' => [], ], ], ], - ]) + ], + ], + ]); + return [ + 'successfully routes petstore-expanded.json' => [ + __DIR__ . '/../../fixtures/docs/petstore-expanded.json', + vfsStream::url('cache') . '/routes.php', + Command::SUCCESS, + $petStoreRoutes + ], + 'successfully routes the WeirdAndWonderful.json' => [ + __DIR__ . '/../../fixtures/WeirdAndWonderful.json', + vfsStream::url('cache') . '/routes.php', + Command::SUCCESS, + $weirdAndWonderfulRoutes + ], + 'successfully routes the WeirdAndWonderful.json and caches in a nested directory' => [ + __DIR__ . '/../../fixtures/WeirdAndWonderful.json', + vfsStream::url('cache') . '/nested-cache/nester-cache/nestest-cache/routes.php', + Command::SUCCESS, + $weirdAndWonderfulRoutes ] ]; }