From cd5620dda232ae43907241ce403abfcfbcb26b42 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 6 Sep 2023 17:11:08 +0100 Subject: [PATCH 1/8] Replace Reader with "membrane\openapi-reader" package --- composer.json | 4 +- src/Console/Service/CacheOpenAPIRoutes.php | 13 +- ...uteOpenAPI.php => CannotCollectRoutes.php} | 4 +- src/Exception/CannotProcessOpenAPI.php | 80 -------- src/Exception/CannotReadOpenAPI.php | 54 ----- src/Reader/OpenAPIFileReader.php | 50 ----- src/Router/Collector/RouteCollector.php | 48 ++--- .../Commands/CacheOpenAPIRoutesTest.php | 66 +++--- .../Service/CacheOpenAPIRoutesTest.php | 68 +++---- tests/Reader/OpenAPIFileReaderTest.php | 188 ------------------ tests/Router/APIeceOfCakeTest.php | 23 ++- tests/Router/Collector/RouteCollectorTest.php | 67 +++---- tests/Router/RouterTest.php | 4 +- tests/fixtures/duplicateOperationId.yaml | 39 ---- tests/fixtures/missingOperationId.yaml | 23 --- 15 files changed, 137 insertions(+), 594 deletions(-) rename src/Exception/{CannotRouteOpenAPI.php => CannotCollectRoutes.php} (82%) delete mode 100644 src/Exception/CannotProcessOpenAPI.php delete mode 100644 src/Exception/CannotReadOpenAPI.php delete mode 100644 src/Reader/OpenAPIFileReader.php delete mode 100644 tests/Reader/OpenAPIFileReaderTest.php delete mode 100644 tests/fixtures/duplicateOperationId.yaml delete mode 100644 tests/fixtures/missingOperationId.yaml diff --git a/composer.json b/composer.json index 5bcd67f..df7b99e 100644 --- a/composer.json +++ b/composer.json @@ -5,12 +5,14 @@ "autoload": { "psr-4": { "Membrane\\OpenAPIRouter\\": "src/", - "Membrane\\OpenAPIRouter\\Fixtures\\": "tests/fixtures/" + "Membrane\\OpenAPIRouter\\Fixtures\\": "tests/fixtures/", + "Membrane\\OpenAPIRouter\\Tests\\": "tests/" } }, "require": { "php": "^8.1.0", "cebe/php-openapi": "^1.7", + "membrane/openapi-reader": "dev-main", "psr/http-message": "^1.0 || ^2.0", "psr/log": "^3.0", "symfony/console": "^6.2" diff --git a/src/Console/Service/CacheOpenAPIRoutes.php b/src/Console/Service/CacheOpenAPIRoutes.php index 1e16759..cd40e3a 100644 --- a/src/Console/Service/CacheOpenAPIRoutes.php +++ b/src/Console/Service/CacheOpenAPIRoutes.php @@ -4,9 +4,11 @@ namespace Membrane\OpenAPIRouter\Console\Service; +use Membrane\OpenAPIReader\Exception\CannotRead; +use Membrane\OpenAPIReader\OpenAPIVersion; +use Membrane\OpenAPIReader\Reader; +use Membrane\OpenAPIRouter\Exception\CannotCollectRoutes; use Membrane\OpenAPIRouter\Exception\CannotProcessOpenAPI; -use Membrane\OpenAPIRouter\Exception\CannotReadOpenAPI; -use Membrane\OpenAPIRouter\Exception\CannotRouteOpenAPI; use Membrane\OpenAPIRouter\Reader\OpenAPIFileReader; use Membrane\OpenAPIRouter\Router\Collector\RouteCollector; use Membrane\OpenAPIRouter\Router\RouteCollection; @@ -31,15 +33,16 @@ public function cache(string $openAPIFilePath, string $cacheDestination): bool } try { - $openApi = (new OpenAPIFileReader())->readFromAbsoluteFilePath($openAPIFilePath); - } catch (CannotReadOpenAPI $e) { + $openApi = (new Reader([OpenAPIVersion::Version_3_0, OpenAPIVersion::Version_3_1])) + ->readFromAbsoluteFilePath($openAPIFilePath); + } catch (CannotRead $e) { $this->logger->error($e->getMessage()); return false; } try { $routeCollection = (new RouteCollector())->collect($openApi); - } catch (CannotRouteOpenAPI | CannotProcessOpenAPI $e) { + } catch (CannotCollectRoutes | CannotProcessOpenAPI $e) { $this->logger->error($e->getMessage()); return false; } diff --git a/src/Exception/CannotRouteOpenAPI.php b/src/Exception/CannotCollectRoutes.php similarity index 82% rename from src/Exception/CannotRouteOpenAPI.php rename to src/Exception/CannotCollectRoutes.php index 51cdde5..a9bdeb4 100644 --- a/src/Exception/CannotRouteOpenAPI.php +++ b/src/Exception/CannotCollectRoutes.php @@ -6,7 +6,9 @@ /* This exception occurs when a route collection cannot be created from your OpenAPI */ -class CannotRouteOpenAPI extends \RuntimeException +use RuntimeException; + +class CannotCollectRoutes extends RuntimeException { public const NO_ROUTES = 0; diff --git a/src/Exception/CannotProcessOpenAPI.php b/src/Exception/CannotProcessOpenAPI.php deleted file mode 100644 index c36340a..0000000 --- a/src/Exception/CannotProcessOpenAPI.php +++ /dev/null @@ -1,80 +0,0 @@ -supportedFileTypes = [ - 'json' => fn($p) => Reader::readFromJsonFile(fileName: $p), - 'yaml' => fn($p) => Reader::readFromYamlFile(fileName: $p), - 'yml' => fn($p) => Reader::readFromYamlFile(fileName: $p), - ]; - } - - public function readFromAbsoluteFilePath(string $absoluteFilePath): OpenApi - { - file_exists($absoluteFilePath) ?: throw CannotReadOpenAPI::fileNotFound($absoluteFilePath); - - $fileType = strtolower(pathinfo($absoluteFilePath, PATHINFO_EXTENSION)); - - $readFrom = $this->supportedFileTypes[$fileType] ?? throw CannotReadOpenAPI::fileTypeNotSupported($fileType); - - try { - $openAPI = $readFrom($absoluteFilePath); - } catch (TypeError | TypeErrorException | ParseException $e) { - throw CannotReadOpenAPI::cannotParse(pathinfo($absoluteFilePath, PATHINFO_BASENAME), $e); - } catch (UnresolvableReferenceException $e) { - throw CannotReadOpenAPI::unresolvedReference(pathinfo($absoluteFilePath, PATHINFO_BASENAME), $e); - } - - $openAPI->validate() ?: throw CannotReadOpenAPI::invalidOpenAPI(pathinfo($absoluteFilePath, PATHINFO_BASENAME)); - - return $openAPI; - } -} diff --git a/src/Router/Collector/RouteCollector.php b/src/Router/Collector/RouteCollector.php index 45256a2..e6df87d 100644 --- a/src/Router/Collector/RouteCollector.php +++ b/src/Router/Collector/RouteCollector.php @@ -7,8 +7,7 @@ use cebe\openapi\spec\OpenApi; use cebe\openapi\spec\Operation; use cebe\openapi\spec\PathItem; -use Membrane\OpenAPIRouter\Exception\CannotProcessOpenAPI; -use Membrane\OpenAPIRouter\Exception\CannotRouteOpenAPI; +use Membrane\OpenAPIRouter\Exception\CannotCollectRoutes; use Membrane\OpenAPIRouter\Router\Route\Route; use Membrane\OpenAPIRouter\Router\Route\Server as ServerRoute; use Membrane\OpenAPIRouter\Router\RouteCollection; @@ -20,27 +19,12 @@ public function collect(OpenApi $openApi): RouteCollection $collection = $this->collectRoutes($openApi); if ($collection === []) { - throw CannotRouteOpenAPI::noRoutes(); + throw CannotCollectRoutes::noRoutes(); } return RouteCollection::fromServers(...$collection); } - /** @return array */ - private function getServers(OpenApi|PathItem|Operation $object): array - { - $uniqueServers = array_unique(array_map(fn($p) => rtrim($p->url, '/'), $object->servers)); - return array_combine($uniqueServers, array_map(fn($p) => $this->getRegex($p), $uniqueServers)); - } - - private function getRegex(string $path): string - { - $regex = preg_replace('#{[^/]+}#', '([^/]+)', $path); - assert($regex !== null); // The pattern is hardcoded, valid regex so should not cause an error in preg_replace - - return $regex; - } - /** @return array */ private function collectRoutes(OpenApi $openApi): array { @@ -66,19 +50,6 @@ private function collectRoutes(OpenApi $openApi): array $collection[$url] ??= new ServerRoute($url, $regex); } - // TODO remove this conditional once OpenAPIFileReader requires operationId - if ($operationObject->operationId === null) { - throw CannotProcessOpenAPI::missingOperationId($path, $method); - } - - if (isset($operationIds[$operationObject->operationId])) { - throw CannotProcessOpenAPI::duplicateOperationId( - $operationObject->operationId, - $operationIds[$operationObject->operationId], - ['path' => $path, 'operation' => $method] - ); - } - if ($operationServers !== []) { $servers = $operationServers; } elseif ($pathServers !== []) { @@ -97,4 +68,19 @@ private function collectRoutes(OpenApi $openApi): array return array_filter($collection, fn($s) => !$s->isEmpty()); } + + /** @return array */ + private function getServers(OpenApi|PathItem|Operation $object): array + { + $uniqueServers = array_unique(array_map(fn($p) => rtrim($p->url, '/'), $object->servers)); + return array_combine($uniqueServers, array_map(fn($p) => $this->getRegex($p), $uniqueServers)); + } + + private function getRegex(string $path): string + { + $regex = preg_replace('#{[^/]+}#', '([^/]+)', $path); + assert($regex !== null); // The pattern is hardcoded, valid regex so should not cause an error in preg_replace + + return $regex; + } } diff --git a/tests/Console/Commands/CacheOpenAPIRoutesTest.php b/tests/Console/Commands/CacheOpenAPIRoutesTest.php index 1222e12..61821fe 100644 --- a/tests/Console/Commands/CacheOpenAPIRoutesTest.php +++ b/tests/Console/Commands/CacheOpenAPIRoutesTest.php @@ -2,16 +2,15 @@ declare(strict_types=1); -namespace Membrane\OpenAPIRouter\Console\Commands; +namespace Membrane\OpenAPIRouter\Tests\Console\Commands; -use Membrane\OpenAPIRouter\Exception\CannotReadOpenAPI; -use Membrane\OpenAPIRouter\Exception\CannotRouteOpenAPI; -use Membrane\OpenAPIRouter\Reader\OpenAPIFileReader; +use Membrane\OpenAPIRouter\Console\Commands\CacheOpenAPIRoutes; +use Membrane\OpenAPIRouter\Console\Service; +use Membrane\OpenAPIRouter\Exception; use Membrane\OpenAPIRouter\Router\Collector\RouteCollector; -use Membrane\OpenAPIRouter\Router\Route\Route; +use Membrane\OpenAPIRouter\Router\Route; use Membrane\OpenAPIRouter\Router\RouteCollection; use org\bovigo\vfs\vfsStream; -use org\bovigo\vfs\vfsStreamDirectory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -21,20 +20,18 @@ use Symfony\Component\Console\Tester\CommandTester; #[CoversClass(CacheOpenAPIRoutes::class)] -#[CoversClass(CannotReadOpenAPI::class)] -#[CoversClass(CannotRouteOpenAPI::class)] -#[UsesClass(\Membrane\OpenAPIRouter\Console\Service\CacheOpenAPIRoutes::class)] -#[UsesClass(OpenAPIFileReader::class)] -#[UsesClass(Route::class)] +#[CoversClass(Exception\CannotCollectRoutes::class)] +#[UsesClass(Service\CacheOpenAPIRoutes::class)] +#[UsesClass(Route\Route::class), UsesClass(Route\Server::class), UsesClass(Route\Path::class)] #[UsesClass(RouteCollection::class)] #[UsesClass(RouteCollector::class)] class CacheOpenAPIRoutesTest extends TestCase { - private vfsStreamDirectory $root; + private string $root; public function setUp(): void { - $this->root = vfsStream::setup('cache'); + $this->root = vfsStream::setup('cache')->url(); } #[Test] @@ -55,31 +52,40 @@ public function outputsErrorForReadonlyFilePaths(): void ); } - public static function failedExecutionProvider(): array + #[Test] + public function itCannotRouteFromRelativeFilePaths(): void { - return [ - 'cannot read from relative filename' => [ - '/../../fixtures/docs/petstore-expanded.json', - vfsStream::url('cache') . '/routes.php', - Command::FAILURE, - ], - 'cannot route from an api with no routes' => [ - __DIR__ . '/../../fixtures/simple.json', - vfsStream::url('cache') . '/routes.php', - Command::FAILURE, - ], - ]; + $filePath = './tests/fixtures/docs/petstore-expanded.json'; + + self::assertTrue(file_exists($filePath)); + + $sut = new CommandTester(new CacheOpenAPIRoutes()); + + $sut->execute(['openAPI' => $filePath, 'destination' => vfsStream::url('cache') . '/routes.php']); + + self::assertSame(Command::FAILURE, $sut->getStatusCode()); } #[Test] - #[DataProvider('failedExecutionProvider')] - public function executeTest(string $openAPI, string $destination, int $expectedStatusCode): void + public function itCannotRouteWithoutAnyRoutes(): void { + $openAPIFilePath = $this->root . '/openapi.json'; + file_put_contents( + $openAPIFilePath, + json_encode([ + 'openapi' => '3.0.0', + 'info' => ['title' => '', 'version' => '1.0.0'], + 'paths' => [] + ]) + ); + + self::assertTrue(file_exists($openAPIFilePath)); + $sut = new CommandTester(new CacheOpenAPIRoutes()); - $sut->execute(['openAPI' => $openAPI, 'destination' => $destination]); + $sut->execute(['openAPI' => $openAPIFilePath, 'destination' => vfsStream::url('cache') . '/routes.php']); - self::assertSame($expectedStatusCode, $sut->getStatusCode()); + self::assertSame(Command::FAILURE, $sut->getStatusCode()); } public static function successfulExecutionProvider(): array diff --git a/tests/Console/Service/CacheOpenAPIRoutesTest.php b/tests/Console/Service/CacheOpenAPIRoutesTest.php index 2b36a49..49eedd9 100644 --- a/tests/Console/Service/CacheOpenAPIRoutesTest.php +++ b/tests/Console/Service/CacheOpenAPIRoutesTest.php @@ -2,76 +2,68 @@ declare(strict_types=1); -namespace Console\Service; +namespace Membrane\OpenAPIRouter\Tests\Console\Service; use Membrane\OpenAPIRouter\Console\Service\CacheOpenAPIRoutes; -use Membrane\OpenAPIRouter\Exception\CannotReadOpenAPI; -use Membrane\OpenAPIRouter\Exception\CannotRouteOpenAPI; -use Membrane\OpenAPIRouter\Reader\OpenAPIFileReader; +use Membrane\OpenAPIRouter\Exception\CannotCollectRoutes; use Membrane\OpenAPIRouter\Router\Collector\RouteCollector; -use Membrane\OpenAPIRouter\Router\Route\Route; +use Membrane\OpenAPIRouter\Router\Route; use Membrane\OpenAPIRouter\Router\RouteCollection; use org\bovigo\vfs\vfsStream; -use org\bovigo\vfs\vfsStreamDirectory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -use Symfony\Component\Console\Command\Command; #[CoversClass(CacheOpenAPIRoutes::class)] -#[CoversClass(CannotReadOpenAPI::class)] -#[CoversClass(CannotRouteOpenAPI::class)] -#[UsesClass(OpenAPIFileReader::class)] +#[CoversClass(CannotCollectRoutes::class)] #[UsesClass(RouteCollector::class)] -#[UsesClass(Route::class)] +#[UsesClass(Route\Route::class), UsesClass(Route\Server::class), UsesClass(Route\Path::class)] #[UsesClass(RouteCollection::class)] class CacheOpenAPIRoutesTest extends TestCase { - private vfsStreamDirectory $root; + private string $root; private CacheOpenAPIRoutes $sut; + private string $fixtures = __DIR__ . '/../../fixtures/'; public function setUp(): void { - $this->root = vfsStream::setup('cache'); + $this->root = vfsStream::setup()->url(); $this->sut = new CacheOpenAPIRoutes(self::createStub(LoggerInterface::class)); } #[Test] public function outputsErrorForReadonlyFilePaths(): void { - $readonlyDestination = $this->root->url('cache'); - chmod($readonlyDestination, 0444); + $cache = $this->root . '/cache'; + mkdir($cache); + chmod($cache, 0444); - self::assertFalse($this->sut->cache( - __DIR__ . '/../../fixtures/docs/petstore-expanded.json', - $readonlyDestination - )); + self::assertFalse($this->sut->cache($this->fixtures . 'docs/petstore-expanded.json', $cache)); } - public static function failedExecutionProvider(): array + #[Test] + public function cannotRouteWithoutPaths(): void { - return [ - 'cannot read from relative filename' => [ - '/../../fixtures/docs/petstore-expanded.json', - vfsStream::url('cache') . '/routes.php', - Command::FAILURE, - ], - 'cannot route from an api with no routes' => [ - __DIR__ . '/../../fixtures/simple.json', - vfsStream::url('cache') . '/routes.php', - Command::FAILURE, - ], - ]; + $openAPIFilePath = $this->root . '/openapi.json'; + file_put_contents( + $openAPIFilePath, + json_encode(['openapi' => '3.0.0', 'info' => ['title' => '', 'version' => '1.0.0'], 'paths' => []]) + ); + + self::assertFalse($this->sut->cache($openAPIFilePath, $this->root . '/cache/routes.php')); } #[Test] - #[DataProvider('failedExecutionProvider')] - public function executeTest(string $openAPI, string $destination): void + public function cannotRouteFromRelativeFilePaths(): void { - self::assertFalse($this->sut->cache($openAPI, $destination)); + $filePath = './tests/fixtures/docs/petstore-expanded.json'; + + self::assertTrue(file_exists($filePath)); + + self::assertFalse($this->sut->cache($filePath, $this->root . '/cache/routes.php')); } public static function successfulExecutionProvider(): array @@ -263,17 +255,17 @@ public static function successfulExecutionProvider(): array return [ 'successfully routes petstore-expanded.json' => [ __DIR__ . '/../../fixtures/docs/petstore-expanded.json', - vfsStream::url('cache/routes.php'), + vfsStream::url('root/cache/routes.php'), $petStoreRoutes ], 'successfully routes the WeirdAndWonderful.json' => [ __DIR__ . '/../../fixtures/WeirdAndWonderful.json', - vfsStream::url('cache/routes.php'), + vfsStream::url('root/cache/routes.php'), $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'), + vfsStream::url('root/cache/nested-cache/nester-cache/nestest-cache/routes.php'), $weirdAndWonderfulRoutes ] ]; diff --git a/tests/Reader/OpenAPIFileReaderTest.php b/tests/Reader/OpenAPIFileReaderTest.php deleted file mode 100644 index e59b6b7..0000000 --- a/tests/Reader/OpenAPIFileReaderTest.php +++ /dev/null @@ -1,188 +0,0 @@ -vfsRoot = vfsStream::setup(); - } - - #[Test] - public function readerThrowsExceptionIfFileNotFound(): void - { - $filePath = $this->vfsRoot->url() . '/openapi.json'; - - self::expectExceptionObject(CannotReadOpenAPI::fileNotFound($filePath)); - - (new OpenAPIFileReader())->readFromAbsoluteFilePath($filePath); - } - - #[Test] - public function readerThrowsExceptionForUnsupportedFileTypes(): void - { - $structure = ['openapi.txt' => 'some text']; - vfsStream::create($structure); - $filePath = $this->vfsRoot->url() . '/openapi.txt'; - - self::expectExceptionObject(CannotReadOpenAPI::fileTypeNotSupported(pathinfo($filePath, PATHINFO_EXTENSION))); - - (new OpenAPIFileReader())->readFromAbsoluteFilePath($filePath); - } - - #[Test] - public function readerThrowsExceptionIfJsonCannotBeParsedAsOpenAPI(): void - { - $structure = [ - 'openapi.json' => - <<vfsRoot->url() . '/openapi.json'; - - self::expectExceptionObject(CannotReadOpenAPI::cannotParse('openapi.json', new TypeError())); - - (new OpenAPIFileReader())->readFromAbsoluteFilePath($filePath); - } - - #[Test] - public function readerThrowsExceptionIfYamlCannotBeParsedAsOpenAPI(): void - { - $structure = [ - 'openapi.yaml' => - <<vfsRoot->url() . '/openapi.yaml'; - - self::expectExceptionObject(CannotReadOpenAPI::cannotParse('openapi.yaml', new ParseException(''))); - - (new OpenAPIFileReader())->readFromAbsoluteFilePath($filePath); - } - - #[Test] - public function readerThrowsExceptionIfYamlContainsInvalidOpenAPI(): void - { - $structure = [ - 'openapi.yaml' => - <<vfsRoot->url() . '/openapi.yaml'; - - self::expectExceptionObject(CannotReadOpenAPI::invalidOpenAPI('openapi.yaml')); - - (new OpenAPIFileReader())->readFromAbsoluteFilePath($filePath); - } - - #[Test] - public function readerThrowsExceptionIfJsonContainsInvalidOpenAPI(): void - { - $structure = [ - 'openapi.json' => - <<vfsRoot->url() . '/openapi.json'; - - self::expectExceptionObject(CannotReadOpenAPI::invalidOpenAPI('openapi.json')); - - (new OpenAPIFileReader())->readFromAbsoluteFilePath($filePath); - } - - #[Test] - public function throwsExceptionForRelativeFilePaths(): void - { - self::expectExceptionObject( - CannotReadOpenAPI::unresolvedReference('petstore.yaml', new UnresolvableReferenceException()) - ); - - (new OpenAPIFileReader())->readFromAbsoluteFilePath('./tests/fixtures/docs/petstore.yaml'); - } - - - #[Test] - public function returnsOpenAPIObjectFromJsonWithValidOpenAPI(): void - { - $structure = [ - 'openapi.json' => - <<vfsRoot->url() . '/openapi.json'; - - $actual = (new OpenAPIFileReader())->readFromAbsoluteFilePath($filePath); - - self::assertInstanceOf(OpenApi::class, $actual); - } - - #[Test] - public function returnsOpenAPIObjectFromYamlWithValidOpenAPI(): void - { - $structure = [ - 'openapi.yaml' => - <<vfsRoot->url() . '/openapi.yaml'; - - $actual = (new OpenAPIFileReader())->readFromAbsoluteFilePath($filePath); - - self::assertInstanceOf(OpenApi::class, $actual); - } -} diff --git a/tests/Router/APIeceOfCakeTest.php b/tests/Router/APIeceOfCakeTest.php index 4c44762..fbf9d45 100644 --- a/tests/Router/APIeceOfCakeTest.php +++ b/tests/Router/APIeceOfCakeTest.php @@ -1,20 +1,24 @@ readFromAbsoluteFilePath($apiFilePath); - $routeCollection = (new RouteCollector())->collect($api); + $openAPI = (new Reader([OpenAPIVersion::Version_3_0, OpenAPIVersion::Version_3_1])) + ->readFromAbsoluteFilePath($apiFilePath); + $routeCollection = (new RouteCollector())->collect($openAPI); // I expect to have no hosted routes self::assertEmpty($routeCollection->routes['hosted']['static']); @@ -42,8 +47,8 @@ public function itCollectsPathsFromAPIeceOfCake(): RouteCollection '/cakes/{icing}' => ['get' => 'findCakesByIcing', 'post' => 'addCakesByIcing'], '/{cakeType}/sponge' => ['get' => 'findSpongeByDesserts'], '/{cakeType}/{icing}' => ['get' => 'findDessertByIcing', 'post' => 'addDessertByIcing'], - ]; + self::assertSame( $hostlessDynamicRoutes, $routeCollection->routes['hostless']['static']['']['dynamic']['paths'] @@ -56,23 +61,21 @@ public function itCollectsPathsFromAPIeceOfCake(): RouteCollection #[Depends('itCollectsPathsFromAPIeceOfCake')] public function itRoutesToCompletelyStaticPathFirst(RouteCollection $routeCollection): void { - $expectedOperationId = 'findSpongeCakes'; $sut = new Router($routeCollection); $actualOperationId = $sut->route('/cakes/sponge', 'get'); - self::assertSame($expectedOperationId, $actualOperationId); + self::assertSame('findSpongeCakes', $actualOperationId); } #[Test, TestDox('Paths with less dynamic elements should take priority')] #[Depends('itCollectsPathsFromAPIeceOfCake')] public function itRoutesToPartiallyDynamicBeforeCompletelyDynamic(RouteCollection $routeCollection): void { - $expectedOperationId = 'findCakesByIcing'; $sut = new Router($routeCollection); $actualOperationId = $sut->route('/cakes/chocolate', 'get'); - self::assertSame($expectedOperationId, $actualOperationId); + self::assertSame('findCakesByIcing', $actualOperationId); } } diff --git a/tests/Router/Collector/RouteCollectorTest.php b/tests/Router/Collector/RouteCollectorTest.php index c4072e3..829d747 100644 --- a/tests/Router/Collector/RouteCollectorTest.php +++ b/tests/Router/Collector/RouteCollectorTest.php @@ -2,12 +2,14 @@ declare(strict_types=1); -namespace Membrane\OpenAPIRouter\Router\Collector; - -use cebe\openapi\Reader; -use Membrane\OpenAPIRouter\Exception\CannotProcessOpenAPI; -use Membrane\OpenAPIRouter\Exception\CannotRouteOpenAPI; -use Membrane\OpenAPIRouter\Router\Route\Route; +namespace Membrane\OpenAPIRouter\Tests\Router\Collector; + +use Membrane\OpenAPIReader\FileFormat; +use Membrane\OpenAPIReader\OpenAPIVersion; +use Membrane\OpenAPIReader\Reader; +use Membrane\OpenAPIRouter\Exception\CannotCollectRoutes; +use Membrane\OpenAPIRouter\Router\Collector\RouteCollector; +use Membrane\OpenAPIRouter\Router\Route; use Membrane\OpenAPIRouter\Router\RouteCollection; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -16,8 +18,8 @@ use PHPUnit\Framework\TestCase; #[CoversClass(RouteCollector::class)] -#[CoversClass(CannotRouteOpenAPI::class)] -#[UsesClass(Route::class)] +#[CoversClass(CannotCollectRoutes::class)] +#[UsesClass(Route\Route::class), UsesClass(Route\Server::class), UsesClass(Route\Path::class)] #[UsesClass(RouteCollection::class)] class RouteCollectorTest extends TestCase { @@ -26,38 +28,19 @@ class RouteCollectorTest extends TestCase #[Test] public function throwExceptionIfThereAreNoRoutes(): void { - $sut = new RouteCollector(); - $openApi = Reader::readFromJsonFile(self::FIXTURES . 'simple.json'); - - self::expectExceptionObject(CannotRouteOpenAPI::noRoutes()); - - $sut->collect($openApi); - } - - #[Test] - public function throwsExceptionForMissingOperationId(): void - { - $sut = new RouteCollector(); - $openApi = Reader::readFromYamlFile(self::FIXTURES . 'missingOperationId.yaml'); - - self::expectExceptionObject(CannotProcessOpenAPI::missingOperationId('/path', 'get')); - - $sut->collect($openApi); - } - - #[Test] - public function throwsExceptionForDuplicateOperationId(): void - { - $sut = new RouteCollector(); - $openApi = Reader::readFromYamlFile(self::FIXTURES . 'duplicateOperationId.yaml'); + $openAPI = (new Reader([OpenAPIVersion::Version_3_0, OpenAPIVersion::Version_3_1])) + ->readFromString( + json_encode([ + 'openapi' => '3.0.0', + 'info' => ['title' => '', 'version' => '1.0.0'], + 'paths' => [] + ]), + FileFormat::Json + ); - self::expectExceptionObject(CannotProcessOpenAPI::duplicateOperationId( - 'operation1', - ['path' => '/path', 'operation' => 'get'], - ['path' => '/path', 'operation' => 'delete'], - )); + self::expectExceptionObject(CannotCollectRoutes::noRoutes()); - $sut->collect($openApi); + (new RouteCollector())->collect($openAPI); } public static function collectTestProvider(): array @@ -260,11 +243,9 @@ public static function collectTestProvider(): array #[DataProvider('collectTestProvider')] public function collectTest(RouteCollection $expected, string $apiFilePath): void { - $openApi = Reader::readFromJsonFile($apiFilePath); - $sut = new RouteCollector(); - - $actual = $sut->collect($openApi); + $openAPI = (new Reader([OpenAPIVersion::Version_3_0, OpenAPIVersion::Version_3_1])) + ->readFromAbsoluteFilePath($apiFilePath); - self::assertEquals($expected, $actual); + self::assertEquals($expected, (new RouteCollector())->collect($openAPI)); } } diff --git a/tests/Router/RouterTest.php b/tests/Router/RouterTest.php index 8edcaff..af4fd81 100644 --- a/tests/Router/RouterTest.php +++ b/tests/Router/RouterTest.php @@ -2,9 +2,11 @@ declare(strict_types=1); -namespace Membrane\OpenAPIRouter\Router; +namespace Membrane\OpenAPIRouter\Tests\Router; use Membrane\OpenAPIRouter\Exception\CannotRouteRequest; +use Membrane\OpenAPIRouter\Router\RouteCollection; +use Membrane\OpenAPIRouter\Router\Router; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/fixtures/duplicateOperationId.yaml b/tests/fixtures/duplicateOperationId.yaml deleted file mode 100644 index fbedc9a..0000000 --- a/tests/fixtures/duplicateOperationId.yaml +++ /dev/null @@ -1,39 +0,0 @@ ---- -openapi: 3.0.0 -info: - title: Test API - version: 1.0.0 -servers: - - url: http://test.com -paths: - "/path": - get: - operationId: operation1 - parameters: - - name: id - in: header - required: true - schema: - type: integer - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: integer - delete: - operationId: operation1 - parameters: - - name: id - in: header - required: true - schema: - type: integer - responses: - '204': - description: Successful Response - content: - application/json: - schema: - type: integer diff --git a/tests/fixtures/missingOperationId.yaml b/tests/fixtures/missingOperationId.yaml deleted file mode 100644 index 0ae712a..0000000 --- a/tests/fixtures/missingOperationId.yaml +++ /dev/null @@ -1,23 +0,0 @@ ---- -openapi: 3.0.0 -info: - title: Test API - version: 1.0.0 -servers: - - url: http://test.com -paths: - "/path": - get: - parameters: - - name: id - in: header - required: true - schema: - type: integer - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: integer From 5eea8df299f6a7e73403a15794fb200330db2e13 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 6 Sep 2023 17:13:05 +0100 Subject: [PATCH 2/8] Rename Commands namespace to Command - Singular form is consistent with other namespaces --- bin/membrane-router | 2 +- src/Console/{Commands => Command}/CacheOpenAPIRoutes.php | 2 +- .../Console/{Commands => Command}/CacheOpenAPIRoutesTest.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/Console/{Commands => Command}/CacheOpenAPIRoutes.php (96%) rename tests/Console/{Commands => Command}/CacheOpenAPIRoutesTest.php (98%) diff --git a/bin/membrane-router b/bin/membrane-router index 5b6778c..1877db8 100755 --- a/bin/membrane-router +++ b/bin/membrane-router @@ -3,7 +3,7 @@ require __DIR__ . '/../vendor/autoload.php'; -use Membrane\OpenAPIRouter\Console\Commands\CacheOpenAPIRoutes; +use Membrane\OpenAPIRouter\Console\Command\CacheOpenAPIRoutes; use Symfony\Component\Console\Application; $application = new Application(); diff --git a/src/Console/Commands/CacheOpenAPIRoutes.php b/src/Console/Command/CacheOpenAPIRoutes.php similarity index 96% rename from src/Console/Commands/CacheOpenAPIRoutes.php rename to src/Console/Command/CacheOpenAPIRoutes.php index acc48e7..2140bf8 100644 --- a/src/Console/Commands/CacheOpenAPIRoutes.php +++ b/src/Console/Command/CacheOpenAPIRoutes.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Membrane\OpenAPIRouter\Console\Commands; +namespace Membrane\OpenAPIRouter\Console\Command; use Membrane\OpenAPIRouter\Console\Service\CacheOpenAPIRoutes as CacheOpenAPIRoutesService; use Symfony\Component\Console\Attribute\AsCommand; diff --git a/tests/Console/Commands/CacheOpenAPIRoutesTest.php b/tests/Console/Command/CacheOpenAPIRoutesTest.php similarity index 98% rename from tests/Console/Commands/CacheOpenAPIRoutesTest.php rename to tests/Console/Command/CacheOpenAPIRoutesTest.php index 61821fe..6d07e91 100644 --- a/tests/Console/Commands/CacheOpenAPIRoutesTest.php +++ b/tests/Console/Command/CacheOpenAPIRoutesTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Membrane\OpenAPIRouter\Tests\Console\Commands; +namespace Membrane\OpenAPIRouter\Tests\Console\Command; -use Membrane\OpenAPIRouter\Console\Commands\CacheOpenAPIRoutes; +use Membrane\OpenAPIRouter\Console\Command\CacheOpenAPIRoutes; use Membrane\OpenAPIRouter\Console\Service; use Membrane\OpenAPIRouter\Exception; use Membrane\OpenAPIRouter\Router\Collector\RouteCollector; From 8f254b8524fa9251bb97359dbdd6f2f6d4645bef Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 6 Sep 2023 17:18:19 +0100 Subject: [PATCH 3/8] Move Router to src - Membrane\OpenAPIRouter\Router\Router namespace changes to Membrane\OpenAPIRouter\Router --- README.md | 7 ++----- benchmark/CebeReaderNoResolveExternalOnly.php | 4 ++-- benchmark/CebeReaderResolveAll.php | 4 ++-- benchmark/WeirdAndWonderful.php | 4 ++-- src/Console/Service/CacheOpenAPIRoutes.php | 4 ++-- src/{Router => }/Route/Path.php | 2 +- src/{Router => }/Route/Route.php | 2 +- src/{Router => }/Route/Server.php | 2 +- src/{Router => }/RouteCollection.php | 4 ++-- src/{Router/Collector => }/RouteCollector.php | 7 +++---- src/{Router => }/Router.php | 2 +- tests/{Router => }/APIeceOfCakeTest.php | 12 +++++++----- tests/Console/Command/CacheOpenAPIRoutesTest.php | 8 +++++--- tests/Console/Service/CacheOpenAPIRoutesTest.php | 8 +++++--- tests/{Router/Collector => }/RouteCollectorTest.php | 10 ++++++---- tests/{Router => }/RouterTest.php | 6 +++--- 16 files changed, 45 insertions(+), 41 deletions(-) rename src/{Router => }/Route/Path.php (94%) rename src/{Router => }/Route/Route.php (84%) rename src/{Router => }/Route/Server.php (97%) rename src/{Router => }/RouteCollection.php (97%) rename src/{Router/Collector => }/RouteCollector.php (92%) rename src/{Router => }/Router.php (99%) rename tests/{Router => }/APIeceOfCakeTest.php (89%) rename tests/{Router/Collector => }/RouteCollectorTest.php (97%) rename tests/{Router => }/RouterTest.php (98%) diff --git a/README.md b/README.md index 9cee9cb..9c9b19c 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,7 @@ To read routes dynamically, you can do the following: ```php readFromAbsoluteFilePath('/app/petstore.yaml'); $routeCollection = (new RouteCollector())->collect($openApi); @@ -57,11 +55,10 @@ Run the following console command to cache the routes from your OpenAPI, to avoi membrane:router:generate-routes ``` - ```php Date: Thu, 7 Sep 2023 07:40:20 +0100 Subject: [PATCH 4/8] Fix namespacing in tests --- tests/APIeceOfCakeTest.php | 10 ++++------ tests/Console/Command/CacheOpenAPIRoutesTest.php | 6 ++---- tests/Console/Service/CacheOpenAPIRoutesTest.php | 6 ++---- tests/RouteCollectorTest.php | 8 +++----- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/tests/APIeceOfCakeTest.php b/tests/APIeceOfCakeTest.php index 5efa521..7e42c3d 100644 --- a/tests/APIeceOfCakeTest.php +++ b/tests/APIeceOfCakeTest.php @@ -4,12 +4,10 @@ use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\Reader; -use Membrane\OpenAPIRouter\Route\Path; -use Membrane\OpenAPIRouter\Route\Server; +use Membrane\OpenAPIRouter\Route; use Membrane\OpenAPIRouter\RouteCollection; use Membrane\OpenAPIRouter\RouteCollector; use Membrane\OpenAPIRouter\Router; -use Membrane\OpenAPIRouter\Router\Route; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\Attributes\Test; @@ -20,14 +18,14 @@ #[CoversClass(Router::class)] #[CoversClass(RouteCollector::class)] #[UsesClass(RouteCollection::class)] -#[UsesClass(\Membrane\OpenAPIRouter\Route\Route::class), UsesClass(Server::class), UsesClass(Path::class)] +#[UsesClass(Route\Route::class), UsesClass(Route\Server::class), UsesClass(Route\Path::class)] class APIeceOfCakeTest extends TestCase { - #[Test, TestDox('It reads and collects all s')] + #[Test, TestDox('It reads and collects all routes')] public function itCollectsPathsFromAPIeceOfCake(): RouteCollection { // Given the APIeceOfCake OpenAPI - $apiFilePath = __DIR__ . '/../fixtures/APIeceOfCake.json'; + $apiFilePath = __DIR__ . '/fixtures/APIeceOfCake.json'; // When I read and collect the routes from APIeceOfCake $openAPI = (new Reader([OpenAPIVersion::Version_3_0, OpenAPIVersion::Version_3_1])) diff --git a/tests/Console/Command/CacheOpenAPIRoutesTest.php b/tests/Console/Command/CacheOpenAPIRoutesTest.php index 90b870a..5b0a486 100644 --- a/tests/Console/Command/CacheOpenAPIRoutesTest.php +++ b/tests/Console/Command/CacheOpenAPIRoutesTest.php @@ -7,11 +7,9 @@ use Membrane\OpenAPIRouter\Console\Command\CacheOpenAPIRoutes; use Membrane\OpenAPIRouter\Console\Service; use Membrane\OpenAPIRouter\Exception; -use Membrane\OpenAPIRouter\Route\Path; -use Membrane\OpenAPIRouter\Route\Server; +use Membrane\OpenAPIRouter\Route; use Membrane\OpenAPIRouter\RouteCollection; use Membrane\OpenAPIRouter\RouteCollector; -use Membrane\OpenAPIRouter\Router\Route; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -24,7 +22,7 @@ #[CoversClass(CacheOpenAPIRoutes::class)] #[CoversClass(Exception\CannotCollectRoutes::class)] #[UsesClass(Service\CacheOpenAPIRoutes::class)] -#[UsesClass(\Membrane\OpenAPIRouter\Route\Route::class), UsesClass(Server::class), UsesClass(Path::class)] +#[UsesClass(Route\Route::class), UsesClass(Route\Server::class), UsesClass(Route\Path::class)] #[UsesClass(RouteCollection::class)] #[UsesClass(RouteCollector::class)] class CacheOpenAPIRoutesTest extends TestCase diff --git a/tests/Console/Service/CacheOpenAPIRoutesTest.php b/tests/Console/Service/CacheOpenAPIRoutesTest.php index e5c4ec8..b32c0c9 100644 --- a/tests/Console/Service/CacheOpenAPIRoutesTest.php +++ b/tests/Console/Service/CacheOpenAPIRoutesTest.php @@ -6,11 +6,9 @@ use Membrane\OpenAPIRouter\Console\Service\CacheOpenAPIRoutes; use Membrane\OpenAPIRouter\Exception\CannotCollectRoutes; -use Membrane\OpenAPIRouter\Route\Path; -use Membrane\OpenAPIRouter\Route\Server; +use Membrane\OpenAPIRouter\Route; use Membrane\OpenAPIRouter\RouteCollection; use Membrane\OpenAPIRouter\RouteCollector; -use Membrane\OpenAPIRouter\Router\Route; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -22,7 +20,7 @@ #[CoversClass(CacheOpenAPIRoutes::class)] #[CoversClass(CannotCollectRoutes::class)] #[UsesClass(RouteCollector::class)] -#[UsesClass(\Membrane\OpenAPIRouter\Route\Route::class), UsesClass(Server::class), UsesClass(Path::class)] +#[UsesClass(Route\Route::class), UsesClass(Route\Server::class), UsesClass(Route\Path::class)] #[UsesClass(RouteCollection::class)] class CacheOpenAPIRoutesTest extends TestCase { diff --git a/tests/RouteCollectorTest.php b/tests/RouteCollectorTest.php index 25eb930..730e0d1 100644 --- a/tests/RouteCollectorTest.php +++ b/tests/RouteCollectorTest.php @@ -8,11 +8,9 @@ use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\Reader; use Membrane\OpenAPIRouter\Exception\CannotCollectRoutes; -use Membrane\OpenAPIRouter\Route\Path; -use Membrane\OpenAPIRouter\Route\Server; +use Membrane\OpenAPIRouter\Route; use Membrane\OpenAPIRouter\RouteCollection; use Membrane\OpenAPIRouter\RouteCollector; -use Membrane\OpenAPIRouter\Router\Route; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -21,11 +19,11 @@ #[CoversClass(RouteCollector::class)] #[CoversClass(CannotCollectRoutes::class)] -#[UsesClass(\Membrane\OpenAPIRouter\Route\Route::class), UsesClass(Server::class), UsesClass(Path::class)] +#[UsesClass(Route\Route::class), UsesClass(Route\Server::class), UsesClass(Route\Path::class)] #[UsesClass(RouteCollection::class)] class RouteCollectorTest extends TestCase { - public const FIXTURES = __DIR__ . '/../../fixtures/'; + public const FIXTURES = __DIR__ . '/fixtures/'; #[Test] public function throwExceptionIfThereAreNoRoutes(): void From 85c45a8e25e4616543a60c952d816f6c3b1caf6a Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 7 Sep 2023 08:29:03 +0100 Subject: [PATCH 5/8] Combine APieceOfCakeTests into RouterTest --- src/Console/Service/CacheOpenAPIRoutes.php | 4 +- tests/APIeceOfCakeTest.php | 81 - tests/RouteCollectorTest.php | 299 ++-- tests/RouterTest.php | 238 +-- tests/fixtures/docs/petstore.yaml | 113 -- tests/fixtures/noReferences.json | 1646 -------------------- tests/fixtures/references.json | 52 - tests/fixtures/references.yaml | 30 - tests/fixtures/simple.json | 11 - 9 files changed, 219 insertions(+), 2255 deletions(-) delete mode 100644 tests/APIeceOfCakeTest.php delete mode 100644 tests/fixtures/docs/petstore.yaml delete mode 100644 tests/fixtures/noReferences.json delete mode 100644 tests/fixtures/references.json delete mode 100644 tests/fixtures/references.yaml delete mode 100644 tests/fixtures/simple.json diff --git a/src/Console/Service/CacheOpenAPIRoutes.php b/src/Console/Service/CacheOpenAPIRoutes.php index 39cdee6..867a71a 100644 --- a/src/Console/Service/CacheOpenAPIRoutes.php +++ b/src/Console/Service/CacheOpenAPIRoutes.php @@ -8,8 +8,6 @@ use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\Reader; use Membrane\OpenAPIRouter\Exception\CannotCollectRoutes; -use Membrane\OpenAPIRouter\Exception\CannotProcessOpenAPI; -use Membrane\OpenAPIRouter\Reader\OpenAPIFileReader; use Membrane\OpenAPIRouter\RouteCollection; use Membrane\OpenAPIRouter\RouteCollector; use Psr\Log\LoggerInterface; @@ -42,7 +40,7 @@ public function cache(string $openAPIFilePath, string $cacheDestination): bool try { $routeCollection = (new RouteCollector())->collect($openApi); - } catch (CannotCollectRoutes | CannotProcessOpenAPI $e) { + } catch (CannotCollectRoutes $e) { $this->logger->error($e->getMessage()); return false; } diff --git a/tests/APIeceOfCakeTest.php b/tests/APIeceOfCakeTest.php deleted file mode 100644 index 7e42c3d..0000000 --- a/tests/APIeceOfCakeTest.php +++ /dev/null @@ -1,81 +0,0 @@ -readFromAbsoluteFilePath($apiFilePath); - $routeCollection = (new RouteCollector())->collect($openAPI); - - // I expect to have no hosted routes - self::assertEmpty($routeCollection->routes['hosted']['static']); - self::assertEmpty($routeCollection->routes['hosted']['dynamic']['servers']); - - // I expect to have one hostless static route - self::assertSame( - 'findSpongeCakes', - $routeCollection->routes['hostless']['static']['']['static']['/cakes/sponge']['get'] - ); - - // I expect to have the following hostless dynamic routes - $hostlessDynamicRoutes = [ - '/cakes/{icing}' => ['get' => 'findCakesByIcing', 'post' => 'addCakesByIcing'], - '/{cakeType}/sponge' => ['get' => 'findSpongeByDesserts'], - '/{cakeType}/{icing}' => ['get' => 'findDessertByIcing', 'post' => 'addDessertByIcing'], - ]; - - self::assertSame( - $hostlessDynamicRoutes, - $routeCollection->routes['hostless']['static']['']['dynamic']['paths'] - ); - - return $routeCollection; - } - - #[Test, TestDox('Completely static paths should take priority over any other')] - #[Depends('itCollectsPathsFromAPIeceOfCake')] - public function itRoutesToCompletelyStaticPathFirst(RouteCollection $routeCollection): void - { - $sut = new Router($routeCollection); - - $actualOperationId = $sut->route('/cakes/sponge', 'get'); - - self::assertSame('findSpongeCakes', $actualOperationId); - } - - #[Test, TestDox('Paths with less dynamic elements should take priority')] - #[Depends('itCollectsPathsFromAPIeceOfCake')] - public function itRoutesToPartiallyDynamicBeforeCompletelyDynamic(RouteCollection $routeCollection): void - { - $sut = new Router($routeCollection); - - $actualOperationId = $sut->route('/cakes/chocolate', 'get'); - - self::assertSame('findCakesByIcing', $actualOperationId); - } -} diff --git a/tests/RouteCollectorTest.php b/tests/RouteCollectorTest.php index 730e0d1..3418059 100644 --- a/tests/RouteCollectorTest.php +++ b/tests/RouteCollectorTest.php @@ -4,6 +4,7 @@ namespace Membrane\OpenAPIRouter\Tests; +use Generator; use Membrane\OpenAPIReader\FileFormat; use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\Reader; @@ -43,111 +44,129 @@ public function throwExceptionIfThereAreNoRoutes(): void (new RouteCollector())->collect($openAPI); } - public static function collectTestProvider(): array + public static function collectTestProvider(): Generator { - return [ - 'petstore-expanded.json' => [ - new RouteCollection([ - 'hosted' => [ - 'static' => [ - 'http://petstore.swagger.io/api' => [ - 'static' => [ - '/pets' => [ - 'get' => 'findPets', - 'post' => 'addPet', - ], + yield 'petstore-expanded.json' => [ + new RouteCollection([ + 'hosted' => [ + 'static' => [ + 'http://petstore.swagger.io/api' => [ + 'static' => [ + '/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' => [], - ], + 'dynamic' => [ + 'regex' => '#^(?|)#', + 'servers' => [], ], - ]), - self::FIXTURES . 'docs/petstore-expanded.json', - ], - 'WeirdAndWonderful.json' => [ - new RouteCollection([ - 'hosted' => [ - 'static' => [ - 'http://weirdest.com' => [ - 'static' => [ - '/however' => [ - 'put' => 'put-however', - 'post' => 'post-however', - ], + ], + 'hostless' => [ + 'static' => [], + 'dynamic' => [ + 'regex' => '#^(?|)#', + 'servers' => [], + ], + ], + ]), + self::FIXTURES . 'docs/petstore-expanded.json', + ]; + + yield 'WeirdAndWonderful.json' => [ + 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', - ], + ], + 'dynamic' => [ + 'regex' => '#^(?|/and/([^/]+)(*MARK:/and/{name}))$#', + 'paths' => [ + '/and/{name}' => [ + 'get' => 'get-and', ], ], ], - 'http://weirder.co.uk' => [ - 'static' => [ - '/however' => [ - 'get' => 'get-however', - ], + ], + 'http://weirder.co.uk' => [ + 'static' => [ + '/however' => [ + 'get' => 'get-however', ], - 'dynamic' => [ - 'regex' => '#^(?|/and/([^/]+)(*MARK:/and/{name}))$#', - 'paths' => [ - '/and/{name}' => [ - 'put' => 'put-and', - 'post' => 'post-and', - ], + ], + 'dynamic' => [ + 'regex' => '#^(?|/and/([^/]+)(*MARK:/and/{name}))$#', + 'paths' => [ + '/and/{name}' => [ + 'put' => 'put-and', + 'post' => 'post-and', ], ], ], - 'http://wonderful.io' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], + ], + 'http://wonderful.io' => [ + 'static' => [ + '/or' => [ + 'post' => 'post-or', ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], + '/xor' => [ + 'delete' => 'delete-xor', ], ], - 'http://wonderful.io/and' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], + 'dynamic' => [ + 'regex' => '#^(?|)$#', + 'paths' => [], + ], + ], + 'http://wonderful.io/and' => [ + 'static' => [ + '/or' => [ + 'post' => 'post-or', ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], + '/xor' => [ + 'delete' => 'delete-xor', + ], + ], + 'dynamic' => [ + 'regex' => '#^(?|)$#', + 'paths' => [], + ], + ], + 'http://wonderful.io/or' => [ + 'static' => [ + '/or' => [ + 'post' => 'post-or', ], + '/xor' => [ + 'delete' => 'delete-xor', + ], + ], + 'dynamic' => [ + 'regex' => '#^(?|)$#', + 'paths' => [], ], - 'http://wonderful.io/or' => [ + ], + ], + 'dynamic' => [ + 'regex' => '#^(?|http://weird.io/([^/]+)(*MARK:http://weird.io/{conjunction}))#', + 'servers' => [ + 'http://weird.io/{conjunction}' => [ 'static' => [ '/or' => [ 'post' => 'post-or', @@ -162,43 +181,43 @@ public static function collectTestProvider(): array ], ], ], - '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' => [ - 'static' => [ - '' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], + '/v1' => [ + 'static' => [ + '/or' => [ + 'post' => 'post-or', ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], + '/xor' => [ + 'delete' => 'delete-xor', ], ], - '/v1' => [ + 'dynamic' => [ + 'regex' => '#^(?|)$#', + 'paths' => [], + ], + ], + ], + 'dynamic' => [ + 'regex' => '#^(?|/([^/]+)(*MARK:/{version}))#', + 'servers' => [ + '/{version}' => [ 'static' => [ '/or' => [ 'post' => 'post-or', @@ -213,29 +232,39 @@ public static function collectTestProvider(): array ], ], ], - 'dynamic' => [ - 'regex' => '#^(?|/([^/]+)(*MARK:/{version}))#', - 'servers' => [ - '/{version}' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], - ], - ], + ], + ], + ]), + self::FIXTURES . 'WeirdAndWonderful.json', + ]; + yield 'APieceOfCake.json' => [ + new RouteCollection([ + 'hosted' => ['static' => [], 'dynamic' => ['regex' => '#^(?|)#', 'servers' => []]], + 'hostless' => [ + 'static' => [ + '' => [ + 'static' => ['/cakes/sponge' => ['get' => 'findSpongeCakes']], + 'dynamic' => [ + 'regex' => '#^(?|' . + '/cakes/([^/]+)(*MARK:/cakes/{icing})|' . + '/([^/]+)/sponge(*MARK:/{cakeType}/sponge)|' . + '/([^/]+)/([^/]+)(*MARK:/{cakeType}/{icing})' . + ')$#', + 'paths' => [ + '/cakes/{icing}' => ['get' => 'findCakesByIcing', 'post' => 'addCakesByIcing'], + '/{cakeType}/sponge' => ['get' => 'findSpongeByDesserts'], + '/{cakeType}/{icing}' => [ + 'get' => 'findDessertByIcing', + 'post' => 'addDessertByIcing' + ] + ] + ] ], ], - ]), - self::FIXTURES . 'WeirdAndWonderful.json', - ], + 'dynamic' => ['regex' => '#^(?|)#', 'servers' => []] + ], + ]), + self::FIXTURES . 'APIeceOfCake.json' ]; } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 2216b91..b8abd2c 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -4,208 +4,47 @@ namespace Membrane\OpenAPIRouter\Tests; +use Generator; +use Membrane\OpenAPIReader\OpenAPIVersion; +use Membrane\OpenAPIReader\Reader; use Membrane\OpenAPIRouter\Exception\CannotRouteRequest; use Membrane\OpenAPIRouter\RouteCollection; +use Membrane\OpenAPIRouter\RouteCollector; use Membrane\OpenAPIRouter\Router; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; #[CoversClass(Router::class)] #[CoversClass(CannotRouteRequest::class)] class RouterTest extends TestCase { + private const FIXTURES = __DIR__ . '/fixtures/'; + private static function getPetStoreRouteCollection(): RouteCollection { - return new RouteCollection([ - 'hosted' => [ - 'static' => [ - 'http://petstore.swagger.io/api' => [ - 'static' => [ - '/pets' => [ - 'get' => 'findPets', - 'post' => 'addPet', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|/pets/([^/]+)(*MARK:/pets/{id}))$#', - 'paths' => [ - '/pets/{id}' => [ - 'get' => 'find pet by id', - 'delete' => 'deletePet', - ], - ], - ], - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)#', - 'servers' => [], - ], - ], - 'hostless' => [ - 'static' => [], - 'dynamic' => [ - 'regex' => '#^(?|)#', - 'servers' => [], - ], - ], - ]); + $openAPI = (new Reader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath(self::FIXTURES . 'docs/petstore-expanded.json'); + + return (new RouteCollector())->collect($openAPI); } private static function getWeirdAndWonderfulRouteCollection(): RouteCollection { - return 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' => '#^(?|/and/([^/]+)(*MARK:/and/{name}))$#', - 'paths' => [ - '/and/{name}' => [ - 'put' => 'put-and', - 'post' => 'post-and', - ], - ], - ], - ], - 'http://wonderful.io' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], - ], - 'http://wonderful.io/and' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], - ], - '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', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], - ], - ], - ], - ], - 'hostless' => [ - 'static' => [ - '' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], - ], - '/v1' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|/([^/]+)(*MARK:/{version}))#', - 'servers' => [ - '/{version}' => [ - 'static' => [ - '/or' => [ - 'post' => 'post-or', - ], - '/xor' => [ - 'delete' => 'delete-xor', - ], - ], - 'dynamic' => [ - 'regex' => '#^(?|)$#', - 'paths' => [], - ], - ], - ], - ], - ], - ]); + $openAPI = (new Reader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath(self::FIXTURES . 'WeirdAndWonderful.json'); + + return (new RouteCollector())->collect($openAPI); + } + + private static function getAPieceOfCakeRouteCollection(): RouteCollection + { + $openAPI = (new Reader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath(self::FIXTURES . 'APIeceOfCake.json'); + + return (new RouteCollector())->collect($openAPI); } public static function unsuccessfulRouteProvider(): array @@ -315,4 +154,35 @@ public function successfulRouteTest( self::assertSame($expected, $actual); } + + public static function provideRoutingPriorities(): Generator + { + yield 'completely static path prioritise over anything dynamic' => [ + 'findSpongeCakes', + '/cakes/sponge', + 'get', + self::getAPieceOfCakeRouteCollection() + ]; + yield 'partially dynamic path to prioritise over anything with more dynamic parts' => [ + 'findCakesByIcing', + '/cakes/chocolate', + 'get', + self::getAPieceOfCakeRouteCollection() + ]; + } + + #[Test, TestDox('When routing the priority will be paths with less dynamic components first')] + #[DataProvider('provideRoutingPriorities')] + public function itWillPrioritiseRoutesWithMoreStaticComponentsFirst( + string $expectedOperationId, + string $url, + string $method, + RouteCollection $routeCollection + ): void { + $sut = new Router($routeCollection); + + $actualOperationId = $sut->route($url, $method); + + self::assertSame($expectedOperationId, $actualOperationId); + } } diff --git a/tests/fixtures/docs/petstore.yaml b/tests/fixtures/docs/petstore.yaml deleted file mode 100644 index 5910232..0000000 --- a/tests/fixtures/docs/petstore.yaml +++ /dev/null @@ -1,113 +0,0 @@ -openapi: "3.0.0" -info: - version: 1.0.0 - title: Swagger Petstore - license: - name: MIT -servers: - - url: http://petstore.swagger.io/v1 -paths: - /pets: - get: - summary: List all pets - operationId: listPets - tags: - - pets - parameters: - - name: limit - in: query - description: How many items to return at one time (max 100) - required: false - schema: - type: integer - maximum: 100 - format: int32 - responses: - '200': - description: A paged array of pets - headers: - x-next: - description: A link to the next page of responses - schema: - type: string - content: - application/json: - schema: - $ref: "#/components/schemas/Pets" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create a pet - operationId: createPets - tags: - - pets - responses: - '201': - description: Null response - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /pets/{petId}: - get: - summary: Info for a specific pet - operationId: showPetById - tags: - - pets - parameters: - - name: petId - in: path - required: true - description: The id of the pet to retrieve - schema: - type: string - responses: - '200': - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Pet" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" -components: - schemas: - Pet: - type: object - required: - - id - - name - properties: - id: - type: integer - format: int64 - name: - type: string - tag: - type: string - Pets: - type: array - maxItems: 100 - items: - $ref: "#/components/schemas/Pet" - Error: - type: object - required: - - code - - message - properties: - code: - type: integer - format: int32 - message: - type: string diff --git a/tests/fixtures/noReferences.json b/tests/fixtures/noReferences.json deleted file mode 100644 index 58b6297..0000000 --- a/tests/fixtures/noReferences.json +++ /dev/null @@ -1,1646 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Test API", - "version": "1.0.0" - }, - "servers": [ - { - "url": "http://www.test.com" - }, - { - "url": "http://www.test.com/path", - "description": "this server contains the path name '/path' inside" - } - ], - "paths": { - "/emptypath": { - "description": "/emptypath has no operations" - }, - "/path": { - "description": "/path has all operations, only DELETE has a default response", - "get": { - "description": "Get operation on /path, no requestBody", - "responses": { - "200": { - "description": "successful response, no content" - } - } - }, - "post": { - "requestBody": { - "description": "Post operation on /path, requestBody->content is empty", - "required": true, - "content": {} - }, - "responses": { - "200": { - "description": "successful response with content that is empty", - "content": {} - } - } - }, - "put": { - "requestBody": { - "description": "Put operation on /path, requestBody content is not application/json", - "required": true, - "content": { - "application/pdf": { - "schema": { - "type": "integer" - } - } - } - }, - "responses": { - "200": { - "description": "successful response with content that is not application/json", - "content": { - "application/pdf": { - "schema": { - "type": "integer" - } - } - } - } - } - }, - "delete": { - "requestBody": { - "description": "Delete operation on /path, requestBody content is application/json", - "required": true, - "content": { - "application/json": { - "schema": { - "type": "integer" - } - } - } - }, - "responses": { - "200": { - "description": "successful response with content that is application/json", - "content": { - "application/json": { - "schema": { - "type": "integer" - } - } - } - }, - "default": { - "description": "default response with content that is application/json", - "content": { - "application/json": { - "schema": { - "type": "integer" - } - } - } - } - } - } - }, - "/parampath/{id}": { - "description": "/parampath has a parameter in the path, applying to all operations", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - } - ], - "get": { - "description": "Get operation in /parampath/{id}, should have two parameters, 'id' and 'name'", - "parameters": [ - { - "name": "name", - "in": "header", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "successful response" - } - } - } - }, - "/requestpathexceptions": { - "get": { - "parameters": [ - { - "name": "id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "" - } - } - }, - "post": { - "parameters": [ - { - "name": "id", - "in": "query", - "content": { - "application/pdf": { - "schema": { - "type": "integer" - } - } - } - } - ], - "responses": { - "200": { - "description": "" - } - } - } - }, - "/requestpathone/{id}": { - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "get": { - "responses": { - "200": { - "description": "" - } - } - }, - "post": { - "parameters": [ - { - "name": "names", - "in": "query", - "schema": { - "type": "array" - } - } - ], - "responses": { - "200": { - "description": "" - } - } - }, - "put": { - "parameters": [ - { - "name": "name", - "in": "query", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - } - }, - "delete": { - "parameters": [ - { - "name": "name", - "in": "query", - "required": true, - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - ], - "responses": { - "200": { - "description": "" - } - } - } - }, - "/requestpathtwo": { - "parameters": [ - { - "name": "id", - "in": "header", - "schema": { - "type": "integer" - } - } - ], - "get": { - "responses": { - "200": { - "description": "" - } - } - }, - "post": { - "parameters": [ - { - "name": "name", - "in": "cookie", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - } - }, - "put": { - "parameters": [ - { - "name": "id", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "" - } - } - }, - "delete": { - "parameters": [ - { - "name": "id", - "in": "header", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - } - } - }, - "/requestbodypath": { - "get": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "integer" - } - } - } - }, - "responses": { - "200": { - "description": "" - } - } - }, - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "integer" - } - } - } - }, - "parameters": [ - { - "name": "id", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - } - } - }, - "/requestbodypath/{id}": { - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "get": { - "parameters": [ - { - "name": "name", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "species", - "in": "header", - "schema": { - "type": "string" - } - }, - { - "name": "subspecies", - "in": "cookie", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "number", - "format": "float" - } - } - } - }, - "responses": { - "200": { - "description": "" - } - } - } - }, - "/responsepath": { - "get": { - "responses": { - "200": { - "description": "int", - "content": { - "application/json": { - "schema": { - "type": "integer" - } - } - } - }, - "201": { - "description": "nullable int", - "content": { - "application/json": { - "schema": { - "type": "integer", - "nullable": true - } - } - } - }, - "202": { - "description": "int, inclusive min", - "content": { - "application/json": { - "schema": { - "type": "integer", - "minimum": 0 - } - } - } - }, - "203": { - "description": "int, exclusive min", - "content": { - "application/json": { - "schema": { - "type": "integer", - "minimum": 0, - "exclusiveMinimum": true - } - } - } - }, - "204": { - "description": "int, inclusive max", - "content": { - "application/json": { - "schema": { - "type": "integer", - "maximum": 100 - } - } - } - }, - "205": { - "description": "int, exclusive max", - "content": { - "application/json": { - "schema": { - "type": "integer", - "maximum": 100, - "exclusiveMaximum": true - } - } - } - }, - "206": { - "description": "int, multipleOf", - "content": { - "application/json": { - "schema": { - "type": "integer", - "multipleOf": 3 - } - } - } - }, - "207": { - "description": "int, enum", - "content": { - "application/json": { - "schema": { - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] - } - } - } - }, - "209": { - "description": "nullable int, exclusive min, inclusive max, multipleOf, enum", - "content": { - "application/json": { - "schema": { - "type": "integer", - "minimum": 0, - "exclusiveMinimum": true, - "maximum": 100, - "multipleOf": 3, - "enum": [ - 1, - 2, - 3 - ], - "nullable": true - } - } - } - }, - "210": { - "description": "number", - "content": { - "application/json": { - "schema": { - "type": "number" - } - } - } - }, - "211": { - "description": "nullable number", - "content": { - "application/json": { - "schema": { - "type": "number", - "nullable": true - } - } - } - }, - "212": { - "description": "number, enum", - "content": { - "application/json": { - "schema": { - "type": "number", - "enum": [ - 1, - 2.3, - 4 - ] - } - } - } - }, - "213": { - "description": "number, float format", - "content": { - "application/json": { - "schema": { - "type": "number", - "format": "float" - } - } - } - }, - "214": { - "description": "nullable number, float format", - "content": { - "application/json": { - "schema": { - "type": "number", - "format": "float", - "nullable": true - } - } - } - }, - "215": { - "description": "number, double format", - "content": { - "application/json": { - "schema": { - "type": "number", - "format": "double" - } - } - } - }, - "219": { - "description": "nullable number, enum, inclusive min, exclusive max, multipleOf", - "content": { - "application/json": { - "schema": { - "type": "number", - "minimum": 6.66, - "maximum": 99.99, - "exclusiveMaximum": true, - "multipleOf": 3.33, - "enum": [ - 1, - 2.3, - 4 - ], - "nullable": true - } - } - } - }, - "220": { - "description": "string", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - }, - "221": { - "description": "nullable string", - "content": { - "application/json": { - "schema": { - "type": "string", - "nullable": true - } - } - } - }, - "222": { - "description": "string, enum", - "content": { - "application/json": { - "schema": { - "type": "string", - "enum": [ - "a", - "b", - "c" - ] - } - } - } - }, - "223": { - "description": "string, date format", - "content": { - "application/json": { - "schema": { - "type": "string", - "format": "date" - } - } - } - }, - "224": { - "description": "string, date-time format", - "content": { - "application/json": { - "schema": { - "type": "string", - "format": "date-time" - } - } - } - }, - "225": { - "description": "string, minLength", - "content": { - "application/json": { - "schema": { - "type": "string", - "minLength": 5 - } - } - } - }, - "226": { - "description": "string, maxLength", - "content": { - "application/json": { - "schema": { - "type": "string", - "maxLength": 10 - } - } - } - }, - "227": { - "description": "string, pattern", - "content": { - "application/json": { - "schema": { - "type": "string", - "pattern": "[A-Za-z]+" - } - } - } - }, - "229": { - "description": "nullable string, enum, minLength, maxLength, pattern", - "content": { - "application/json": { - "schema": { - "type": "string", - "minLength": 5, - "maxLength": 10, - "pattern": "[A-Za-z]+", - "enum": [ - "a", - "b", - "c" - ], - "nullable": true - } - } - } - }, - "230": { - "description": "bool", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "231": { - "description": "nullable bool", - "content": { - "application/json": { - "schema": { - "type": "boolean", - "nullable": true - } - } - } - }, - "232": { - "description": "bool, enum", - "content": { - "application/json": { - "schema": { - "type": "boolean", - "enum": [ - true - ] - } - } - } - }, - "239": { - "description": "nullable bool, enum", - "content": { - "application/json": { - "schema": { - "type": "boolean", - "enum": [ - true, - null - ], - "nullable": true - } - } - } - }, - "240": { - "description": "array of ints", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "integer" - } - } - } - } - }, - "241": { - "description": "array of strings, enum", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - }, - "enum": [ - [ - "a", - "b", - "c" - ], - [ - "d", - "e", - "f" - ] - ] - } - } - } - }, - "242": { - "description": "nullable array of strings", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - } - } - } - }, - "243": { - "description": "array of booleans, minItems", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "boolean" - }, - "minItems": 5 - } - } - } - }, - "244": { - "description": "array of floats, maxItems", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "number", - "format": "double" - }, - "maxItems": 5 - } - } - } - }, - "245": { - "description": "array of numbers, maxItems", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "number" - }, - "uniqueItems": true - } - } - } - }, - "269": { - "description": "nullable array of nullable numbers, minItems, maxItems, uniqueItems", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "number", - "nullable": true - }, - "enum": [ - [ - 1, - 2.0, - null - ], - [ - 4.0, - null, - 6 - ] - ], - "uniqueItems": true, - "minItems": 2, - "maxItems": 5, - "nullable": true - } - } - } - }, - "270": { - "description": "object with (string) name", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "271": { - "description": "object with (integer) id, enum", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - }, - "enum": [ - { - "id": 5 - }, - { - "id": 10 - } - ] - } - } - } - }, - "272": { - "description": "nullable object with (float) price", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "price": { - "type": "number", - "format": "float" - } - }, - "nullable": true - } - } - } - }, - "273": { - "description": "object with (string) name, (int) id, (bool) status", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "status": { - "type": "boolean" - } - } - } - } - } - }, - "274": { - "description": "object with (string) name, (int) id, (bool) status, required", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "status": { - "type": "boolean" - } - }, - "required": [ - "name", - "id" - ] - } - } - } - }, - "299": { - "description": "nullable object with (string) name, (int) id, (bool) status, enum, required", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "status": { - "type": "boolean" - } - }, - "enum": [ - { - "name": "Ben", - "id": 5, - "status": true - }, - { - "name": "Blink", - "id": 10, - "status": true - } - ], - "required": [ - "name", - "id" - ] - } - } - } - }, - "300": { - "description": "allOf, one object", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - ] - } - } - } - }, - "301": { - "description": "allOf,two objects, identical parameter", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - } - ] - } - } - } - }, - "302": { - "description": "allOf, two objects, unique parameters", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - ] - } - } - } - }, - "303": { - "description": "allOf, two objects, conflicting parameters", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - ] - } - } - } - }, - "304": { - "description": "allOf, two objects, unique parameters, one requiredField", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" - ] - } - ] - } - } - } - }, - "305": { - "description": "allOf, two objects, unique parameters, two requiredFields", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - }, - "required": [ - "id" - ] - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" - ] - } - ] - } - } - } - }, - "306": { - "description": "allOf, two objects, unique parameters, two requiredFields requiring the other schemas property", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - }, - "required": [ - "name" - ] - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "id" - ] - } - ] - } - } - } - }, - "320": { - "description": "anyOf, one object", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - ] - } - } - } - }, - "321": { - "description": "anyOf,two objects, identical parameter", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - } - ] - } - } - } - }, - "322": { - "description": "anyOf, two objects, unique parameters", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - ] - } - } - } - }, - "323": { - "description": "anyOf, two objects, conflicting parameters", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - ] - } - } - } - }, - "324": { - "description": "anyOf, two objects, unique parameters, one requiredField", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" - ] - } - ] - } - } - } - }, - "325": { - "description": "anyOf, two objects, unique parameters, two requiredFields", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - }, - "required": [ - "id" - ] - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" - ] - } - ] - } - } - } - }, - "326": { - "description": "anyOf, two objects, unique parameters, two requiredFields requiring the other schemas property", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - }, - "required": [ - "name" - ] - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "id" - ] - } - ] - } - } - } - }, - "340": { - "description": "oneOf, one object", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - ] - } - } - } - }, - "341": { - "description": "oneOf,two objects, identical parameter", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - } - ] - } - } - } - }, - "342": { - "description": "oneOf, two objects, unique parameters", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - ] - } - } - } - }, - "343": { - "description": "oneOf, two objects, conflicting parameters", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - ] - } - } - } - }, - "344": { - "description": "oneOf, two objects, unique parameters, one requiredField", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" - ] - } - ] - } - } - } - }, - "345": { - "description": "oneOf, two objects, unique parameters, two requiredFields", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - }, - "required": [ - "id" - ] - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" - ] - } - ] - } - } - } - }, - "346": { - "description": "oneOf, two objects, unique parameters, two requiredFields requiring the other schemas property", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - }, - "required": [ - "name" - ] - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "id" - ] - } - ] - } - } - } - }, - "360": { - "description": "'not' a string", - "content": { - "application/json": { - "schema": { - "not": { - "type": "string" - } - } - } - } - }, - "404": { - "description": "schema with no specified type", - "content": { - "application/json": { - "schema": { - } - } - } - } - } - } - } - } -} diff --git a/tests/fixtures/references.json b/tests/fixtures/references.json deleted file mode 100644 index 73e2f5a..0000000 --- a/tests/fixtures/references.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Test API", - "version": "1.0.0" - }, - "servers": [ - { - "url": "http://test.com" - } - ], - "paths": { - "/path": { - "get": { - "parameters": [ - { - "name": "id", - "in": "header", - "required": true, - "schema": { - "$ref": "#/components/schemas/id" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200" - } - } - } - } - }, - "components": { - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "integer" - } - } - } - } - }, - "schemas": { - "id": { - "type": "integer" - } - } - } -} diff --git a/tests/fixtures/references.yaml b/tests/fixtures/references.yaml deleted file mode 100644 index d4311db..0000000 --- a/tests/fixtures/references.yaml +++ /dev/null @@ -1,30 +0,0 @@ ---- -openapi: 3.0.0 -info: - title: Test API - version: 1.0.0 -servers: - - url: http://test.com -paths: - "/path": - get: - parameters: - - name: id - in: header - required: true - schema: - "$ref": "#/components/schemas/id" - responses: - '200': - "$ref": "#/components/responses/200" -components: - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: integer - schemas: - id: - type: integer diff --git a/tests/fixtures/simple.json b/tests/fixtures/simple.json deleted file mode 100644 index 6d03897..0000000 --- a/tests/fixtures/simple.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Test API", - "version": "1.0.0" - }, - "paths": { - "/path": { - } - } -} From 10028d70bfe009fe7189f41025203bd021afa7a5 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 7 Sep 2023 08:36:41 +0100 Subject: [PATCH 6/8] Fix README linting --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9c9b19c..4113848 100644 --- a/README.md +++ b/README.md @@ -5,31 +5,31 @@ To make sure it runs quickly we've used techniques inspired by [Nikita Popov](https://www.npopov.com/2014/02/18/Fast-request-routing-using-regular-expressions.html) and [Nicolas Grekas](https://nicolas-grekas.medium.com/making-symfonys-router-77-7x-faster-1-2-958e3754f0e1). -# Requirements +## Requirements - A valid [OpenAPI specification](https://github.com/OAI/OpenAPI-Specification#readme). - An operationId on all [Operation Objects](https://spec.openapis.org/oas/v3.1.0#operation-object) so that each route is uniquely identifiable. -# Rules +## Rules -## Naming Conventions +### Naming Conventions - Forward slashes at the end of a server url will be ignored since [paths MUST begin with a forward slash.](https://spec.openapis.org/oas/v3.1.0#paths-object) - [Dynamic paths which are identical other than the variable names MUST NOT exist.](https://spec.openapis.org/oas/v3.1.0#paths-object) -## Routing Priorities +### 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. -# Installation +## Installation ```text composer require membrane/openapi-router ``` -# Quick Start +## Quick Start To read routes dynamically, you can do the following: @@ -47,7 +47,7 @@ $requestedOperationId = $router->route('http://petstore.swagger.io/v1/pets', 'ge echo $requestedOperationId; // listPets ``` -# Caching Routes +## Caching Routes Run the following console command to cache the routes from your OpenAPI, to avoid reading your OpenAPI file everytime: From 0f768bc3d33c5e60bcb9c4db0de0947d04777e88 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 7 Sep 2023 10:16:40 +0100 Subject: [PATCH 7/8] TypeHint usort functions --- src/Route/Server.php | 5 ++++- src/RouteCollection.php | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Route/Server.php b/src/Route/Server.php index 3291219..969a72f 100644 --- a/src/Route/Server.php +++ b/src/Route/Server.php @@ -54,7 +54,10 @@ public function isHosted(): bool public function jsonSerialize(): array { $filteredPaths = array_filter($this->paths, fn($p) => !$p->isEmpty()); - usort($filteredPaths, fn($a, $b) => $a->howManyDynamicComponents() <=> $b->howManyDynamicComponents()); + usort( + $filteredPaths, + fn(Path $a, Path $b) => $a->howManyDynamicComponents() <=> $b->howManyDynamicComponents() + ); $staticPaths = $dynamicPaths = $regex = []; foreach ($filteredPaths as $path) { diff --git a/src/RouteCollection.php b/src/RouteCollection.php index c681a52..941fb5c 100644 --- a/src/RouteCollection.php +++ b/src/RouteCollection.php @@ -46,7 +46,10 @@ public function __construct( public static function fromServers(Server ...$servers): self { $filteredServers = array_filter($servers, fn($s) => !$s->isEmpty()); - usort($filteredServers, fn($a, $b) => $a->howManyDynamicComponents() <=> $b->howManyDynamicComponents()); + usort( + $filteredServers, + fn(Server $a, Server $b) => $a->howManyDynamicComponents() <=> $b->howManyDynamicComponents() + ); $hostedServers = $hostlessServers = []; foreach ($filteredServers as $server) { From 86b1f8746ea124f768025d9a983b0bd40c58a3b9 Mon Sep 17 00:00:00 2001 From: John Charman Date: Fri, 22 Sep 2023 10:26:55 +0100 Subject: [PATCH 8/8] Upgrade Reader dependency to 1.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index df7b99e..ff07687 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "require": { "php": "^8.1.0", "cebe/php-openapi": "^1.7", - "membrane/openapi-reader": "dev-main", + "membrane/openapi-reader": "^1.0.0", "psr/http-message": "^1.0 || ^2.0", "psr/log": "^3.0", "symfony/console": "^6.2"