diff --git a/src/Console/Service/CacheOpenAPIRoutes.php b/src/Console/Service/CacheOpenAPIRoutes.php index 50d09b7..1e16759 100644 --- a/src/Console/Service/CacheOpenAPIRoutes.php +++ b/src/Console/Service/CacheOpenAPIRoutes.php @@ -9,7 +9,7 @@ use Membrane\OpenAPIRouter\Exception\CannotRouteOpenAPI; use Membrane\OpenAPIRouter\Reader\OpenAPIFileReader; use Membrane\OpenAPIRouter\Router\Collector\RouteCollector; -use Membrane\OpenAPIRouter\Router\ValueObject\RouteCollection; +use Membrane\OpenAPIRouter\Router\RouteCollection; use Psr\Log\LoggerInterface; class CacheOpenAPIRoutes diff --git a/src/Router/Collector/RouteCollector.php b/src/Router/Collector/RouteCollector.php index 1199ed0..45256a2 100644 --- a/src/Router/Collector/RouteCollector.php +++ b/src/Router/Collector/RouteCollector.php @@ -9,42 +9,73 @@ use cebe\openapi\spec\PathItem; use Membrane\OpenAPIRouter\Exception\CannotProcessOpenAPI; use Membrane\OpenAPIRouter\Exception\CannotRouteOpenAPI; -use Membrane\OpenAPIRouter\Router\ValueObject\Route; -use Membrane\OpenAPIRouter\Router\ValueObject\RouteCollection; +use Membrane\OpenAPIRouter\Router\Route\Route; +use Membrane\OpenAPIRouter\Router\Route\Server as ServerRoute; +use Membrane\OpenAPIRouter\Router\RouteCollection; class RouteCollector { public function collect(OpenApi $openApi): RouteCollection { - $routes = $this->collectRoutes($openApi); + $collection = $this->collectRoutes($openApi); - if ($routes === []) { + if ($collection === []) { throw CannotRouteOpenAPI::noRoutes(); } - return $this->sortRoutes($this->mergeRoutes(...$routes)); + return RouteCollection::fromServers(...$collection); } - /** @return Route[] */ + /** @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 { + $collection = []; + $rootServers = $this->getServers($openApi); + foreach ($rootServers as $url => $regex) { + $collection[$url] ??= new ServerRoute($url, $regex); + } + $operationIds = []; foreach ($openApi->paths as $path => $pathObject) { + $pathRegex = $this->getRegex($path); + $pathServers = $this->getServers($pathObject); - foreach ($pathObject->getOperations() as $operation => $operationObject) { + foreach ($pathServers as $url => $regex) { + $collection[$url] ??= new ServerRoute($url, $regex); + } + + foreach ($pathObject->getOperations() as $method => $operationObject) { $operationServers = $this->getServers($operationObject); + foreach ($operationServers as $url => $regex) { + $collection[$url] ??= new ServerRoute($url, $regex); + } // TODO remove this conditional once OpenAPIFileReader requires operationId if ($operationObject->operationId === null) { - throw CannotProcessOpenAPI::missingOperationId($path, $operation); + throw CannotProcessOpenAPI::missingOperationId($path, $method); } if (isset($operationIds[$operationObject->operationId])) { throw CannotProcessOpenAPI::duplicateOperationId( $operationObject->operationId, $operationIds[$operationObject->operationId], - ['path' => $path, 'operation' => $operation] + ['path' => $path, 'operation' => $method] ); } @@ -56,152 +87,14 @@ private function collectRoutes(OpenApi $openApi): array $servers = $rootServers; } - $collection[] = new Route( - $servers, - $path, - $operation, - $operationObject->operationId - ); - - $operationIds[$operationObject->operationId] = [ - 'path' => $path, - 'operation' => $operation - ]; - } - } - return $collection ?? []; - } - - /** @return string[] */ - private function getServers(OpenApi|PathItem|Operation $object): array - { - return array_unique(array_map(fn($p) => rtrim($p->url, '/'), $object->servers)); - } - - /** @return string[][][] */ - private function mergeRoutes(Route ...$routes): array - { - foreach ($routes as $route) { - foreach ($route->servers as $server) { - $routesArray[$server][$route->path][$route->method] = $route->operationId; - } - } - - return $routesArray ?? []; - } - - /** @param string[][][] $routes */ - private function sortRoutes(array $routes): RouteCollection - { - $routesWithSortedPaths = []; - - foreach ($routes as $server => $paths) { - $routesWithSortedPaths[$server] = $this->sortPaths($paths); - } - - $routesWithSortedServers = $this->sortServers($routesWithSortedPaths); - - return $routesWithSortedServers; - } - - /** - * @param string[][] $paths - * @return array{ - * 'static': string[][], - * 'dynamic': array{ - * 'regex': string, - * 'paths': string[][] - * } - * } - */ - private function sortPaths(array $paths): array - { - $staticPaths = $dynamicPaths = $groupRegex = []; - - foreach ($paths as $path => $operations) { - $pathRegex = $this->getRegex($path); - if ($path === $pathRegex) { - $staticPaths[$path] = $operations; - } else { - $dynamicPaths[$path] = $operations; - $groupRegex[] = sprintf('%s(*MARK:%s)', $pathRegex, $path); - } - } - - return [ - 'static' => $staticPaths, - 'dynamic' => [ - 'regex' => sprintf('#^(?|%s)$#', implode('|', $groupRegex)), - 'paths' => $dynamicPaths, - ], - ]; - } - - /** - * @param array $servers - */ - private function sortServers(array $servers): RouteCollection - { - $hostedServers = $hostlessServers = []; - foreach ($servers as $server => $paths) { - if (parse_url($server, PHP_URL_HOST) === null) { - $hostlessServers[$server] = $paths; - } else { - $hostedServers[$server] = $paths; - } - } - - $hostedStaticServers = $hostedDynamicServers = $hostedGroupRegex = []; - foreach ($hostedServers as $server => $paths) { - $serverRegex = $this->getRegex($server); - if ($server === $serverRegex) { - $hostedStaticServers[$server] = $paths; - } else { - $hostedDynamicServers[$server] = $paths; - $hostedGroupRegex[] = sprintf('%s(*MARK:%s)', $serverRegex, $server); - } - } + foreach ($servers as $url => $regex) { + $collection[$url]->addRoute(new Route($path, $pathRegex, $method, $operationObject->operationId)); + } - $hostlessStaticServers = $hostlessDynamicServers = $hostlessGroupRegex = []; - foreach ($hostlessServers as $server => $paths) { - $serverRegex = $this->getRegex($server); - if ($server === $serverRegex) { - $hostlessStaticServers[$server] = $paths; - } else { - $hostlessDynamicServers[$server] = $paths; - $hostlessGroupRegex[] = sprintf('%s(*MARK:%s)', $serverRegex, $server); + $operationIds[$operationObject->operationId] = ['path' => $path, 'operation' => $method]; } } - return new RouteCollection([ - 'hosted' => [ - 'static' => $hostedStaticServers, - 'dynamic' => [ - 'regex' => sprintf('#^(?|%s)#', implode('|', $hostedGroupRegex)), - 'servers' => $hostedDynamicServers, - ], - ], - 'hostless' => [ - 'static' => $hostlessStaticServers, - 'dynamic' => [ - 'regex' => sprintf('#^(?|%s)#', implode('|', $hostlessGroupRegex)), - 'servers' => $hostlessDynamicServers, - ], - ], - ]); - } - - 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_filter($collection, fn($s) => !$s->isEmpty()); } } diff --git a/src/Router/Route/Path.php b/src/Router/Route/Path.php new file mode 100644 index 0000000..77b89fd --- /dev/null +++ b/src/Router/Route/Path.php @@ -0,0 +1,45 @@ + */ + private array $operations = []; + + public function __construct( + public readonly string $url, + public readonly string $regex, + ) { + } + + public function addRoute(Route $route): void + { + $this->operations[$route->method] = $route->operationId; + } + + public function isDynamic(): bool + { + return $this->url !== $this->regex; + } + + public function howManyDynamicComponents(): int + { + return substr_count($this->regex, '([^/]+)'); + } + + public function isEmpty(): bool + { + return count($this->operations) === 0; + } + + /** @return array */ + public function jsonSerialize(): array + { + return [...$this->operations]; + } +} diff --git a/src/Router/ValueObject/Route.php b/src/Router/Route/Route.php similarity index 62% rename from src/Router/ValueObject/Route.php rename to src/Router/Route/Route.php index 999c46a..5547f5c 100644 --- a/src/Router/ValueObject/Route.php +++ b/src/Router/Route/Route.php @@ -2,14 +2,13 @@ declare(strict_types=1); -namespace Membrane\OpenAPIRouter\Router\ValueObject; +namespace Membrane\OpenAPIRouter\Router\Route; class Route { - /** @param string[] $servers */ public function __construct( - public readonly array $servers, public readonly string $path, + public readonly string $pathRegex, public readonly string $method, public readonly string $operationId ) { diff --git a/src/Router/Route/Server.php b/src/Router/Route/Server.php new file mode 100644 index 0000000..b04ae6d --- /dev/null +++ b/src/Router/Route/Server.php @@ -0,0 +1,84 @@ +*/ + private array $paths = []; + + public function __construct( + public readonly string $url, + public readonly string $regex, + ) { + } + + public function addRoute(Route $route): void + { + if (!isset($this->paths[$route->path])) { + $this->addPath(new Path($route->path, $route->pathRegex)); + } + + $this->paths[$route->path]->addRoute($route); + } + + public function isDynamic(): bool + { + return $this->url !== $this->regex; + } + + public function howManyDynamicComponents(): int + { + return substr_count($this->regex, '([^/]+)'); + } + + public function isEmpty(): bool + { + return count(array_filter($this->paths, fn($p) => !$p->isEmpty())) === 0; + } + + public function isHosted(): bool + { + return parse_url($this->url, PHP_URL_HOST) !== null; + } + + /** @return array{ + * 'static': array>, + * 'dynamic': array{'regex': string, 'paths': array>} + * } + */ + public function jsonSerialize(): array + { + $filteredPaths = array_filter($this->paths, fn($p) => !$p->isEmpty()); + usort($filteredPaths, fn($a, $b) => $a->howManyDynamicComponents() <=> $b->howManyDynamicComponents()); + + $staticPaths = $dynamicPaths = $regex = []; + foreach ($filteredPaths as $path) { + if ($path->isDynamic()) { + $dynamicPaths[$path->url] = $path->jsonSerialize(); + $regex[] = sprintf('%s(*MARK:%s)', $path->regex, $path->url); + } else { + $staticPaths[$path->url] = $path->jsonSerialize(); + } + } + + return [ + 'static' => $staticPaths, + 'dynamic' => [ + 'regex' => sprintf('#^(?|%s)$#', implode('|', $regex)), + 'paths' => $dynamicPaths + ] + ]; + } + + private function addPath(Path $path): void + { + if (!isset($this->paths[$path->url])) { + $this->paths[$path->url] = $path; + } + } +} diff --git a/src/Router/RouteCollection.php b/src/Router/RouteCollection.php new file mode 100644 index 0000000..92d6c9d --- /dev/null +++ b/src/Router/RouteCollection.php @@ -0,0 +1,97 @@ +, + * 'dynamic': array{ + * 'regex': string, + * 'servers': array + * } + * }, + * 'hostless' : array{ + * 'static': array, + * 'dynamic': array{ + * 'regex': string, + * 'servers': array + * } + * } + * } $routes + */ + public function __construct( + public readonly array $routes + ) { + } + + public static function fromServers(Server ...$servers): self + { + $filteredServers = array_filter($servers, fn($s) => !$s->isEmpty()); + usort($filteredServers, fn($a, $b) => $a->howManyDynamicComponents() <=> $b->howManyDynamicComponents()); + + $hostedServers = $hostlessServers = []; + foreach ($filteredServers as $server) { + if ($server->isHosted()) { + $hostedServers[$server->url] = $server; + } else { + $hostlessServers[$server->url] = $server; + } + } + + $hostedStaticServers = $hostedDynamicServers = $hostedGroupRegex = []; + foreach ($hostedServers as $server) { + if ($server->isDynamic()) { + $hostedDynamicServers[$server->url] = $server->jsonSerialize(); + $hostedGroupRegex[] = sprintf('%s(*MARK:%s)', $server->regex, $server->url); + } else { + $hostedStaticServers[$server->url] = $server->jsonSerialize(); + } + } + + $hostlessStaticServers = $hostlessDynamicServers = $hostlessGroupRegex = []; + foreach ($hostlessServers as $server) { + if ($server->isDynamic()) { + $hostlessDynamicServers[$server->url] = $server->jsonSerialize(); + $hostlessGroupRegex[] = sprintf('%s(*MARK:%s)', $server->regex, $server->url); + } else { + $hostlessStaticServers[$server->url] = $server->jsonSerialize(); + } + } + + return new self([ + 'hosted' => [ + 'static' => $hostedStaticServers, + 'dynamic' => [ + 'regex' => sprintf('#^(?|%s)#', implode('|', $hostedGroupRegex)), + 'servers' => $hostedDynamicServers, + ], + ], + 'hostless' => [ + 'static' => $hostlessStaticServers, + 'dynamic' => [ + 'regex' => sprintf('#^(?|%s)#', implode('|', $hostlessGroupRegex)), + 'servers' => $hostlessDynamicServers, + ], + ], + ]); + } +} diff --git a/src/Router/Router.php b/src/Router/Router.php index 66a5e38..eec3b0f 100644 --- a/src/Router/Router.php +++ b/src/Router/Router.php @@ -5,7 +5,6 @@ namespace Membrane\OpenAPIRouter\Router; use Membrane\OpenAPIRouter\Exception\CannotRouteRequest; -use Membrane\OpenAPIRouter\Router\ValueObject\RouteCollection; class Router { diff --git a/src/Router/ValueObject/RouteCollection.php b/src/Router/ValueObject/RouteCollection.php deleted file mode 100644 index 1e926d2..0000000 --- a/src/Router/ValueObject/RouteCollection.php +++ /dev/null @@ -1,43 +0,0 @@ -, - * 'dynamic': array{ - * 'regex': string, - * 'servers': array - * } - * }, - * 'hostless' : array{ - * 'static': array, - * 'dynamic': array{ - * 'regex': string, - * 'servers': array - * } - * } - * } $routes - */ - public function __construct( - public readonly array $routes - ) { - } -} diff --git a/tests/Console/Commands/CacheOpenAPIRoutesTest.php b/tests/Console/Commands/CacheOpenAPIRoutesTest.php index 25ced35..1222e12 100644 --- a/tests/Console/Commands/CacheOpenAPIRoutesTest.php +++ b/tests/Console/Commands/CacheOpenAPIRoutesTest.php @@ -8,8 +8,8 @@ use Membrane\OpenAPIRouter\Exception\CannotRouteOpenAPI; use Membrane\OpenAPIRouter\Reader\OpenAPIFileReader; use Membrane\OpenAPIRouter\Router\Collector\RouteCollector; -use Membrane\OpenAPIRouter\Router\ValueObject\Route; -use Membrane\OpenAPIRouter\Router\ValueObject\RouteCollection; +use Membrane\OpenAPIRouter\Router\Route\Route; +use Membrane\OpenAPIRouter\Router\RouteCollection; use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStreamDirectory; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/Console/Service/CacheOpenAPIRoutesTest.php b/tests/Console/Service/CacheOpenAPIRoutesTest.php index e3ed1ef..2b36a49 100644 --- a/tests/Console/Service/CacheOpenAPIRoutesTest.php +++ b/tests/Console/Service/CacheOpenAPIRoutesTest.php @@ -9,8 +9,8 @@ use Membrane\OpenAPIRouter\Exception\CannotRouteOpenAPI; use Membrane\OpenAPIRouter\Reader\OpenAPIFileReader; use Membrane\OpenAPIRouter\Router\Collector\RouteCollector; -use Membrane\OpenAPIRouter\Router\ValueObject\Route; -use Membrane\OpenAPIRouter\Router\ValueObject\RouteCollection; +use Membrane\OpenAPIRouter\Router\Route\Route; +use Membrane\OpenAPIRouter\Router\RouteCollection; use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStreamDirectory; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/Router/APIeceOfCakeTest.php b/tests/Router/APIeceOfCakeTest.php new file mode 100644 index 0000000..4c44762 --- /dev/null +++ b/tests/Router/APIeceOfCakeTest.php @@ -0,0 +1,78 @@ +readFromAbsoluteFilePath($apiFilePath); + $routeCollection = (new RouteCollector())->collect($api); + + // 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 + { + $expectedOperationId = 'findSpongeCakes'; + $sut = new Router($routeCollection); + + $actualOperationId = $sut->route('/cakes/sponge', 'get'); + + self::assertSame($expectedOperationId, $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); + } +} diff --git a/tests/Router/Collector/RouteCollectorTest.php b/tests/Router/Collector/RouteCollectorTest.php index 3be5c14..c4072e3 100644 --- a/tests/Router/Collector/RouteCollectorTest.php +++ b/tests/Router/Collector/RouteCollectorTest.php @@ -7,8 +7,8 @@ use cebe\openapi\Reader; use Membrane\OpenAPIRouter\Exception\CannotProcessOpenAPI; use Membrane\OpenAPIRouter\Exception\CannotRouteOpenAPI; -use Membrane\OpenAPIRouter\Router\ValueObject\Route; -use Membrane\OpenAPIRouter\Router\ValueObject\RouteCollection; +use Membrane\OpenAPIRouter\Router\Route\Route; +use Membrane\OpenAPIRouter\Router\RouteCollection; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Router/RouterTest.php b/tests/Router/RouterTest.php index 9100bd4..8edcaff 100644 --- a/tests/Router/RouterTest.php +++ b/tests/Router/RouterTest.php @@ -5,7 +5,6 @@ namespace Membrane\OpenAPIRouter\Router; use Membrane\OpenAPIRouter\Exception\CannotRouteRequest; -use Membrane\OpenAPIRouter\Router\ValueObject\RouteCollection; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/fixtures/APIeceOfCake.json b/tests/fixtures/APIeceOfCake.json new file mode 100644 index 0000000..41791b1 --- /dev/null +++ b/tests/fixtures/APIeceOfCake.json @@ -0,0 +1,114 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "APIece of Cake", + "description": "An API for paths to take to make a cake to bake", + "version": "1.0.0" + }, + "paths": { + "/{cakeType}/{icing}": { + "parameters": [ + { + "name": "cakeType", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "icing", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "findDessertByIcing", + "responses": { + "200": { + "description": "Successful Dessert Response" + } + } + }, + "post": { + "operationId": "addDessertByIcing", + "responses": { + "200": { + "description": "Successful Dessert Response" + } + } + } + }, + "/cakes/sponge": { + "get": { + "operationId": "findSpongeCakes", + "responses": { + "200": { + "description": "Successful Cake Response" + } + } + } + }, + "/cakes/{icing}": { + "parameters": [ + { + "name": "icing", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "findCakesByIcing", + "responses": { + "200": { + "description": "Successful Cake Response" + } + } + }, + "post": { + "operationId": "addCakesByIcing", + "parameters": [ + { + "name": "icing", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful Cake Response" + } + } + } + }, + "/{cakeType}/sponge": { + "parameters": [ + { + "name": "cakeType", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "operationId": "findSpongeByDesserts", + "responses": { + "200": { + "description": "Successful Sponge Response" + } + } + } + } + } +}