From 2dacd597f5b2c1a4b13527b039a068450a541101 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sat, 19 Sep 2026 20:46:44 +1200 Subject: [PATCH 01/27] Rebuild on swagger-php's spec pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the openapi-extras and docblock-annotation dependency; build directly against OpenApi\Builder / Mode::SPEC and read metadata off Spec\Operation DTOs instead of the classic annotations model. - Controller-level prefix/tags/security/responses inheritance is now core swagger-php (Spec\PathItem), so openapi-extras's Controller attribute needs no replacement. Middleware gets a small in-house Attachable (Attributes\Middleware) instead — PathItem's own attachables don't clone down to operations the way tags/security/responses do, so that inheritance is resolved in OpenApiRouter itself. - Introduce RouteRegistration, a plain DTO adapters now register against instead of the raw Spec\Operation: Spec\Operation carries a live \Reflector with no __serialize(), so it can't survive the OPTION_CACHE round-trip through a PSR-16 cache. Adapters no longer depend on swagger-php at all. - Port the Laravel/Slim adapters onto the new DTO; _context is gone, replaced by getReflector()/getClassName(). - Remove the unused, unwired classic-mode VendorPropertyValidation processor, and the orphaned Lumen test fixture left behind when Lumen support was dropped in #61. - Require zircote/swagger-php ^6.9, drop openapi-extras and doctrine/annotations. --- composer.json | 9 +- src/Adapters/LaravelRoutingAdapter.php | 20 +- src/Adapters/SlimRoutingAdapter.php | 21 +- src/Attributes/Middleware.php | 51 +++++ src/OpenApiRouter.php | 213 ++++++++++-------- src/Processors/VendorPropertyValidation.php | 84 ------- src/RouteRegistration.php | 29 +++ src/RoutingAdapterInterface.php | 9 +- .../Controllers/AttributeController.php | 16 +- .../Fixtures/Controllers/InvokeController.php | 24 +- .../Controllers/MiddlewareController.php | 23 +- .../Controllers/NamedRouteController.php | 26 +-- .../Controllers/ParametersController.php | 162 +++---------- .../Controllers/PrefixedController.php | 24 +- tests/Laravel/CachingTest.php | 3 +- tests/Laravel/CallsApplicationTrait.php | 9 +- tests/Slim/CallsControllerTrait.php | 9 +- 17 files changed, 293 insertions(+), 439 deletions(-) create mode 100644 src/Attributes/Middleware.php delete mode 100644 src/Processors/VendorPropertyValidation.php create mode 100644 src/RouteRegistration.php diff --git a/composer.json b/composer.json index 1f40b64..5e686e0 100644 --- a/composer.json +++ b/composer.json @@ -39,18 +39,16 @@ }, "extra": { "branch-alias": { - "dev-main": "3.x-dev" + "dev-main": "5.x-dev" } }, "require": { "php": ">=8.2", "psr/log": "^1.1 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", - "radebatz/openapi-extras": "^4.2", - "zircote/swagger-php": "^4.11.1 || ^5.0.2 || ^6.0" + "zircote/swagger-php": "^6.9" }, "require-dev": { - "doctrine/annotations": "^2.0", "friendsofphp/php-cs-fixer": "^3.62.0", "nyholm/psr7": "^1.4", "nyholm/psr7-server": "^1.0", @@ -61,8 +59,5 @@ }, "suggest": { "radebatz/openapi-verifier": "Allows your PHPUnit tests to validate your controller response against your OpenAPI spec." - }, - "conflict": { - "zircote/swagger-php": "5.3.0" } } diff --git a/src/Adapters/LaravelRoutingAdapter.php b/src/Adapters/LaravelRoutingAdapter.php index 93b220d..d40595d 100644 --- a/src/Adapters/LaravelRoutingAdapter.php +++ b/src/Adapters/LaravelRoutingAdapter.php @@ -4,8 +4,7 @@ use Illuminate\Foundation\Application; use Illuminate\Routing\Router; -use OpenApi\Annotations\Operation; -use OpenApi\Annotations\Parameter; +use Radebatz\OpenApi\Routing\RouteRegistration; use Radebatz\OpenApi\Routing\RoutingAdapterInterface; /** @@ -27,13 +26,12 @@ public function __construct(Application $app, array $options = []) /** * @inheritdoc */ - public function register(Operation $operation, string $controller, array $parameters, array $custom): void + public function register(RouteRegistration $route): void { - $path = $operation->path; + $path = $route->path; $where = []; - /** @var Parameter $parameter */ - foreach ($parameters as $name => $parameter) { + foreach ($route->parameters as $name => $parameter) { if (!$parameter['required'] && false !== strpos($path, $needle = "/{{$name}}")) { $path = str_replace($needle, "/{{$name}?}", $path); } @@ -53,7 +51,7 @@ public function register(Operation $operation, string $controller, array $parame } } - $controller = str_replace('::__invoke', '', $controller); + $controller = str_replace('::__invoke', '', $route->controller); /** @var Router $router */ $router = $this->app->get('router'); @@ -61,13 +59,13 @@ public function register(Operation $operation, string $controller, array $parame $action = [ 'uses' => str_replace('::', '@', $controller), ]; - if ($custom[static::X_NAME]) { - $action['as'] = $custom[static::X_NAME]; + if ($route->custom[static::X_NAME]) { + $action['as'] = $route->custom[static::X_NAME]; } $router - ->addRoute(strtoupper($operation->method), $path, $action) - ->middleware($custom[static::X_MIDDLEWARE]) + ->addRoute(strtoupper($route->method), $path, $action) + ->middleware($route->custom[static::X_MIDDLEWARE]) ->where($where); } diff --git a/src/Adapters/SlimRoutingAdapter.php b/src/Adapters/SlimRoutingAdapter.php index 35f686f..d70a6cd 100644 --- a/src/Adapters/SlimRoutingAdapter.php +++ b/src/Adapters/SlimRoutingAdapter.php @@ -2,7 +2,7 @@ namespace Radebatz\OpenApi\Routing\Adapters; -use OpenApi\Annotations as OA; +use Radebatz\OpenApi\Routing\RouteRegistration; use Radebatz\OpenApi\Routing\RoutingAdapterInterface; use Slim\App; @@ -25,14 +25,13 @@ public function __construct(App $app, array $options = []) /** * @inheritdoc */ - public function register(OA\Operation $operation, string $controller, array $parameters, array $custom): void + public function register(RouteRegistration $route): void { - $path = $operation->path; + $path = $route->path; - $controller = str_replace('::', ':', $controller); + $controller = str_replace('::', ':', $route->controller); - /** @var OA\Parameter $parameter */ - foreach ($parameters as $name => $parameter) { + foreach ($route->parameters as $name => $parameter) { if (!$parameter['required']) { if (false !== strpos($path, $needle = "/{{$name}}[/{")) { // multiple optional parameters @@ -57,13 +56,13 @@ public function register(OA\Operation $operation, string $controller, array $par } } - $route = $this->app->map([strtoupper($operation->method)], $path, $controller); - if ($custom[static::X_NAME]) { - $route->setName($custom[static::X_NAME]); + $slimRoute = $this->app->map([strtoupper($route->method)], $path, $controller); + if ($route->custom[static::X_NAME]) { + $slimRoute->setName($route->custom[static::X_NAME]); } - foreach ($custom[static::X_MIDDLEWARE] as $middleware) { - $route->add($middleware); + foreach ($route->custom[static::X_MIDDLEWARE] as $middleware) { + $slimRoute->add($middleware); } } diff --git a/src/Attributes/Middleware.php b/src/Attributes/Middleware.php new file mode 100644 index 0000000..8f28ecd --- /dev/null +++ b/src/Attributes/Middleware.php @@ -0,0 +1,51 @@ + $names PSR-15 middleware names/class-strings, in the shape the routing adapter expects + */ + public function __construct( + public array $names = [], + ) { + } + + /** + * Without this, `Attachable::isRoot()` (true, unconditionally) means an unmerged + * `Middleware` lands in `Specification::$attachables` as its own top-level, orphaned + * entry instead of nesting into the `Operation`/`PathItem` it was stacked beside. + * + * @return array + */ + public function merge(): array + { + return [ + Operation::class => 'attachables[]', + PathItem::class => 'attachables[]', + ]; + } +} diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index 26b1206..55cb7b4 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -2,14 +2,14 @@ namespace Radebatz\OpenApi\Routing; -use OpenApi\Analysis; -use OpenApi\Annotations as OA; -use OpenApi\Context; -use OpenApi\Generator; +use OpenApi\Builder; +use OpenApi\Builder\Mode; +use OpenApi\Builder\Result; +use OpenApi\Spec as OA; +use OpenApi\Specification; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; -use Radebatz\OpenApi\Extras\OpenApiBuilder; -use Radebatz\OpenApi\Extras\Annotations\Middleware; +use Radebatz\OpenApi\Routing\Attributes\Middleware; use Symfony\Component\Finder\Finder; /** @@ -17,15 +17,13 @@ */ class OpenApiRouter { - public const OPTION_RELOAD = 'relaod'; + public const OPTION_RELOAD = 'reload'; public const OPTION_CACHE = 'cache'; - public const OPTION_OA_INFO_INJECT = 'oa_info_inject'; public const OPTION_OA_OPERATION_ID_AS_NAME = 'oa_operation_id_as_name'; - public const CACHE_KEY_OPENAPI = 'openapi-router.openapi'; + public const CACHE_KEY_ROUTES = 'openapi-router.routes'; protected string|array|Finder $sources; - protected ?OA\OpenApi $openapi; protected RoutingAdapterInterface $routingAdapter; protected array $options; @@ -43,108 +41,125 @@ public function __construct($sources, RoutingAdapterInterface $routingAdapter, a $this->options = $options + [ self::OPTION_RELOAD => true, self::OPTION_CACHE => null, - self::OPTION_OA_INFO_INJECT => false, self::OPTION_OA_OPERATION_ID_AS_NAME => true, ]; } - public function registerRoutes(): ?OA\OpenApi + /** + * @return list|null `null` when the adapter's own route cache was used instead + */ + public function registerRoutes(): ?array { if (!$this->options[self::OPTION_RELOAD] && $this->routingAdapter->registerCached()) { return null; } - $openapi = null; + $routes = null; /** @var CacheInterface $cache */ if (($cache = $this->options[self::OPTION_CACHE]) && !$this->options[self::OPTION_RELOAD]) { - // try cache - $openapi = $cache->get(self::CACHE_KEY_OPENAPI); + $routes = $cache->get(self::CACHE_KEY_ROUTES); } - $this->registerOpenApi($openapi ?: ($openapi = $this->scan())); + $routes ??= $this->extractRoutes($this->scan()->specification() ?? new Specification()); + + foreach ($routes as $route) { + $this->routingAdapter->register($route); + } if ($cache && !$this->options[self::OPTION_RELOAD]) { - $cache->set(self::CACHE_KEY_OPENAPI, $openapi); + $cache->set(self::CACHE_KEY_ROUTES, $routes); } - return $openapi; + return $routes; } - protected function registerOpenApi(OA\OpenApi $openapi) + /** + * @return list + */ + protected function extractRoutes(Specification $specification): array { - $methods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']; - - foreach ($openapi->paths as $pathItem) { - foreach ($methods as $method) { - $operation = null; - /** @var OA\Parameter[] $parameters */ - $parameters = []; - - if (Generator::UNDEFINED !== $pathItem->{$method}) { - /** @var OA\Operation $operation */ - $operation = $pathItem->{$method}; - - if ($operation) { - if (!Generator::isDefault($operation->parameters)) { - foreach ($operation->parameters as $parameter) { - if ('path' == $parameter->in) { - $parameters[] = $parameter; - } - } - } + $classToPathItem = []; + foreach ($specification->pathItems as $pathItem) { + if (($className = $pathItem->getClassName()) !== null) { + $classToPathItem[$className] = $pathItem; + } + } - $controller = null; - $context = $operation->_context; - if ($context && $context->method) { - if ($context->class) { - if ($context->namespace) { - $controller = $context->namespace . '\\' . $context->class . '::' . $context->method; - } else { - $controller = $context->class . '::' . $context->method; - } - } else { - $controller = $context->method; - } - } + $routes = []; + foreach ($specification->operations as $operation) { + $reflector = $operation->getReflector(); + if (!$reflector instanceof \ReflectionMethod) { + // no declaring method to dispatch to (e.g. a plain function) — nothing to register + continue; + } - $middleware = []; - if (!Generator::isDefault($operation->attachables)) { - foreach ($operation->attachables as $attachable) { - if ($attachable instanceof Middleware) { - $middleware = array_merge($middleware, $attachable->names); - } - } - } - $middleware = array_unique($middleware); - - $custom = [ - RoutingAdapterInterface::X_NAME => $this->options[self::OPTION_OA_OPERATION_ID_AS_NAME] ? $operation->operationId : null, - RoutingAdapterInterface::X_MIDDLEWARE => $middleware, - ]; - if (!Generator::isDefault($operation->x)) { - foreach (array_keys($custom) as $xKey) { - if (array_key_exists($xKey, $operation->x)) { - $custom[$xKey] = is_array($custom[$xKey]) ? array_merge($custom[$xKey], $operation->x[$xKey]) : $operation->x[$xKey]; - } - } - } + $controller = $operation->getClassName() . '::' . $reflector->getName(); - $this->routingAdapter->register( - $operation, - $controller, - $this->parameterMetadata(array_reverse($parameters)), - $custom - ); - } + $parameters = []; + foreach ($operation->parameters ?? [] as $parameter) { + if ('path' === $parameter->in) { + $parameters[] = $parameter; } } + + $routes[] = new RouteRegistration( + path: $operation->path ?? '', + method: strtoupper($operation->method ?? 'GET'), + controller: $controller, + parameters: $this->parameterMetadata(array_reverse($parameters)), + custom: $this->customProperties($operation, $classToPathItem), + ); } + + return $routes; + } + + /** + * @param array $classToPathItem + * + * @return array + */ + protected function customProperties(OA\Operation $operation, array $classToPathItem): array + { + // PathItem's own class-level attachables don't clone down to its operations the way + // tags/security/responses do (swagger-php's `PathItems` augmenter only clones those + // three) — resolved here instead, so a controller-level `#[Middleware]` still applies + // to every operation under it. + $attachables = $operation->attachables ?? []; + $className = $operation->getClassName(); + if ($className !== null && isset($classToPathItem[$className])) { + $attachables = [...$classToPathItem[$className]->attachables ?? [], ...$attachables]; + } + + $middleware = []; + foreach ($attachables as $attachable) { + if ($attachable instanceof Middleware) { + $middleware = array_merge($middleware, $attachable->names); + } + } + + $custom = [ + RoutingAdapterInterface::X_NAME => $this->options[self::OPTION_OA_OPERATION_ID_AS_NAME] ? $operation->operationId : null, + RoutingAdapterInterface::X_MIDDLEWARE => array_values(array_unique($middleware)), + ]; + + foreach (array_keys($custom) as $xKey) { + if (array_key_exists($xKey, $operation->x ?? [])) { + $custom[$xKey] = is_array($custom[$xKey]) + ? array_merge($custom[$xKey], (array) $operation->x[$xKey]) + : $operation->x[$xKey]; + } + } + + return $custom; } /** * Extract (uri) parameter meta data. * - * @param OA\Parameter[] $parameters + * @param list $parameters + * + * @return array */ protected function parameterMetadata(array $parameters): array { @@ -154,17 +169,16 @@ protected function parameterMetadata(array $parameters): array $name = $parameter->name; $metadata[$name] = [ - 'required' => !Generator::isDefault($parameter->required) && $parameter->required, + 'required' => (bool) $parameter->required, 'type' => null, 'pattern' => null, ]; - if (!Generator::isDefault($parameter->schema)) { - $schema = $parameter->schema; + if ($schema = $parameter->schema) { switch ($schema->type) { case 'string': $metadata[$name]['type'] = $schema->type; - if (!Generator::isDefault($pattern = $schema->pattern)) { + if ($pattern = $schema->pattern) { $metadata[$name]['type'] = 'regex'; $metadata[$name]['pattern'] = $pattern; } @@ -179,20 +193,27 @@ protected function parameterMetadata(array $parameters): array return $metadata; } - public function scan(?LoggerInterface $logger = null): OA\OpenApi + public function scan(?LoggerInterface $logger = null): Result { - $generator = (new OpenApiBuilder())->build($logger); + $builder = (new Builder()) + ->addSource($this->sources) + ->setMode(Mode::SPEC); - // provide default @OA\Info in case we need to do some scanning - $analysis = $generator->withContext(function (Generator $generator, Analysis $analysis, Context $context) { - if ($this->options[self::OPTION_OA_INFO_INJECT]) { - $analysis->addAnnotation(new OA\Info(['title' => 'OpenApi', 'version' => '1.0']), $context); - } + if ($logger instanceof LoggerInterface) { + $builder->setLogger($logger); + } - return $analysis; - }); + $result = $builder->build(); + + if ($logger instanceof LoggerInterface) { + foreach ($result->errors() as $error) { + $logger->error($error); + } + foreach ($result->warnings() as $warning) { + $logger->warning($warning); + } + } - return $generator - ->generate($this->sources, $analysis); + return $result; } } diff --git a/src/Processors/VendorPropertyValidation.php b/src/Processors/VendorPropertyValidation.php deleted file mode 100644 index 70774cd..0000000 --- a/src/Processors/VendorPropertyValidation.php +++ /dev/null @@ -1,84 +0,0 @@ -uniqueNames = $uniqueNames; - if ($callback && !is_callable($callback)) { - throw new \InvalidArgumentException('Invalid callback'); - } - $this->callback = $callback; - } - - public function __invoke(Analysis $analysis) - { - /** @var Operation[] $operations */ - $operations = $analysis->getAnnotationsOfType(Operation::class); - /** @var Operation $operation */ - foreach ($operations as $operation) { - if (!Generator::isDefault($operation->operationId)) { - $this->validateUniqueName($operation->operationId); - } - - if (!Generator::isDefault($operation->attachables)) { - foreach ($operation->attachables as $attachable) { - if ($attachable instanceof Middleware) { - $this->validateVendorProperty(RoutingAdapterInterface::X_MIDDLEWARE, $attachable->names); - } - } - } - - if (!Generator::isDefault($operation->x)) { - if (array_key_exists(RoutingAdapterInterface::X_NAME, $operation->x)) { - $this->validateUniqueName($operation->x[RoutingAdapterInterface::X_NAME]); - } - - $custom = [RoutingAdapterInterface::X_MIDDLEWARE, RoutingAdapterInterface::X_NAME]; - foreach ($custom as $key) { - if (array_key_exists($key, $operation->x)) { - $this->validateVendorProperty($key, $operation->x[$key]); - } - } - } - } - } - - protected function validateUniqueName($name) - { - if ($this->uniqueNames && in_array($name, $this->names)) { - throw new \LogicException(sprintf('Duplicate operationId/name: "%s"', $name)); - } - $this->names[] = $name; - } - - protected function validateVendorProperty($name, $value) - { - if ($this->callback) { - $values = (array) $value; - foreach ($values as $value) { - call_user_func($this->callback, $name, $value); - } - } - } -} diff --git a/src/RouteRegistration.php b/src/RouteRegistration.php new file mode 100644 index 0000000..bb14393 --- /dev/null +++ b/src/RouteRegistration.php @@ -0,0 +1,29 @@ + $parameters URI parameter metadata, keyed by name, in reverse declaration order + * @param array $custom `RoutingAdapterInterface::X_*` keys + */ + public function __construct( + public readonly string $path, + public readonly string $method, + public readonly string $controller, + public readonly array $parameters, + public readonly array $custom, + ) { + } +} diff --git a/src/RoutingAdapterInterface.php b/src/RoutingAdapterInterface.php index b98a8e7..a444b7c 100644 --- a/src/RoutingAdapterInterface.php +++ b/src/RoutingAdapterInterface.php @@ -2,9 +2,6 @@ namespace Radebatz\OpenApi\Routing; -use OpenApi\Annotations\Operation; -use OpenApi\Annotations\Parameter; - /** * Routing adapter interface. */ @@ -17,12 +14,8 @@ interface RoutingAdapterInterface /** * Register a route. - * - * @param Operation $operation The route URI pattern - * @param array $parameters URI parameter meta data in reverse order - * @param array $custom Custom properties */ - public function register(Operation $operation, string $controller, array $parameters, array $custom): void; + public function register(RouteRegistration $route): void; /** * Register routes cached on framework level (optional). diff --git a/tests/Fixtures/Controllers/AttributeController.php b/tests/Fixtures/Controllers/AttributeController.php index a72f491..261dec9 100644 --- a/tests/Fixtures/Controllers/AttributeController.php +++ b/tests/Fixtures/Controllers/AttributeController.php @@ -2,19 +2,19 @@ namespace Radebatz\OpenApi\Routing\Tests\Fixtures\Controllers; -use OpenApi\Attributes as OAT; -use Radebatz\OpenApi\Extras\Attributes as OAX; +use OpenApi\Spec as OA; +use Radebatz\OpenApi\Routing\Attributes\Middleware; use Radebatz\OpenApi\Routing\Tests\Fixtures\Middleware\BarMiddleware; use Radebatz\OpenApi\Routing\Tests\Fixtures\Middleware\FooMiddleware; -#[OAX\Controller(prefix: '/attributes')] -#[OAT\Response(response: 403, description: 'Not allowed')] -#[OAX\Middleware([FooMiddleware::class])] +#[OA\PathItem(prefix: '/attributes')] +#[OA\Response(response: 403, description: 'Not allowed')] +#[Middleware(names: [FooMiddleware::class])] class AttributeController { - #[OAT\Get(path: '/prefixed', x: ['name' => 'attributes'])] - #[OAT\Response(response: 200, description: 'All good')] - #[OAX\Middleware([BarMiddleware::class])] + #[OA\Operation\Get(path: '/prefixed', x: ['name' => 'attributes'])] + #[OA\Response(response: 200, description: 'All good')] + #[Middleware(names: [BarMiddleware::class])] public function prefixed() { return FakeResponse::create('Get fooya'); diff --git a/tests/Fixtures/Controllers/InvokeController.php b/tests/Fixtures/Controllers/InvokeController.php index 47586d8..d763e9b 100644 --- a/tests/Fixtures/Controllers/InvokeController.php +++ b/tests/Fixtures/Controllers/InvokeController.php @@ -2,28 +2,14 @@ namespace Radebatz\OpenApi\Routing\Tests\Fixtures\Controllers; -use OpenApi\Annotations as OA; -use Radebatz\OpenApi\Extras\Annotations as OAX; +use OpenApi\Spec as OA; -/** - * @OAX\Controller( - * prefix="/foo", - * - * @OA\Response(response="401", description="Unauthorized") - * ) - */ +#[OA\PathItem(prefix: '/foo')] +#[OA\Response(response: 401, description: 'Unauthorized')] class InvokeController { - /** - * @OA\Get( - * path="/invoke/{name}", - * x={ - * "name": "invoke" - * }, - * - * @OA\Response(response="200", description="All good") - * ) - */ + #[OA\Operation\Get(path: '/invoke/{name}', x: ['name' => 'invoke'])] + #[OA\Response(response: 200, description: 'All good')] public function __invoke($name) { return FakeResponse::create('invoke'); diff --git a/tests/Fixtures/Controllers/MiddlewareController.php b/tests/Fixtures/Controllers/MiddlewareController.php index 5243b1c..e0535e3 100644 --- a/tests/Fixtures/Controllers/MiddlewareController.php +++ b/tests/Fixtures/Controllers/MiddlewareController.php @@ -2,21 +2,20 @@ namespace Radebatz\OpenApi\Routing\Tests\Fixtures\Controllers; -use OpenApi\Annotations as OA; +use OpenApi\Spec as OA; +use Radebatz\OpenApi\Routing\Tests\Fixtures\Middleware\BarMiddleware; +use Radebatz\OpenApi\Routing\Tests\Fixtures\Middleware\FooMiddleware; class MiddlewareController { - /** - * @OA\Get( - * path="/mw", - * x={ - * "name": "mw", - * "middleware": {"Radebatz\OpenApi\Routing\Tests\Fixtures\Middleware\FooMiddleware", "Radebatz\OpenApi\Routing\Tests\Fixtures\Middleware\BarMiddleware"} - * }, - * - * @OA\Response(response="200", description="All good") - * ) - */ + #[OA\Operation\Get( + path: '/mw', + x: [ + 'name' => 'mw', + 'middleware' => [FooMiddleware::class, BarMiddleware::class], + ], + )] + #[OA\Response(response: 200, description: 'All good')] public function mw($request = null, $response = null) { return FakeResponse::create('MW!', $response); diff --git a/tests/Fixtures/Controllers/NamedRouteController.php b/tests/Fixtures/Controllers/NamedRouteController.php index ed4e31a..1a5f9cb 100644 --- a/tests/Fixtures/Controllers/NamedRouteController.php +++ b/tests/Fixtures/Controllers/NamedRouteController.php @@ -2,35 +2,19 @@ namespace Radebatz\OpenApi\Routing\Tests\Fixtures\Controllers; -use OpenApi\Annotations as OA; +use OpenApi\Spec as OA; class NamedRouteController { - /** - * @OA\Get( - * path="/getya", - * x={ - * "name": "getya" - * }, - * - * @OA\Response(response="200", description="All good") - * ) - */ + #[OA\Operation\Get(path: '/getya', x: ['name' => 'getya'])] + #[OA\Response(response: 200, description: 'All good')] public function getya() { return FakeResponse::create('Get ya'); } - /** - * @OA\Get( - * path="/static_getya", - * x={ - * "name": "static_getya" - * }, - * - * @OA\Response(response="200", description="All good") - * ) - */ + #[OA\Operation\Get(path: '/static_getya', x: ['name' => 'static_getya'])] + #[OA\Response(response: 200, description: 'All good')] public static function static_getya() { return FakeResponse::create('Static Get ya'); diff --git a/tests/Fixtures/Controllers/ParametersController.php b/tests/Fixtures/Controllers/ParametersController.php index 1a6c82e..9e0df05 100644 --- a/tests/Fixtures/Controllers/ParametersController.php +++ b/tests/Fixtures/Controllers/ParametersController.php @@ -2,150 +2,54 @@ namespace Radebatz\OpenApi\Routing\Tests\Fixtures\Controllers; -use OpenApi\Annotations as OA; +use OpenApi\Spec as OA; class ParametersController { - /** - * @OA\Get( - * path="/hey/{name}", - * x={ - * "name": "hey" - * }, - * - * @OA\Parameter( - * name="name", - * in="path", - * required=true, - * description="The name", - * - * @OA\Schema( - * type="string" - * ) - * ), - * - * @OA\Response(response="200", description="All good") - * ) - */ - public function hey($name) - { + #[OA\Operation\Get(path: '/hey/{name}', x: ['name' => 'hey'])] + #[OA\Response(response: 200, description: 'All good')] + public function hey( + #[OA\Parameter\Path(description: 'The name', schema: new OA\Schema(type: 'string'))] + $name + ) { return FakeResponse::create(sprintf('Hey: %s', $name)); } - /** - * @OA\Get( - * path="/oi/{name}", - * x={ - * "name": "oi" - * }, - * - * @OA\Parameter( - * name="name", - * in="path", - * required=false, - * description="The name", - * - * @OA\Schema( - * type="string" - * ) - * ), - * - * @OA\Response(response="200", description="All good") - * ) - */ - public function oi($name = 'you') - { + #[OA\Operation\Get(path: '/oi/{name}', x: ['name' => 'oi'])] + #[OA\Response(response: 200, description: 'All good')] + public function oi( + #[OA\Parameter\Path(description: 'The name', required: false, schema: new OA\Schema(type: 'string'))] + $name = 'you' + ) { return FakeResponse::create(sprintf('Oi: %s', $name)); } - /** - * @OA\Get( - * path="/id/{id}", - * x={ - * "name": "id" - * }, - * - * @OA\Parameter( - * name="id", - * in="path", - * required=true, - * description="The id", - * - * @OA\Schema( - * type="integer", - * format="int32" - * ) - * ), - * - * @OA\Response(response="200", description="All good") - * ) - */ - public function id($id) - { + #[OA\Operation\Get(path: '/id/{id}', x: ['name' => 'id'])] + #[OA\Response(response: 200, description: 'All good')] + public function id( + #[OA\Parameter\Path(description: 'The id', schema: new OA\Schema(type: 'integer', format: 'int32'))] + $id + ) { return FakeResponse::create(sprintf('ID: %s', $id)); } - /** - * @OA\Get( - * path="/hid/{hid}", - * x={ - * "name": "hid" - * }, - * - * @OA\Parameter( - * name="hid", - * in="path", - * required=true, - * description="The hid", - * - * @OA\Schema( - * type="string", - * pattern="[0-9a-f]+" - * ) - * ), - * - * @OA\Response(response="200", description="All good") - * ) - */ - public function hid($hid) - { + #[OA\Operation\Get(path: '/hid/{hid}', x: ['name' => 'hid'])] + #[OA\Response(response: 200, description: 'All good')] + public function hid( + #[OA\Parameter\Path(description: 'The hid', schema: new OA\Schema(type: 'string', pattern: '[0-9a-f]+'))] + $hid + ) { return FakeResponse::create(sprintf('HID: %s', $hid)); } - /** - * @OA\Get( - * path="/multi/{foo}/{bar}", - * x={ - * "name": "multi" - * }, - * - * @OA\Parameter( - * name="foo", - * in="path", - * required=false, - * description="The foo", - * - * @OA\Schema( - * type="string" - * ) - * ), - * - * @OA\Parameter( - * name="bar", - * in="path", - * required=false, - * description="The bar", - * - * @OA\Schema( - * type="string" - * ) - * ), - * - * @OA\Response(response="200", description="All good") - * ) - */ - public function multi($foo = null, $bar = null) - { + #[OA\Operation\Get(path: '/multi/{foo}/{bar}', x: ['name' => 'multi'])] + #[OA\Response(response: 200, description: 'All good')] + public function multi( + #[OA\Parameter\Path(description: 'The foo', required: false, schema: new OA\Schema(type: 'string'))] + $foo = null, + #[OA\Parameter\Path(description: 'The bar', required: false, schema: new OA\Schema(type: 'string'))] + $bar = null + ) { return FakeResponse::create('foobar'); } } diff --git a/tests/Fixtures/Controllers/PrefixedController.php b/tests/Fixtures/Controllers/PrefixedController.php index 8272d1e..5a9c706 100644 --- a/tests/Fixtures/Controllers/PrefixedController.php +++ b/tests/Fixtures/Controllers/PrefixedController.php @@ -2,28 +2,14 @@ namespace Radebatz\OpenApi\Routing\Tests\Fixtures\Controllers; -use OpenApi\Annotations as OA; -use Radebatz\OpenApi\Extras\Annotations as OAX; +use OpenApi\Spec as OA; -/** - * @OAX\Controller( - * prefix="/foo", - * - * @OA\Response(response="403", description="Not allowed") - * ) - */ +#[OA\PathItem(prefix: '/foo')] +#[OA\Response(response: 403, description: 'Not allowed')] class PrefixedController { - /** - * @OA\Get( - * path="/prefixed", - * x={ - * "name": "prefixed" - * }, - * - * @OA\Response(response="200", description="All good") - * ) - */ + #[OA\Operation\Get(path: '/prefixed', x: ['name' => 'prefixed'])] + #[OA\Response(response: 200, description: 'All good')] public function prefixed() { return FakeResponse::create('Get fooya'); diff --git a/tests/Laravel/CachingTest.php b/tests/Laravel/CachingTest.php index 58f58cd..520c0a1 100644 --- a/tests/Laravel/CachingTest.php +++ b/tests/Laravel/CachingTest.php @@ -31,7 +31,6 @@ public static function reloadTests(): iterable public function testReload(?CacheInterface $cache, bool $reload, bool $openapisCached): void { $options = [ - OpenApiRouter::OPTION_OA_INFO_INJECT => true, OpenApiRouter::OPTION_RELOAD => $reload, OpenApiRouter::OPTION_CACHE => $cache, ]; @@ -43,7 +42,7 @@ public function testReload(?CacheInterface $cache, bool $reload, bool $openapisC $router = $app['router']; $this->assertNotNull($router->getRoutes()->getByName('getya')); - $this->assertEquals($openapisCached, $cache && $cache->has(OpenApiRouter::CACHE_KEY_OPENAPI)); + $this->assertEquals($openapisCached, $cache && $cache->has(OpenApiRouter::CACHE_KEY_ROUTES)); } protected function getApp(): Application diff --git a/tests/Laravel/CallsApplicationTrait.php b/tests/Laravel/CallsApplicationTrait.php index d81f055..33b94db 100644 --- a/tests/Laravel/CallsApplicationTrait.php +++ b/tests/Laravel/CallsApplicationTrait.php @@ -37,14 +37,11 @@ public function createApplication() ]); Facade::setFacadeApplication($app); - $options = [ - OpenApiRouter::OPTION_OA_INFO_INJECT => true, - ]; - (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app), $options)) + (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app))) ->registerRoutes(); - $openapi = (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app), $options)) + $result = (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app))) ->scan(); - file_put_contents(__DIR__ . '/openapi.yaml', $openapi->toYaml()); + file_put_contents(__DIR__ . '/openapi.yaml', $result->toYaml()); $this->app = $app; } diff --git a/tests/Slim/CallsControllerTrait.php b/tests/Slim/CallsControllerTrait.php index 1dca745..7c5a10a 100644 --- a/tests/Slim/CallsControllerTrait.php +++ b/tests/Slim/CallsControllerTrait.php @@ -33,14 +33,11 @@ protected function getApp(): App { $app = AppFactory::create(); - $options = [ - OpenApiRouter::OPTION_OA_INFO_INJECT => true, - ]; - (new OpenApiRouter($this->getFixtureFinder(), new SlimRoutingAdapter($app), $options)) + (new OpenApiRouter($this->getFixtureFinder(), new SlimRoutingAdapter($app))) ->registerRoutes(); - $openapi = (new OpenApiRouter($this->getFixtureFinder(), new SlimRoutingAdapter($app), $options)) + $result = (new OpenApiRouter($this->getFixtureFinder(), new SlimRoutingAdapter($app))) ->scan(); - file_put_contents(__DIR__ . '/openapi.yaml', $openapi->toYaml()); + file_put_contents(__DIR__ . '/openapi.yaml', $result->toYaml()); return $app; } From 9b45a38385735056e01db5083d0f04030d6b4619 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sat, 19 Sep 2026 20:53:10 +1200 Subject: [PATCH 02/27] Replace the options-array config with fluent setters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors swagger-php's own Builder (with*()/set*() returning static) rather than an untyped array keyed by string constants: - OpenApiRouter: withReload(), withCache(), withOperationIdAsName(), withLogger() replace the OPTION_* array and scan()'s bolted-on $logger param. - Adapters only ever had one option each (OPTION_AUTO_REGEX), so that's now a plain typed constructor parameter instead of an array with one key. RoutingAdapterInterface::OPTION_AUTO_REGEX is gone with it. X_NAME/X_MIDDLEWARE stay as constants — those key the open-ended `custom` map vendor-extension overrides write into, not a fixed options bag. --- src/Adapters/LaravelRoutingAdapter.php | 18 +++-- src/Adapters/SlimRoutingAdapter.php | 18 +++-- src/OpenApiRouter.php | 95 +++++++++++++++++--------- src/RoutingAdapterInterface.php | 2 - tests/Laravel/CachingTest.php | 9 +-- 5 files changed, 81 insertions(+), 61 deletions(-) diff --git a/src/Adapters/LaravelRoutingAdapter.php b/src/Adapters/LaravelRoutingAdapter.php index d40595d..4ddcce1 100644 --- a/src/Adapters/LaravelRoutingAdapter.php +++ b/src/Adapters/LaravelRoutingAdapter.php @@ -12,15 +12,13 @@ */ class LaravelRoutingAdapter implements RoutingAdapterInterface { - protected Application $app; - protected array $options; - - public function __construct(Application $app, array $options = []) - { - $this->app = $app; - $this->options = array_merge([ - static::OPTION_AUTO_REGEX => true, - ], $options); + /** + * @param bool $autoRegex Constrain an `integer`-typed path parameter to `[0-9]+` + */ + public function __construct( + protected Application $app, + protected bool $autoRegex = true, + ) { } /** @@ -44,7 +42,7 @@ public function register(RouteRegistration $route): void break; case 'integer': - if ($this->options[static::OPTION_AUTO_REGEX]) { + if ($this->autoRegex) { $where[$name] = '[0-9]+'; } break; diff --git a/src/Adapters/SlimRoutingAdapter.php b/src/Adapters/SlimRoutingAdapter.php index d70a6cd..dc73730 100644 --- a/src/Adapters/SlimRoutingAdapter.php +++ b/src/Adapters/SlimRoutingAdapter.php @@ -11,15 +11,13 @@ */ class SlimRoutingAdapter implements RoutingAdapterInterface { - protected App $app; - protected array $options; - - public function __construct(App $app, array $options = []) - { - $this->app = $app; - $this->options = array_merge([ - static::OPTION_AUTO_REGEX => true, - ], $options); + /** + * @param bool $autoRegex Constrain an `integer`-typed path parameter to `[0-9]+` + */ + public function __construct( + protected App $app, + protected bool $autoRegex = true, + ) { } /** @@ -49,7 +47,7 @@ public function register(RouteRegistration $route): void break; case 'integer': - if ($this->options[static::OPTION_AUTO_REGEX]) { + if ($this->autoRegex) { $path = str_replace("{{$name}}", "{{$name}:[0-9]+}", $path); } break; diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index 55cb7b4..f23f1a3 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -17,32 +17,62 @@ */ class OpenApiRouter { - public const OPTION_RELOAD = 'reload'; - public const OPTION_CACHE = 'cache'; - public const OPTION_OA_OPERATION_ID_AS_NAME = 'oa_operation_id_as_name'; - public const CACHE_KEY_ROUTES = 'openapi-router.routes'; - protected string|array|Finder $sources; - protected RoutingAdapterInterface $routingAdapter; - protected array $options; + protected bool $reload = true; + protected ?CacheInterface $cache = null; + protected bool $operationIdAsName = true; + protected ?LoggerInterface $logger = null; /** - * Create new routes. - * - * @param string|array|Finder $sources The directory(s) or filename(s) - * @param RoutingAdapterInterface $routingAdapter the framework adapter - * @param array $options Optional configuration options + * @param string|array|Finder $sources The directory(s) or filename(s) */ - public function __construct($sources, RoutingAdapterInterface $routingAdapter, array $options = []) + public function __construct( + protected string|array|Finder $sources, + protected RoutingAdapterInterface $routingAdapter, + ) { + } + + /** + * Force a rescan on every {@see registerRoutes()} call rather than trusting the cache or + * the adapter's own cached routes. Typically off in production. + */ + public function withReload(bool $reload = true): static { - $this->sources = $sources; - $this->routingAdapter = $routingAdapter; - $this->options = $options + [ - self::OPTION_RELOAD => true, - self::OPTION_CACHE => null, - self::OPTION_OA_OPERATION_ID_AS_NAME => true, - ]; + $this->reload = $reload; + + return $this; + } + + /** + * Cache extracted routes here across requests when {@see withReload()} is off. + */ + public function withCache(?CacheInterface $cache): static + { + $this->cache = $cache; + + return $this; + } + + /** + * Use the operation's `operationId` as the route name. When off, only an explicit + * `x-name` vendor property names a route. + */ + public function withOperationIdAsName(bool $operationIdAsName = true): static + { + $this->operationIdAsName = $operationIdAsName; + + return $this; + } + + /** + * Receive scan warnings/errors here, from both {@see scan()} and {@see registerRoutes()}. + */ + public function withLogger(?LoggerInterface $logger): static + { + $this->logger = $logger; + + return $this; } /** @@ -50,14 +80,13 @@ public function __construct($sources, RoutingAdapterInterface $routingAdapter, a */ public function registerRoutes(): ?array { - if (!$this->options[self::OPTION_RELOAD] && $this->routingAdapter->registerCached()) { + if (!$this->reload && $this->routingAdapter->registerCached()) { return null; } $routes = null; - /** @var CacheInterface $cache */ - if (($cache = $this->options[self::OPTION_CACHE]) && !$this->options[self::OPTION_RELOAD]) { - $routes = $cache->get(self::CACHE_KEY_ROUTES); + if ($this->cache && !$this->reload) { + $routes = $this->cache->get(self::CACHE_KEY_ROUTES); } $routes ??= $this->extractRoutes($this->scan()->specification() ?? new Specification()); @@ -66,8 +95,8 @@ public function registerRoutes(): ?array $this->routingAdapter->register($route); } - if ($cache && !$this->options[self::OPTION_RELOAD]) { - $cache->set(self::CACHE_KEY_ROUTES, $routes); + if ($this->cache && !$this->reload) { + $this->cache->set(self::CACHE_KEY_ROUTES, $routes); } return $routes; @@ -139,7 +168,7 @@ protected function customProperties(OA\Operation $operation, array $classToPathI } $custom = [ - RoutingAdapterInterface::X_NAME => $this->options[self::OPTION_OA_OPERATION_ID_AS_NAME] ? $operation->operationId : null, + RoutingAdapterInterface::X_NAME => $this->operationIdAsName ? $operation->operationId : null, RoutingAdapterInterface::X_MIDDLEWARE => array_values(array_unique($middleware)), ]; @@ -193,24 +222,24 @@ protected function parameterMetadata(array $parameters): array return $metadata; } - public function scan(?LoggerInterface $logger = null): Result + public function scan(): Result { $builder = (new Builder()) ->addSource($this->sources) ->setMode(Mode::SPEC); - if ($logger instanceof LoggerInterface) { - $builder->setLogger($logger); + if ($this->logger instanceof LoggerInterface) { + $builder->setLogger($this->logger); } $result = $builder->build(); - if ($logger instanceof LoggerInterface) { + if ($this->logger instanceof LoggerInterface) { foreach ($result->errors() as $error) { - $logger->error($error); + $this->logger->error($error); } foreach ($result->warnings() as $warning) { - $logger->warning($warning); + $this->logger->warning($warning); } } diff --git a/src/RoutingAdapterInterface.php b/src/RoutingAdapterInterface.php index a444b7c..69da0fb 100644 --- a/src/RoutingAdapterInterface.php +++ b/src/RoutingAdapterInterface.php @@ -10,8 +10,6 @@ interface RoutingAdapterInterface public const X_NAME = 'name'; public const X_MIDDLEWARE = 'middleware'; - public const OPTION_AUTO_REGEX = 'autoregex'; - /** * Register a route. */ diff --git a/tests/Laravel/CachingTest.php b/tests/Laravel/CachingTest.php index 520c0a1..289d484 100644 --- a/tests/Laravel/CachingTest.php +++ b/tests/Laravel/CachingTest.php @@ -30,12 +30,9 @@ public static function reloadTests(): iterable */ public function testReload(?CacheInterface $cache, bool $reload, bool $openapisCached): void { - $options = [ - OpenApiRouter::OPTION_RELOAD => $reload, - OpenApiRouter::OPTION_CACHE => $cache, - ]; - - (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app = $this->getApp()), $options)) + (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app = $this->getApp()))) + ->withReload($reload) + ->withCache($cache) ->registerRoutes(); /** @var Router $router */ From b42b29baabcbb10e129cd68d1abb9abd9f2d506c Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sat, 19 Sep 2026 21:13:25 +1200 Subject: [PATCH 03/27] Modernise the toolchain and add phpstan at level 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tooling: - rector: was pinned at PHP_81 while composer requires >=8.2; now PHP_82 with the same prepared sets swagger-php uses (deadCode, codeQuality, codingStyle, typeDeclarations, phpunitCodeQuality) plus phpunit attribute sets, and tests/Fixtures skipped so attribute fixtures are left alone. - phpstan added at level 7 with no baseline, with an `analyse` script and its own static-analysis workflow, matching swagger-php/type-info-extras. Starting clean at 7 is affordable here precisely because v2 is a rewrite. - php-cs-fixer aligned with swagger-php: @PSR12 (was the superseded @PSR2), ordered_imports, php_unit_attributes, attribute_empty_parentheses, phpdoc_param_order, and crucially fully_qualified_strict_types with import_symbols — without it the FQCNs rector emits were never cleaned up. - phpunit.xml.dist was still on the PHPUnit 9 schema and emitted a deprecation every run; now 11.5 with /cacheDirectory. Both frameworks move into require-dev permanently. That was impossible while Lumen was supported (it conflicted with laravel/laravel), but Lumen went in #61, so a single install now covers both adapters. Which in turn collapses the per-framework workflows: slim.yml's matrix had exactly one Slim version, so it was a plain build; only the Laravel major axis carries signal, and that folds into build.yml. Fixes found by phpstan, not invented: - parameterMetadata() ignored OpenAPI 3.1 list types, so a parameter declared `type: ['integer', 'null']` silently lost its [0-9]+ route constraint. routableType() reduces a type list to the single routable scalar. - the Laravel adapter chained ->middleware()->where(); Route::middleware() returns the middleware list when called with no arguments, so its return type is `$this|array` and the chain was unsound. lint is also decoupled from test, matching swagger-php, so `composer test` runs phpunit only and lint stands on its own. --- .github/workflows/build.yml | 58 ++++++++++++++++++++---- .github/workflows/code-style.yml | 2 +- .github/workflows/laravel.yml | 60 ------------------------- .github/workflows/slim.yml | 49 -------------------- .github/workflows/static-analysis.yml | 28 ++++++++++++ .gitignore | 1 + .php-cs-fixer.dist.php | 14 ++++-- composer.json | 16 ++++--- phpstan.neon.dist | 11 +++++ phpunit.xml.dist | 19 ++++---- rector.php | 23 +++++++--- src/Adapters/LaravelRoutingAdapter.php | 11 ++--- src/Adapters/SlimRoutingAdapter.php | 6 ++- src/OpenApiRouter.php | 37 ++++++++++++--- src/RouteRegistration.php | 12 ++--- src/RoutingAdapterInterface.php | 1 + tests/Laravel/CachingTest.php | 24 +++++----- tests/Laravel/CallsApplicationTrait.php | 7 ++- tests/Laravel/LaravelTest.php | 38 +++++++--------- tests/Laravel/LaravelTestCase.php | 6 ++- tests/Laravel/ParametersTest.php | 27 +++++------ tests/Slim/CallsControllerTrait.php | 11 ++++- tests/Slim/ParametersTest.php | 33 +++++++------- tests/Slim/SlimTest.php | 37 +++++++-------- 24 files changed, 280 insertions(+), 251 deletions(-) delete mode 100644 .github/workflows/laravel.yml delete mode 100644 .github/workflows/slim.yml create mode 100644 .github/workflows/static-analysis.yml create mode 100644 phpstan.neon.dist diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 503da53..b3b0d5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,11 +11,53 @@ on: - main jobs: - call-laravel: - uses: ./.github/workflows/laravel.yml - with: - php-versions: '8.2,8.3,8.4,8.5' - call-slim: - uses: ./.github/workflows/slim.yml - with: - php-versions: '8.2,8.3,8.4,8.5' + test: + runs-on: ${{ matrix.operating-system }} + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GITHUB_TOKEN }}"}}' + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + strategy: + fail-fast: true + matrix: + operating-system: [ ubuntu-latest ] + php: [ '8.2', '8.3', '8.4', '8.5' ] + # both frameworks are in require-dev, so a single run covers both adapters; Laravel is + # the only one with more than one supported major, so it is the only axis varied here + laravel: [ '12.0', '13.0' ] + dependencies: [ 'lowest', 'highest' ] + exclude: + - php: '8.2' + laravel: '13.0' + + name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} on ${{ matrix.operating-system }} with ${{ matrix.dependencies }} dependencies + + steps: + - uses: actions/checkout@v6 + name: Checkout repository + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: pcov + + - name: Pin Laravel version + run: composer require --dev laravel/laravel "^${{ matrix.laravel }}" -W --no-update + + - name: Composer install + uses: ramsey/composer-install@v4 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: PHPUnit + Code coverage + run: | + mkdir -p build/logs + vendor/bin/phpunit --coverage-text --coverage-clover build/logs/clover.xml + + - name: Code coverage upload to Coveralls + if: matrix.php == '8.3' && matrix.dependencies == 'highest' && matrix.laravel == '13.0' + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + composer require php-coveralls/php-coveralls -W + vendor/bin/php-coveralls -v --coverage_clover=build/logs/clover.xml diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml index 14b5e83..5cc551c 100644 --- a/.github/workflows/code-style.yml +++ b/.github/workflows/code-style.yml @@ -24,5 +24,5 @@ jobs: with: dependency-versions: 'highest' - - name: Static Code Analysis + - name: Check Code Style run: composer lint diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml deleted file mode 100644 index 0e5e6f2..0000000 --- a/.github/workflows/laravel.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: laravel - -on: - workflow_call: - inputs: - php-versions: - required: true - type: string - -jobs: - test: - runs-on: ${{ matrix.operating-system }} - env: - COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GITHUB_TOKEN }}"}}' - COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - strategy: - fail-fast: true - matrix: - operating-system: [ ubuntu-latest ] - php: ${{ fromJson(format('[{0}]', inputs.php-versions)) }} - laravel: [ '12.0', '13.0' ] - dependencies: [ 'lowest', ' highest' ] - exclude: - - php: '8.2' - laravel: '13.0' - - name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} on ${{ matrix.operating-system }} with ${{ matrix.dependencies }} dependencies - - steps: - - uses: actions/checkout@v6 - name: Checkout repository - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - coverage: pcov - - - name: Install framework deps - run: | - composer require --dev laravel/laravel "^${{ matrix.laravel }}" -W - - - name: Composer install - uses: ramsey/composer-install@v4 - with: - dependency-versions: ${{ matrix.dependencies }} - composer-options: ${{ matrix.composer-options }} - - - name: PHPUnit + Code coverage - run: | - mkdir -p build/logs - vendor/bin/phpunit --coverage-text --coverage-clover build/logs/clover.xml - - - name: Code coverage upload to Coveralls - if: matrix.php == '8.3' && matrix.dependencies == 'highest' && matrix.laravel == '11.0' - env: - COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - composer require php-coveralls/php-coveralls -W - vendor/bin/php-coveralls -v --coverage_clover=build/logs/clover.xml diff --git a/.github/workflows/slim.yml b/.github/workflows/slim.yml deleted file mode 100644 index df52c45..0000000 --- a/.github/workflows/slim.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: slim - -on: - workflow_call: - inputs: - php-versions: - required: true - type: string - -jobs: - test: - runs-on: ${{ matrix.operating-system }} - env: - COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GITHUB_TOKEN }}"}}' - COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - strategy: - fail-fast: true - matrix: - operating-system: [ ubuntu-latest ] - php: ${{ fromJson(format('[{0}]', inputs.php-versions)) }} - slim: [ '4.0' ] - dependencies: [ 'lowest', 'highest' ] - - name: PHP ${{ matrix.php }} / Slim ${{ matrix.slim }} on ${{ matrix.operating-system }} with ${{ matrix.dependencies }} dependencies - - steps: - - uses: actions/checkout@v6 - name: Checkout repository - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - coverage: pcov - - - name: Install framework deps - run: | - composer require --dev slim/slim "^${{ matrix.slim }}" -W - - - name: Composer install - uses: ramsey/composer-install@v4 - with: - dependency-versions: ${{ matrix.dependencies }} - composer-options: ${{ matrix.composer-options }} - - - name: PHPUnit + Code coverage - run: | - mkdir -p build/logs - vendor/bin/phpunit --coverage-text --coverage-clover build/logs/clover.xml diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..6d8a653 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,28 @@ +name: static-analysis + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + static-analysis: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + name: Checkout repository + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + + - uses: ramsey/composer-install@v4 + with: + dependency-versions: 'highest' + + - name: Static Code Analysis + run: composer analyse diff --git a/.gitignore b/.gitignore index 55640a2..e60625e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea/ .php_cs.cache .php-cs-fixer.cache +.phpunit.cache/ .phpunit.result.cache composer.lock vendor/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 0641794..affda4a 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -8,12 +8,13 @@ return (new PhpCsFixer\Config()) ->setRules([ - '@PSR2' => true, - '@DoctrineAnnotation' => true, + '@PSR12' => true, + 'blank_line_after_opening_tag' => false, 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => true, 'no_unused_imports' => true, 'blank_line_before_statement' => ['statements' => ['return']], - 'visibility_required' => true, + 'modifier_keywords' => true, 'cast_spaces' => ['space' => 'single'], 'concat_space' => ['spacing' => 'one'], 'type_declaration_spaces' => true, @@ -40,9 +41,10 @@ 'trim_array_spaces' => true, 'single_space_around_construct' => true, 'single_line_comment_spacing' => true, - 'fully_qualified_strict_types' => true, + 'fully_qualified_strict_types' => ['import_symbols' => true, 'leading_backslash_in_global_namespace' => true], 'global_namespace_import' => ['import_classes' => false, 'import_constants' => null, 'import_functions' => null], + 'attribute_empty_parentheses' => ['use_parentheses' => false], 'nullable_type_declaration_for_default_null_value' => true, 'no_empty_phpdoc' => true, @@ -64,6 +66,10 @@ 'phpdoc_no_useless_inheritdoc' => true, 'phpdoc_no_empty_return' => true, 'phpdoc_no_alias_tag' => true, + 'phpdoc_param_order' => true, + 'multiline_comment_opening_closing' => true, + + 'php_unit_attributes' => true, ]) ->setFinder($finder) ; diff --git a/composer.json b/composer.json index 5e686e0..a06445b 100644 --- a/composer.json +++ b/composer.json @@ -13,17 +13,20 @@ ], "prefer-stable": true, "scripts": { - "cs": "php-cs-fixer fix --allow-risky=yes", + "cs": "export XDEBUG_MODE=off && php-cs-fixer fix --allow-risky=yes", "rector": "rector process src tests", "lint": [ "@cs --dry-run", "@rector --dry-run" ], - "test": [ - "phpunit", - "@lint" + "test": "export XDEBUG_MODE=off && phpunit", + "analyse": [ + "export XDEBUG_MODE=off && phpstan analyse --memory-limit=2G" ] }, + "scripts-descriptions": { + "analyse": "Run static analysis (phpstan)" + }, "config": { "sort-packages": true }, @@ -50,10 +53,13 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.62.0", + "laravel/laravel": "^12.0 || ^13.0", "nyholm/psr7": "^1.4", "nyholm/psr7-server": "^1.0", - "phpunit/phpunit": "^9.0 || ^10.5", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.5", "rector/rector": "^1.2 || ^2.0", + "slim/slim": "^4.0", "symfony/cache": "^6.0 || ^7.0", "symfony/psr-http-message-bridge": "^6.0 || ^7.0" }, diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..32273c9 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +parameters: + phpVersion: 80200 + level: 7 + paths: + - src + - tests + parallel: + processTimeout: 300.0 + excludePaths: + - 'tests/Fixtures/*' + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 365bf44..df48bc6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,16 +1,17 @@ - - - - src - - + + tests + + + src + + diff --git a/rector.php b/rector.php index cf258a1..c6d6467 100644 --- a/rector.php +++ b/rector.php @@ -1,12 +1,25 @@ withRules([ - TypedPropertyFromStrictConstructorRector::class + ->withSkipPath(__DIR__ . '/tests/Fixtures') + ->withSkip([ + NewlineBeforeNewAssignSetRector::class, + NewlineAfterStatementRector::class, + ReadOnlyPropertyRector::class, ]) - ->withPreparedSets(true, true) - ->withPhpVersion(PhpVersion::PHP_81); + ->withPreparedSets( + deadCode: true, + codeQuality: true, + codingStyle: true, + typeDeclarations: true, + phpunitCodeQuality: true, + ) + ->withAttributesSets(phpunit: true) + ->withPhpVersion(PhpVersion::PHP_82) + ->withPhpSets(); diff --git a/src/Adapters/LaravelRoutingAdapter.php b/src/Adapters/LaravelRoutingAdapter.php index 4ddcce1..874ffbc 100644 --- a/src/Adapters/LaravelRoutingAdapter.php +++ b/src/Adapters/LaravelRoutingAdapter.php @@ -30,7 +30,7 @@ public function register(RouteRegistration $route): void $where = []; foreach ($route->parameters as $name => $parameter) { - if (!$parameter['required'] && false !== strpos($path, $needle = "/{{$name}}")) { + if (!$parameter['required'] && str_contains($path, $needle = "/{{$name}}")) { $path = str_replace($needle, "/{{$name}?}", $path); } @@ -61,10 +61,11 @@ public function register(RouteRegistration $route): void $action['as'] = $route->custom[static::X_NAME]; } - $router - ->addRoute(strtoupper($route->method), $path, $action) - ->middleware($route->custom[static::X_MIDDLEWARE]) - ->where($where); + // not chained: Route::middleware() returns the middleware list when called without + // arguments, so its return type is `$this|array` and chaining off it is not safe + $laravelRoute = $router->addRoute(strtoupper($route->method), $path, $action); + $laravelRoute->middleware($route->custom[static::X_MIDDLEWARE]); + $laravelRoute->where($where); } /** diff --git a/src/Adapters/SlimRoutingAdapter.php b/src/Adapters/SlimRoutingAdapter.php index dc73730..e847c82 100644 --- a/src/Adapters/SlimRoutingAdapter.php +++ b/src/Adapters/SlimRoutingAdapter.php @@ -2,6 +2,7 @@ namespace Radebatz\OpenApi\Routing\Adapters; +use Psr\Container\ContainerInterface; use Radebatz\OpenApi\Routing\RouteRegistration; use Radebatz\OpenApi\Routing\RoutingAdapterInterface; use Slim\App; @@ -12,7 +13,8 @@ class SlimRoutingAdapter implements RoutingAdapterInterface { /** - * @param bool $autoRegex Constrain an `integer`-typed path parameter to `[0-9]+` + * @param App $app + * @param bool $autoRegex Constrain an `integer`-typed path parameter to `[0-9]+` */ public function __construct( protected App $app, @@ -31,7 +33,7 @@ public function register(RouteRegistration $route): void foreach ($route->parameters as $name => $parameter) { if (!$parameter['required']) { - if (false !== strpos($path, $needle = "/{{$name}}[/{")) { + if (str_contains($path, $needle = "/{{$name}}[/{")) { // multiple optional parameters $path = preg_replace("#/{{$name}}(\[?.*}\])#", "[/{{$name}}$1]", $path); } else { diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index f23f1a3..8b3f11c 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -20,12 +20,15 @@ class OpenApiRouter public const CACHE_KEY_ROUTES = 'openapi-router.routes'; protected bool $reload = true; + protected ?CacheInterface $cache = null; + protected bool $operationIdAsName = true; + protected ?LoggerInterface $logger = null; /** - * @param string|array|Finder $sources The directory(s) or filename(s) + * @param string|list|Finder $sources The directory(s) or filename(s) */ public function __construct( protected string|array|Finder $sources, @@ -85,7 +88,7 @@ public function registerRoutes(): ?array } $routes = null; - if ($this->cache && !$this->reload) { + if ($this->cache instanceof CacheInterface && !$this->reload) { $routes = $this->cache->get(self::CACHE_KEY_ROUTES); } @@ -95,7 +98,7 @@ public function registerRoutes(): ?array $this->routingAdapter->register($route); } - if ($this->cache && !$this->reload) { + if ($this->cache instanceof CacheInterface && !$this->reload) { $this->cache->set(self::CACHE_KEY_ROUTES, $routes); } @@ -144,7 +147,7 @@ protected function extractRoutes(Specification $specification): array } /** - * @param array $classToPathItem + * @param array $classToPathItem * * @return array */ @@ -204,16 +207,16 @@ protected function parameterMetadata(array $parameters): array ]; if ($schema = $parameter->schema) { - switch ($schema->type) { + switch ($this->routableType($schema->type)) { case 'string': - $metadata[$name]['type'] = $schema->type; + $metadata[$name]['type'] = 'string'; if ($pattern = $schema->pattern) { $metadata[$name]['type'] = 'regex'; $metadata[$name]['pattern'] = $pattern; } break; case 'integer': - $metadata[$name]['type'] = $schema->type; + $metadata[$name]['type'] = 'integer'; break; } } @@ -222,6 +225,26 @@ protected function parameterMetadata(array $parameters): array return $metadata; } + /** + * Reduce a schema type to the single scalar type routing cares about. + * + * OpenAPI 3.1 allows a list of types, which is how nullability is expressed + * (`['integer', 'null']`). `null` is not a routable type, so it is dropped; anything + * still ambiguous after that (a genuine union) has no single constraint to apply. + * + * @param string|list|null $type + */ + protected function routableType(string|array|null $type): ?string + { + if (!is_array($type)) { + return $type; + } + + $types = array_values(array_filter($type, static fn (string $candidate): bool => $candidate !== 'null')); + + return count($types) === 1 ? $types[0] : null; + } + public function scan(): Result { $builder = (new Builder()) diff --git a/src/RouteRegistration.php b/src/RouteRegistration.php index bb14393..f28390e 100644 --- a/src/RouteRegistration.php +++ b/src/RouteRegistration.php @@ -12,18 +12,18 @@ * has the `Specification` in hand and hands adapters this instead — cacheable, and it means * an adapter never needs to depend on swagger-php at all. */ -final class RouteRegistration +final readonly class RouteRegistration { /** * @param array $parameters URI parameter metadata, keyed by name, in reverse declaration order * @param array $custom `RoutingAdapterInterface::X_*` keys */ public function __construct( - public readonly string $path, - public readonly string $method, - public readonly string $controller, - public readonly array $parameters, - public readonly array $custom, + public string $path, + public string $method, + public string $controller, + public array $parameters, + public array $custom, ) { } } diff --git a/src/RoutingAdapterInterface.php b/src/RoutingAdapterInterface.php index 69da0fb..daeb2e7 100644 --- a/src/RoutingAdapterInterface.php +++ b/src/RoutingAdapterInterface.php @@ -8,6 +8,7 @@ interface RoutingAdapterInterface { public const X_NAME = 'name'; + public const X_MIDDLEWARE = 'middleware'; /** diff --git a/tests/Laravel/CachingTest.php b/tests/Laravel/CachingTest.php index 289d484..fa915c5 100644 --- a/tests/Laravel/CachingTest.php +++ b/tests/Laravel/CachingTest.php @@ -3,31 +3,29 @@ namespace Radebatz\OpenApi\Routing\Tests\Laravel; use Illuminate\Foundation\Application; +use Illuminate\Routing\Route; use Illuminate\Routing\Router; use Illuminate\Support\Facades\Facade; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\SimpleCache\CacheInterface; use Radebatz\OpenApi\Routing\Adapters\LaravelRoutingAdapter; use Radebatz\OpenApi\Routing\OpenApiRouter; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; -class CachingTest extends LaravelTestCase +final class CachingTest extends LaravelTestCase { use CallsApplicationTrait; - public static function reloadTests(): iterable + public static function reloadTests(): \Iterator { - return [ - 'no-cache-reload' => [null, true, false], - 'no-cache-no-reload' => [null, false, false], - 'cache-reload' => [new Psr16Cache(new ArrayAdapter()), false, true], - 'cache-no-reload' => [new Psr16Cache(new ArrayAdapter()), true, false], - ]; + yield 'no-cache-reload' => [null, true, false]; + yield 'no-cache-no-reload' => [null, false, false]; + yield 'cache-reload' => [new Psr16Cache(new ArrayAdapter()), false, true]; + yield 'cache-no-reload' => [new Psr16Cache(new ArrayAdapter()), true, false]; } - /** - * @dataProvider reloadTests - */ + #[DataProvider('reloadTests')] public function testReload(?CacheInterface $cache, bool $reload, bool $openapisCached): void { (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app = $this->getApp()))) @@ -37,9 +35,9 @@ public function testReload(?CacheInterface $cache, bool $reload, bool $openapisC /** @var Router $router */ $router = $app['router']; - $this->assertNotNull($router->getRoutes()->getByName('getya')); + $this->assertInstanceOf(Route::class, $router->getRoutes()->getByName('getya')); - $this->assertEquals($openapisCached, $cache && $cache->has(OpenApiRouter::CACHE_KEY_ROUTES)); + $this->assertSame($openapisCached, $cache instanceof CacheInterface && $cache->has(OpenApiRouter::CACHE_KEY_ROUTES)); } protected function getApp(): Application diff --git a/tests/Laravel/CallsApplicationTrait.php b/tests/Laravel/CallsApplicationTrait.php index 33b94db..abae028 100644 --- a/tests/Laravel/CallsApplicationTrait.php +++ b/tests/Laravel/CallsApplicationTrait.php @@ -18,7 +18,7 @@ trait CallsApplicationTrait protected function setUp(): void { - if (!class_exists('\\Illuminate\\Foundation\\Application')) { + if (!class_exists(Application::class)) { $this->markTestSkipped('not installed.'); } @@ -56,7 +56,10 @@ protected function getRouter(?Application $app = null): Router return $app['router']; } - protected function route(string $name, $parameters = [], bool $absolute = true): string + /** + * @param array|string $parameters + */ + protected function route(string $name, array|string $parameters = [], bool $absolute = true): string { return $this->createApplication()['url']->route($name, $parameters, $absolute); } diff --git a/tests/Laravel/LaravelTest.php b/tests/Laravel/LaravelTest.php index e2901bf..0ffb79e 100644 --- a/tests/Laravel/LaravelTest.php +++ b/tests/Laravel/LaravelTest.php @@ -2,30 +2,32 @@ namespace Radebatz\OpenApi\Routing\Tests\Laravel; +use Illuminate\Routing\Route; +use PHPUnit\Framework\Attributes\Test; use Radebatz\OpenApi\Routing\Tests\Fixtures\Middleware\BarMiddleware; use Radebatz\OpenApi\Routing\Tests\Fixtures\Middleware\FooMiddleware; -class LaravelTest extends LaravelTestCase +final class LaravelTest extends LaravelTestCase { use CallsApplicationTrait; - /** @test */ - public function namedRoute() + #[Test] + public function namedRoute(): void { - $this->assertNotNull($this->getRouter()->getRoutes()->getByName('getya')); + $this->assertInstanceOf(Route::class, $this->getRouter()->getRoutes()->getByName('getya')); } - /** @test */ - public function invoke() + #[Test] + public function invoke(): void { - $this->assertNotNull($this->getRouter()->getRoutes()->getByName('invoke')); + $this->assertInstanceOf(Route::class, $this->getRouter()->getRoutes()->getByName('invoke')); $response = $this->get($this->route('invoke', 'joe')); $response->assertStatus(200); } - /** @test */ - public function prefixed() + #[Test] + public function prefixed(): void { $response = $this->get($this->route('prefixed')); $response->assertStatus(200); @@ -34,28 +36,20 @@ public function prefixed() $response->assertStatus(200); } - /** - * @test - * - * @requires PHP 8.1 - */ - public function attributesPrefixed() + #[Test] + public function attributesPrefixed(): void { $response = $this->get('attributes/prefixed'); echo $response->getContent(); $response->assertStatus(200); } - /** - * @test - * - * @requires PHP 8.1 - */ - public function attributesMiddleware() + #[Test] + public function attributesMiddleware(): void { $route = $this->getRouter()->getRoutes()->getByName('attributes'); - $this->assertNotNull($route); + $this->assertInstanceOf(Route::class, $route); $this->assertSame([FooMiddleware::class, BarMiddleware::class], $route->gatherMiddleware()); } } diff --git a/tests/Laravel/LaravelTestCase.php b/tests/Laravel/LaravelTestCase.php index 3d7b6fd..58e6c2d 100644 --- a/tests/Laravel/LaravelTestCase.php +++ b/tests/Laravel/LaravelTestCase.php @@ -2,12 +2,14 @@ namespace Radebatz\OpenApi\Routing\Tests\Laravel; -if (class_exists('\\Illuminate\\Foundation\\Testing\\TestCase')) { +use PHPUnit\Framework\TestCase; + +if (class_exists(\Illuminate\Foundation\Testing\TestCase::class)) { abstract class LaravelTestCase extends \Illuminate\Foundation\Testing\TestCase { } } else { - class LaravelTestCase extends \PHPUnit\Framework\TestCase + class LaravelTestCase extends TestCase { } } diff --git a/tests/Laravel/ParametersTest.php b/tests/Laravel/ParametersTest.php index bfce287..7cf38b9 100644 --- a/tests/Laravel/ParametersTest.php +++ b/tests/Laravel/ParametersTest.php @@ -4,38 +4,39 @@ use Illuminate\Http\Request; use Illuminate\Routing\Route; +use PHPUnit\Framework\Attributes\Test; -class ParametersTest extends LaravelTestCase +final class ParametersTest extends LaravelTestCase { use CallsApplicationTrait; - /** @test */ - public function parameter() + #[Test] + public function parameter(): void { /* @var Route $route */ - $this->assertNotNull($route = $this->getRouter()->getRoutes()->getByName('hey')); + $this->assertInstanceOf(Route::class, $route = $this->getRouter()->getRoutes()->getByName('hey')); $this->assertEquals('hey/{name}', $route->uri()); } - /** @test */ - public function optionalParameter() + #[Test] + public function optionalParameter(): void { - $this->assertNotNull($route = $this->getRouter()->getRoutes()->getByName('oi')); + $this->assertInstanceOf(Route::class, $route = $this->getRouter()->getRoutes()->getByName('oi')); $this->assertEquals('oi/{name?}', $route->uri()); } - /** @test */ - public function typedParameter() + #[Test] + public function typedParameter(): void { - $this->assertNotNull($route = $this->getRouter()->getRoutes()->getByName('id')); + $this->assertInstanceOf(Route::class, $route = $this->getRouter()->getRoutes()->getByName('id')); $this->assertTrue($route->matches(Request::create('id/123'))); $this->assertFalse($route->matches(Request::create('id/12x3'))); } - /** @test */ - public function regexParameter() + #[Test] + public function regexParameter(): void { - $this->assertNotNull($route = $this->getRouter()->getRoutes()->getByName('hid')); + $this->assertInstanceOf(Route::class, $route = $this->getRouter()->getRoutes()->getByName('hid')); $this->assertTrue($route->matches(Request::create('hid/a1b2c3'))); $this->assertFalse($route->matches(Request::create('hid/z12x3'))); } diff --git a/tests/Slim/CallsControllerTrait.php b/tests/Slim/CallsControllerTrait.php index 7c5a10a..b71e870 100644 --- a/tests/Slim/CallsControllerTrait.php +++ b/tests/Slim/CallsControllerTrait.php @@ -3,6 +3,7 @@ namespace Radebatz\OpenApi\Routing\Tests\Slim; use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Radebatz\OpenApi\Routing\Adapters\SlimRoutingAdapter; use Radebatz\OpenApi\Routing\OpenApiRouter; @@ -17,11 +18,14 @@ trait CallsControllerTrait protected function setUp(): void { - if (!class_exists('\\Slim\\App') || version_compare(App::VERSION, '4.0.0', '<')) { + if (!class_exists(App::class) || version_compare(App::VERSION, '4.0.0', '<')) { $this->markTestSkipped('not installed.'); } } + /** + * @param App|null $app + */ protected function getRouteCollector(?App $app = null): RouteCollectorInterface { $app = $app ?: $this->getApp(); @@ -29,6 +33,9 @@ protected function getRouteCollector(?App $app = null): RouteCollectorInterface return $app->getRouteCollector(); } + /** + * @return App + */ protected function getApp(): App { $app = AppFactory::create(); @@ -42,7 +49,7 @@ protected function getApp(): App return $app; } - protected function call($path, $method = 'GET'): ResponseInterface + protected function call(string $path, string $method = 'GET'): ResponseInterface { $request = (new Psr17Factory())->createServerRequest($method, $path); diff --git a/tests/Slim/ParametersTest.php b/tests/Slim/ParametersTest.php index d4fb229..b552b76 100644 --- a/tests/Slim/ParametersTest.php +++ b/tests/Slim/ParametersTest.php @@ -2,44 +2,45 @@ namespace Radebatz\OpenApi\Routing\Tests\Slim; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -class ParametersTest extends TestCase +final class ParametersTest extends TestCase { use CallsControllerTrait; - /** @test */ - public function parameter() + #[Test] + public function parameter(): void { $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('hey')); - $this->assertEquals('/hey/{name}', $route->getPattern()); + $this->assertSame('/hey/{name}', $route->getPattern()); } - /** @test */ - public function optionalParameter() + #[Test] + public function optionalParameter(): void { $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('oi')); - $this->assertEquals('/oi[/{name}]', $route->getPattern()); + $this->assertSame('/oi[/{name}]', $route->getPattern()); } - /** @test */ - public function multiOptionalParameter() + #[Test] + public function multiOptionalParameter(): void { $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('multi')); - $this->assertEquals('/multi[/{foo}[/{bar}]]', $route->getPattern()); + $this->assertSame('/multi[/{foo}[/{bar}]]', $route->getPattern()); } - /** @test */ - public function typedParameter() + #[Test] + public function typedParameter(): void { $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('id')); - $this->assertEquals('/id/{id:[0-9]+}', $route->getPattern()); + $this->assertSame('/id/{id:[0-9]+}', $route->getPattern()); } - /** @test */ - public function regexParameter() + #[Test] + public function regexParameter(): void { $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('hid')); - $this->assertEquals('/hid/{hid:[0-9a-f]+}', $route->getPattern()); + $this->assertSame('/hid/{hid:[0-9a-f]+}', $route->getPattern()); } } diff --git a/tests/Slim/SlimTest.php b/tests/Slim/SlimTest.php index 55069f0..d7ea8b5 100644 --- a/tests/Slim/SlimTest.php +++ b/tests/Slim/SlimTest.php @@ -2,53 +2,50 @@ namespace Radebatz\OpenApi\Routing\Tests\Slim; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -class SlimTest extends TestCase +final class SlimTest extends TestCase { use CallsControllerTrait; - /** @test */ - public function namedRoute() + #[Test] + public function namedRoute(): void { $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('getya')); - $this->assertEquals('/getya', $route->getPattern()); + $this->assertSame('/getya', $route->getPattern()); } - /** @test */ - public function request() + #[Test] + public function request(): void { $response = $this->call('/static_getya'); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertSame(200, $response->getStatusCode()); $this->assertEquals('Static Get ya', $response->getBody()); } - /** @test */ - public function prefixed() + #[Test] + public function prefixed(): void { $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('prefixed')); $response = $this->call('/foo/prefixed'); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertSame(200, $response->getStatusCode()); } - /** @test */ - public function middleware() + #[Test] + public function middleware(): void { $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('mw')); $response = $this->call('/mw'); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertSame(200, $response->getStatusCode()); } - /** - * @test - * - * @requires PHP 8.1 - */ - public function attributesPrefixed() + #[Test] + public function attributesPrefixed(): void { $response = $this->call('attributes/prefixed'); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertSame(200, $response->getStatusCode()); } } From e89e9db41da3a816dd780814fdd4df8513d10c32 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 13:09:09 +1200 Subject: [PATCH 04/27] Reject misplaced Middleware, and tighten docblocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Middleware::isRoot() now returns false. Attachable::isRoot() is unconditionally true, so a #[Middleware] with no Operation or PathItem sibling to merge into was silently collected as a root attachable and then ignored by the router — the mistake produced empty middleware lists rather than an error. Declaring it non-root makes the assembler reject the leftover instead. Attributes that merge successfully are dropped by AttributeFactory::resolveNesting() before the check, so correctly placed middleware is unaffected. Adds MiddlewareTest: merging into a method-level Operation, into a class-level PathItem, and the rejection above (verified to fail without the isRoot() override). The nesting is convention-driven with no type to enforce it, and there was no direct test of the attribute at all. Docblocks follow one shape throughout: summary ending in a full stop, a blank line, then the description. Class and method blocks stay multiline even when they only carry tags; the single-line form is reserved for a lone @var or similar. --- src/Attributes/Middleware.php | 27 ++++-- src/OpenApiRouter.php | 13 +-- src/RouteRegistration.php | 9 +- tests/Attributes/MiddlewareTest.php | 90 +++++++++++++++++++ .../Fixtures/Invalid/MisplacedMiddleware.php | 18 ++++ tests/Laravel/CallsApplicationTrait.php | 4 +- 6 files changed, 140 insertions(+), 21 deletions(-) create mode 100644 tests/Attributes/MiddlewareTest.php create mode 100644 tests/Fixtures/Invalid/MisplacedMiddleware.php diff --git a/src/Attributes/Middleware.php b/src/Attributes/Middleware.php index 8f28ecd..c7f53f2 100644 --- a/src/Attributes/Middleware.php +++ b/src/Attributes/Middleware.php @@ -7,12 +7,11 @@ use OpenApi\Spec\PathItem; /** - * Attaches one or more PSR-15 middleware to an operation or its containing controller. + * Attaches PSR-15 middleware to an operation or its containing controller. * - * Stack directly alongside an `Operation` attribute (per-route) or a `PathItem` attribute - * (per-controller, resolved via `OpenApiRouter`'s own class-to-`PathItem` lookup — swagger-php's - * `PathItems` augmenter clones `tags`/`security`/`responses` from a class-level `PathItem` onto - * its operations, but not `attachables`, so this package resolves that inheritance itself): + * Stack beside an `Operation` (per-route) or a `PathItem` (per-controller). Controller-level + * inheritance is resolved by `OpenApiRouter`, since swagger-php's `PathItems` augmenter does + * not clone `attachables` down to operations. * * #[OA\PathItem(prefix: '/users')] * #[Middleware(names: ['auth'])] @@ -27,7 +26,7 @@ class Middleware extends Attachable { /** - * @param array $names PSR-15 middleware names/class-strings, in the shape the routing adapter expects + * @param array $names Middleware names or class-strings, as the adapter expects them */ public function __construct( public array $names = [], @@ -35,9 +34,9 @@ public function __construct( } /** - * Without this, `Attachable::isRoot()` (true, unconditionally) means an unmerged - * `Middleware` lands in `Specification::$attachables` as its own top-level, orphaned - * entry instead of nesting into the `Operation`/`PathItem` it was stacked beside. + * Nest into the `Operation` or `PathItem` this is stacked beside. + * + * Without it the attribute stays unmerged and is silently orphaned. * * @return array */ @@ -48,4 +47,14 @@ public function merge(): array PathItem::class => 'attachables[]', ]; } + + /** + * Middleware only ever qualifies an operation or path item, never stands alone. + * + * Saying so turns a misplaced `#[Middleware]` into an assembly error. + */ + public function isRoot(): bool + { + return false; + } } diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index 8b3f11c..d953c0a 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -37,8 +37,10 @@ public function __construct( } /** - * Force a rescan on every {@see registerRoutes()} call rather than trusting the cache or - * the adapter's own cached routes. Typically off in production. + * Rescan on every {@see registerRoutes()} call. + * + * Bypasses both the configured cache and the adapter's own cached routes. Typically off + * in production. */ public function withReload(bool $reload = true): static { @@ -58,8 +60,9 @@ public function withCache(?CacheInterface $cache): static } /** - * Use the operation's `operationId` as the route name. When off, only an explicit - * `x-name` vendor property names a route. + * Use the operation's `operationId` as the route name. + * + * When off, only an explicit `x-name` vendor property names a route. */ public function withOperationIdAsName(bool $operationIdAsName = true): static { @@ -187,7 +190,7 @@ protected function customProperties(OA\Operation $operation, array $classToPathI } /** - * Extract (uri) parameter meta data. + * Extract URI parameter metadata. * * @param list $parameters * diff --git a/src/RouteRegistration.php b/src/RouteRegistration.php index f28390e..ca873ab 100644 --- a/src/RouteRegistration.php +++ b/src/RouteRegistration.php @@ -5,12 +5,9 @@ /** * Everything a routing adapter needs to register one route. * - * Deliberately plain data, decoupled from swagger-php's `Spec\*` DTOs: every root DTO in the - * spec pipeline carries a live `\Reflector` internally (`AbstractAttribute::getReflector()`) - * with no `__serialize()`/`__sleep()` to strip it, so a `Spec\Operation` cannot survive a - * PSR-16 cache round-trip. `OpenApiRouter` resolves everything a route needs while it still - * has the `Specification` in hand and hands adapters this instead — cacheable, and it means - * an adapter never needs to depend on swagger-php at all. + * Plain data rather than a `Spec\Operation`, because spec DTOs hold a live `\Reflector` with + * no `__serialize()` and so cannot survive a PSR-16 cache round-trip. Adapters therefore need + * no swagger-php dependency of their own. */ final readonly class RouteRegistration { diff --git a/tests/Attributes/MiddlewareTest.php b/tests/Attributes/MiddlewareTest.php new file mode 100644 index 0000000..47fdcdb --- /dev/null +++ b/tests/Attributes/MiddlewareTest.php @@ -0,0 +1,90 @@ +collect(new \ReflectionClass($class)); + + return $assembler->getSpecification(); + } + + public function testMergesIntoTheOperationItIsStackedOn(): void + { + $specification = $this->assemble(AttributeController::class); + + $this->assertCount(1, $specification->operations); + $names = $this->middlewareNames($specification->operations[0]->attachables ?? []); + + $this->assertSame([BarMiddleware::class], $names); + } + + public function testMergesIntoTheContainingPathItem(): void + { + $specification = $this->assemble(AttributeController::class); + + $this->assertCount(1, $specification->pathItems); + $names = $this->middlewareNames($specification->pathItems[0]->attachables ?? []); + + $this->assertSame([FooMiddleware::class], $names); + } + + /** + * An unmergeable instance must fail rather than vanish. + * + * Nothing merged it and it is not a root, so silently landing in + * `Specification::$attachables` would hide the mistake. + */ + public function testMisplacedMiddlewareIsRejected(): void + { + $this->expectException(OpenApiException::class); + $this->expectExceptionMessageMatches('/Non-root attribute .*Middleware/'); + + $this->assemble(MisplacedMiddleware::class); + } + + public function testIsNeverARootAttribute(): void + { + $this->assertFalse((new Middleware())->isRoot()); + } + + /** + * @param list $attachables + * + * @return list + */ + private function middlewareNames(array $attachables): array + { + $names = []; + foreach ($attachables as $attachable) { + if ($attachable instanceof Middleware) { + $names = array_merge($names, $attachable->names); + } + } + + return array_values($names); + } +} diff --git a/tests/Fixtures/Invalid/MisplacedMiddleware.php b/tests/Fixtures/Invalid/MisplacedMiddleware.php new file mode 100644 index 0000000..4a47ca3 --- /dev/null +++ b/tests/Fixtures/Invalid/MisplacedMiddleware.php @@ -0,0 +1,18 @@ +app) { From ce97de1f2eab0fe38654831daf69742aac7fcb75 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 13:32:46 +1200 Subject: [PATCH 05/27] Let callers supply the builder, and stop double-logging diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan() constructed its Builder inline, so every extension point swagger-php exposes — augmenters, translators, resolver, compiler, setVersion() — was unreachable. The builder is now overridable via withBuilder(), with defaultBuilder() public so a caller can start from this package's defaults and adjust rather than rebuild them. Supplying a builder means supplying it whole, logger included. Also drops the re-reporting of Result::errors()/warnings() to the logger. The builder already forwards compiler diagnostics there, so every warning was logged twice; Result carries the compiler messages only, while the logger additionally sees scan-time warnings, making it the superset and the re-report pure duplication. --- src/OpenApiRouter.php | 63 ++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index d953c0a..3b15148 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -27,6 +27,8 @@ class OpenApiRouter protected ?LoggerInterface $logger = null; + protected ?Builder $builder = null; + /** * @param string|list|Finder $sources The directory(s) or filename(s) */ @@ -36,6 +38,37 @@ public function __construct( ) { } + /** + * Scan with a caller-configured builder instead of {@see defaultBuilder()}. + * + * The builder is used as given — sources, mode and logger included — so start from + * `defaultBuilder()` to keep this package's defaults. + */ + public function withBuilder(?Builder $builder): static + { + $this->builder = $builder; + + return $this; + } + + /** + * The builder used when none is supplied. + * + * Public so it can serve as the starting point for a customised one. + */ + public function defaultBuilder(): Builder + { + $builder = (new Builder()) + ->addSource($this->sources) + ->setMode(Mode::SPEC); + + if ($this->logger instanceof LoggerInterface) { + $builder->setLogger($this->logger); + } + + return $builder; + } + /** * Rescan on every {@see registerRoutes()} call. * @@ -72,7 +105,9 @@ public function withOperationIdAsName(bool $operationIdAsName = true): static } /** - * Receive scan warnings/errors here, from both {@see scan()} and {@see registerRoutes()}. + * Logger for {@see defaultBuilder()}. + * + * Ignored when a builder is supplied via {@see withBuilder()}, which carries its own. */ public function withLogger(?LoggerInterface $logger): static { @@ -248,27 +283,13 @@ protected function routableType(string|array|null $type): ?string return count($types) === 1 ? $types[0] : null; } + /** + * Build the specification. + * + * Diagnostics reach the logger through the builder, so they are not re-reported here. + */ public function scan(): Result { - $builder = (new Builder()) - ->addSource($this->sources) - ->setMode(Mode::SPEC); - - if ($this->logger instanceof LoggerInterface) { - $builder->setLogger($this->logger); - } - - $result = $builder->build(); - - if ($this->logger instanceof LoggerInterface) { - foreach ($result->errors() as $error) { - $this->logger->error($error); - } - foreach ($result->warnings() as $warning) { - $this->logger->warning($warning); - } - } - - return $result; + return ($this->builder ?? $this->defaultBuilder())->build(); } } From 8b076a7b6929c02f532a8f73c63644e13b4bcc73 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 13:42:21 +1200 Subject: [PATCH 06/27] Drop scan(); registerRoutes() builds inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan() produced a byte-identical document to a plain Builder call, so it added nothing over using swagger-php directly. With withBuilder() and defaultBuilder() in place, customisation no longer needs it either — the one line it wrapped moves into registerRoutes(). The test helpers were its only callers, building a second OpenApiRouter purely to write an openapi.yaml that nothing reads and .gitignore excludes. Removing that halves the scan work per test app; the suite runs in roughly two thirds the time. A user wanting a document calls swagger-php's Builder or CLI, which is now the whole story rather than one of two. --- src/OpenApiRouter.php | 15 +++------------ tests/Laravel/CallsApplicationTrait.php | 3 --- tests/Slim/CallsControllerTrait.php | 3 --- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index 3b15148..23f84ae 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -4,7 +4,6 @@ use OpenApi\Builder; use OpenApi\Builder\Mode; -use OpenApi\Builder\Result; use OpenApi\Spec as OA; use OpenApi\Specification; use Psr\Log\LoggerInterface; @@ -130,7 +129,9 @@ public function registerRoutes(): ?array $routes = $this->cache->get(self::CACHE_KEY_ROUTES); } - $routes ??= $this->extractRoutes($this->scan()->specification() ?? new Specification()); + $routes ??= $this->extractRoutes( + ($this->builder ?? $this->defaultBuilder())->build()->specification() ?? new Specification() + ); foreach ($routes as $route) { $this->routingAdapter->register($route); @@ -282,14 +283,4 @@ protected function routableType(string|array|null $type): ?string return count($types) === 1 ? $types[0] : null; } - - /** - * Build the specification. - * - * Diagnostics reach the logger through the builder, so they are not re-reported here. - */ - public function scan(): Result - { - return ($this->builder ?? $this->defaultBuilder())->build(); - } } diff --git a/tests/Laravel/CallsApplicationTrait.php b/tests/Laravel/CallsApplicationTrait.php index ff052ff..ef29b48 100644 --- a/tests/Laravel/CallsApplicationTrait.php +++ b/tests/Laravel/CallsApplicationTrait.php @@ -41,9 +41,6 @@ public function createApplication() (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app))) ->registerRoutes(); - $result = (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app))) - ->scan(); - file_put_contents(__DIR__ . '/openapi.yaml', $result->toYaml()); $this->app = $app; } diff --git a/tests/Slim/CallsControllerTrait.php b/tests/Slim/CallsControllerTrait.php index b71e870..96deede 100644 --- a/tests/Slim/CallsControllerTrait.php +++ b/tests/Slim/CallsControllerTrait.php @@ -42,9 +42,6 @@ protected function getApp(): App (new OpenApiRouter($this->getFixtureFinder(), new SlimRoutingAdapter($app))) ->registerRoutes(); - $result = (new OpenApiRouter($this->getFixtureFinder(), new SlimRoutingAdapter($app))) - ->scan(); - file_put_contents(__DIR__ . '/openapi.yaml', $result->toYaml()); return $app; } From 652814aef229d48f934698a6d23702e8f8b612db Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 13:43:24 +1200 Subject: [PATCH 07/27] Drop the gitignore entry for a file nothing generates tests/*/openapi.yaml was written by the test helpers, not by the router, and those writes went with scan(). The entry now excludes nothing, which is the failure mode where an exclusion keeps looking load-bearing while protecting against nothing. Stale copies removed from the working tree; confirmed a full run recreates neither. --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index e60625e..2823918 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,4 @@ composer.lock vendor/ tests/storage/ -tests/*/openapi.yaml /.claude/settings.local.json From 9ca09a4f311dbced07c2ff91119ffc21cc8bd90d Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 13:53:22 +1200 Subject: [PATCH 08/27] Rewrite the docs for 5.x README and Configuration.md both described 4.x: openapi-extras attributes, an options array whose every key is gone, and two worked examples that would fatal. Configuration.md also still listed the Lumen adapter removed in #61, and the Slim example used Slim 3 bootstrapping. Adds docs/Upgrading.md, leading with what PathItem gains over the Controller attribute it replaces rather than presenting a swap table, and naming the three things with no equivalent: inherit:false, the wrap envelope, and the openapi-extras attributes themselves, which cannot be kept by re-requiring the package because Mode::SPEC cannot see classic annotations. Documents generating the OpenAPI document with swagger-php directly, since this package no longer contributes to it. phpdoc filled in where the public surface is thinnest: RoutingAdapterInterface, which is what a third-party adapter implements and had no @param at all, and RouteRegistration, whose reverse parameter ordering is load-bearing for Slim's nested optional placeholders. Every snippet was executed rather than composed: the README controller registers as /api/v1/getme with the operationId as its name, the CLI invocation produces the document shown, the operationId hashing workaround and the translator hook both work as written, and Middleware is confirmed absent from the output. --- README.md | 70 ++++++++----- docs/Configuration.md | 174 +++++++++++++++++--------------- docs/Upgrading.md | 161 +++++++++++++++++++++++++++++ src/RouteRegistration.php | 7 +- src/RoutingAdapterInterface.php | 31 +++++- 5 files changed, 331 insertions(+), 112 deletions(-) create mode 100644 docs/Upgrading.md diff --git a/README.md b/README.md index ea4ea4f..68fc54c 100644 --- a/README.md +++ b/README.md @@ -5,79 +5,99 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ## Introduction -Allows to (re-)use [Swagger-PHP](https://github.com/zircote/swagger-php) attributes (docblock annotations are deprecated), -to configure routes in the following frameworks: + +Configure framework routes from the [swagger-php](https://github.com/zircote/swagger-php) +attributes already describing your API, so the routing table and the OpenAPI document cannot +drift apart. + +Supported frameworks: * [Laravel](https://github.com/laravel/laravel) * [Slim](https://github.com/slimphp/Slim) - ## Requirements -* [PHP 8.1 or higher](http://www.php.net/) - depending on framework version. - -## Installation -You can use **composer** or simply **download the release**. +* PHP 8.2 or higher +* `zircote/swagger-php` ^6.9 -**Composer** +Attributes are read through swagger-php's spec pipeline (`OpenApi\Spec`). Docblock +annotations are not supported — see the [upgrade guide](docs/Upgrading.md) if you are +coming from 4.x. -The preferred method is via [composer](https://getcomposer.org). Follow the -[installation instructions](https://getcomposer.org/doc/00-intro.md) if you do not already have -composer installed. - -Once composer is installed, execute the following command in your project root to install this library: +## Installation ```sh composer require radebatz/openapi-router ``` -After that all required classes should be availabe in your project to add routing support. ## Basic usage -Example using the `Slim` framework adapter and standard [OpenApi attributes](https://zircote.github.io/swagger-php/guide/attributes) only. - **Controller** + ```php write('Get me'); } } ``` +`PathItem` applies to every operation in the class: `prefix` composes into their paths, and +`tags`, `security` and `responses` are shared with them. It composes along the class +hierarchy, so a base controller can carry what its subclasses have in common. + +`Middleware` is this package's own attribute — middleware is a routing concern and has no +place in the OpenAPI document, so it never appears in the output. Stack it on the class for +every route in it, or on a single method. + **index.php** + ```php registerRoutes(); $app->run(); ``` +## Generating the OpenAPI document + +Use swagger-php directly — its CLI or `Builder` over the same sources: + +```sh +./vendor/bin/openapi --mode spec -o openapi.yaml src/controllers +``` + +This package contributes nothing to the document, so routing it through here would only add +a layer. + ## Documentation + * [Configuration](docs/Configuration.md) +* [Upgrading to 5.x](docs/Upgrading.md) ## License diff --git a/docs/Configuration.md b/docs/Configuration.md index 21f677a..3ec7272 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -1,113 +1,125 @@ # Configuration -## Global Configuration -The `OpenApiRouter` class takes an array (map) as optional third constructor argument which allows to customise -its behaviour. +`OpenApiRouter` is configured with fluent setters, each returning the router so calls chain. +All are optional. -All option names (keys) are defined as class constants in `Radebatz\OpenApi\Routing\OpenApiRouter`. +```php +use Radebatz\OpenApi\Routing\Adapters\LaravelRoutingAdapter; +use Radebatz\OpenApi\Routing\OpenApiRouter; + +(new OpenApiRouter([__DIR__ . '/../src/Controllers'], new LaravelRoutingAdapter($app))) + ->withReload(false) + ->withCache($psr16Cache) + ->registerRoutes(); +``` + +## Router -**`OPTION_RELOAD`** ---- -Enforces loading of route annotations on each request. +### `withReload(bool $reload = true)` -Typically you want this turned off on production. Requires a cache confgured (annotation caching) or caching support implemented by the used adapter. +Rescan on every `registerRoutes()` call, bypassing both the configured cache and the +adapter's own cached routes. Default `true`; turn it off in production. -**Note**: When using a framework it is recommended to rely on the framework caching rather than using the (simple) build in cache. +### `withCache(?CacheInterface $cache)` -Default: `true` +A PSR-16 cache for the extracted routes, used when `withReload(false)` is set. Default +`null`. -**`OPTION_CACHE`** ---- -Instance of a PSR-16 simple cache. +Routes are cached rather than the specification, because a `Spec\Operation` holds a live +`\Reflector` and cannot be serialized. -Used for caching of parsed OpenApi annotations if the `reload` option is disabled. +### `withOperationIdAsName(bool $operationIdAsName = true)` -Default: `null` +Use each operation's `operationId` as the route name. Default `true`. When off, only an +explicit `x-name` names a route. -**`OPTION_OA_INFO_INJECT`** ---- -Controls whether to inject a default `@OA\Info` instance while scanning. +Note swagger-php hashes generated operation ids by default, so relying on this without +declaring `operationId` yourself produces hashed route names. Either declare them, or turn +hashing off through the builder: -This can be useful for testing or small projects. +```php +$builder = $router->defaultBuilder(); +$builder->getAugmenters()->get(OpenApi\Augmenter\OperationIds::class)?->setHash(false); +$router->withBuilder($builder); +``` -Default: `false` +### `withLogger(?LoggerInterface $logger)` -**`OPTION_OA_OPERATION_ID_AS_NAME`** ---- -Controls whether to use the configured `operationId` as the route name. If disabled the adapter will look for a vendor property -`x-name` on the operation (`Get`, `Post`, etc.) attribute. +A PSR-3 logger for the scan, passed to `defaultBuilder()`. Ignored when a builder is supplied +via `withBuilder()`, which carries its own. -Allows to set the route name via the standard `operationId` rather than the vendor `x-name`. +### `withBuilder(?Builder $builder)` and `defaultBuilder()` -**Note**: The default for `operationId` in `swagger-php` is to generate an operationId and hash it if it is explicitely set. generated +`defaultBuilder()` returns the `OpenApi\Builder` this package would use — sources added, spec +mode set, logger applied. `withBuilder()` replaces it. -Default: `true` +Start from `defaultBuilder()` to keep those defaults and adjust: -### Example use ```php - true, - OpenApiRouter::OPTION_CACHE => new ArrayCache(), - ]; - - (new OpenApiRouter([__DIR__ . '/Fixtures/Laravel'], new LaravelRoutingAdapter($app), $options)) - ->registerRoutes(); +$builder = $router->defaultBuilder(); +$builder->withAttributeFactory( + fn ($factory) => $factory->withTranslators( + fn ($translators) => $translators->add(new MyTranslator()) + ) +); + +$router->withBuilder($builder)->registerRoutes(); ``` -## Adapter Configuration -Each framework is different and that means that not all features are available in all adapters. -Adapter configuration keys are available as constants on the `RoutingAdapterInterface` interface. +Both hooks take a callable and receive the thing to configure, which is swagger-php's +convention throughout `Builder`. + +A supplied builder is used as given, so it must carry its own sources and mode. -Right now these options are available: +This matters for routing when it changes *which operations are discovered* — a translator +turning a framework-native attribute into a `Spec\Operation` adds routes. Configuration that +only shapes the document belongs in a direct swagger-php call instead; this package does not +generate documents. -**`OPTION_AUTO_REGEX`** ---- -When enabled the adapter will automatically configure a `[0-9]+` regex for any path elements defined as integer. +## Adapters -Available for: -* All adapters +Both adapters take the framework application and one option: - default: `true` +```php +new LaravelRoutingAdapter($app, autoRegex: false); +new SlimRoutingAdapter($app, autoRegex: false); +``` -**`OPTION_NAMESPACE`** ---- -Specifies a base namespace for all controllers. If set this will be removed from the controller classes passed into the -framework router. +### `$autoRegex` -Available for: -* `LaravelRouteringAdapter` +Constrain a path parameter declared `type: 'integer'` to `[0-9]+`. Default `true`. - default: `'App\\Http\\Controllers\\'` -* `LumneRoutingAdapter` - - default: `'App\\Http\\Controllers\\'` +Laravel expresses this as a `where()` constraint, Slim as a `{id:[0-9]+}` placeholder. A +parameter whose schema carries an explicit `pattern` uses that instead, regardless of this +setting. -### Example use +## Vendor properties -```php - true, - OpenApiRouter::OPTION_CACHE => new ArrayCache(), - ]; - - $adapterOptions = [ - RoutingAdapterInterface::OPTION_AUTO_REGEX => false, - RoutingAdapterInterface::OPTION_NAMESPACE => 'My\\App', - ]; - - (new OpenApiRouter([__DIR__ . '/Fixtures/Laravel'], new LaravelRoutingAdapter($app, $adapterOptions), $options)) - ->registerRoutes(); +| Property | Effect | +|---|---| +| `x-name` | **Replaces** the route name, whatever `withOperationIdAsName()` would have produced | +| `x-middleware` | **Appends** to the middleware from `#[Middleware]` attributes | + +```php +#[OA\Operation\Get(path: '/pets', operationId: 'listPets', x: [ + 'name' => 'pets.index', + 'middleware' => ['throttle:60,1'], +])] ``` + +The constants are `RoutingAdapterInterface::X_NAME` and `X_MIDDLEWARE`. + +## Middleware + +`#[Middleware(names: [...])]` attaches middleware in whatever form the framework expects — +a class-string, or an alias such as Laravel's `auth`. + +Stack it beside an `OA\Operation` for a single route, or beside an `OA\PathItem` for every +route in that controller; both apply, and the class-level entries come first. It is an +`Attachable` and never appears in the generated document. + +A `#[Middleware]` with no `OA\Operation` or `OA\PathItem` beside it is an error rather than a +silent no-op. diff --git a/docs/Upgrading.md b/docs/Upgrading.md new file mode 100644 index 0000000..8b96426 --- /dev/null +++ b/docs/Upgrading.md @@ -0,0 +1,161 @@ +# Upgrading to 5.x + +5.x reads attributes through swagger-php's **spec pipeline** (`OpenApi\Spec`) instead of the +classic annotation pipeline, and drops `radebatz/openapi-extras`. Every controller attribute +changes, and so does the router's configuration API. + +The reason it is worth doing: `OA\PathItem` — core swagger-php — does everything +openapi-extras' `Controller` did and several things it could not. + +## Requirements + +| | 4.x | 5.x | +|---|---|---| +| PHP | 8.1+ | 8.2+ | +| swagger-php | `^4.11 \|\| ^5.0 \|\| ^6.0` | `^6.9` | +| `radebatz/openapi-extras` | required | removed | +| docblock annotations | supported | not read at all | + +## Controller attributes + +### `OAX\Controller` becomes `OA\PathItem` + +```diff +-use OpenApi\Attributes as OA; +-use Radebatz\OpenApi\Extras\Attributes as OAX; ++use OpenApi\Spec as OA; + +-#[OAX\Controller(prefix: '/api/v1', tags: ['pets'])] ++#[OA\PathItem(prefix: '/api/v1', tags: ['pets'])] + #[OA\Response(response: 401, description: 'Unauthorized')] + class PetController +``` + +`prefix`, `tags` and `responses` behave as before. `PathItem` adds what `Controller` had no +way to express: + +- **`parameters`** — shared path parameters, emitted at path level as the specification + defines them instead of repeated on every operation +- **`servers`**, **`summary`**, **`description`** — also real path-level output +- **`security`** — cloned to contained operations the way tags and responses are +- **`ref`** — the path item can itself be a `$ref` + +Composition also improved: prefixes compose along the whole class hierarchy, and tags, +security, responses and parameters accumulate from every ancestor with deduplication by +value, scheme, status code and name+in respectively. + +**Two `Controller` features have no equivalent.** `middlewares` moves to this package's own +attribute (below). **`inherit: false`**, which stopped the ancestor walk at a given class, +is gone — `PathItem` composition is unconditional. If you relied on it, flatten the +hierarchy or stop inheriting from the ancestor carrying the unwanted metadata. + +### Operation attributes are namespaced + +```diff +-#[OA\Get(path: '/pets/{id}')] ++#[OA\Operation\Get(path: '/pets/{id}')] +``` + +Likewise `Post`, `Put`, `Patch`, `Delete`, `Head`, `Options`, `Trace`. See swagger-php's +[migration notes](https://zircote.github.io/swagger-php/) for the full attribute mapping — +this package only changes the two things it owns. + +### `OAX\Middleware` becomes `Middleware` + +```diff +-use Radebatz\OpenApi\Extras\Attributes as OAX; ++use Radebatz\OpenApi\Routing\Attributes\Middleware; + +-#[OAX\Middleware(names: ['auth'])] ++#[Middleware(names: ['auth'])] +``` + +Same shape, and it now lives here rather than in a separate package. A `#[Middleware]` with +no `OA\Operation` or `OA\PathItem` beside it is now an error instead of being silently +ignored. + +## What openapi-extras took with it + +4.x bundled openapi-extras' processors, so its attributes worked in your controllers whether +or not you depended on the package directly. They are gone, and **re-requiring +`radebatz/openapi-extras` will not bring them back** — `JsonResponse` extends +`OpenApi\Annotations\Response`, so these are classic-pipeline annotations that the spec +pipeline cannot see. + +### `JsonRequestBody` / `JsonResponse` + +Shorthand for JSON content. Write it out: + +```diff +-#[OAX\JsonResponse(response: 200, ref: Pet::class)] ++#[OA\Response(response: 200, description: 'A pet', content: [ ++ new OA\MediaType(mediaType: 'application/json', schema: new OA\Schema(ref: Pet::class)), ++])] +``` + +### `JsonResponse`'s `wrap` + +`wrap: 'data'` nested the payload under an envelope key. There is no equivalent — it was +bespoke. Either declare the wrapper schema explicitly, or reimplement it as a swagger-php +augmenter and register it through [`withBuilder()`](Configuration.md). + +### `EnumDescription` and `Customizers` + +swagger-php ships `Augmenter\EnumDescriptions`, which covers the first. `Customizers` and +`addCustomizer()` are replaced by the builder hooks reachable through `withBuilder()`. + +## Configuration + +The options array is gone; each option is a fluent setter. + +```diff +-$options = [ +- OpenApiRouter::OPTION_RELOAD => false, +- OpenApiRouter::OPTION_CACHE => $cache, +-]; +-(new OpenApiRouter($sources, new SlimRoutingAdapter($app), $options)) +- ->registerRoutes(); ++(new OpenApiRouter($sources, new SlimRoutingAdapter($app))) ++ ->withReload(false) ++ ->withCache($cache) ++ ->registerRoutes(); +``` + +| 4.x | 5.x | +|---|---| +| `OPTION_RELOAD` | `withReload()` | +| `OPTION_CACHE` | `withCache()` | +| `OPTION_OA_OPERATION_ID_AS_NAME` | `withOperationIdAsName()` | +| `OPTION_OA_INFO_INJECT` | removed — declare `OA\Info` yourself, or let document generation handle it | +| `OPTION_AUTO_REGEX` (adapter options array) | `$autoRegex` constructor argument | +| `RoutingAdapterInterface::OPTION_NAMESPACE` | removed with the Lumen adapter in 4.x | + +`CACHE_KEY_OPENAPI` is now `CACHE_KEY_ROUTES`: the extracted routes are cached rather than +the specification, because a `Spec\Operation` holds a live `\Reflector` and cannot be +serialized. Clear any existing cache on upgrade. + +## `scan()` is removed + +4.x exposed `scan()` to produce the OpenAPI document. It no longer contributes anything — +the output is byte-identical to running swagger-php over the same sources — so generate the +document with swagger-php directly: + +```sh +./vendor/bin/openapi --mode spec -o openapi.yaml src/Controllers +``` + +In 4.x this was not possible: the document was wrong without openapi-extras' processors, +which is why `scan()` existed. + +## Custom adapters + +`RoutingAdapterInterface::register()` takes a single `RouteRegistration` instead of four +arguments: + +```diff +-public function register(Operation $operation, string $controller, array $parameters, array $custom): void ++public function register(RouteRegistration $route): void +``` + +The values are the same, as properties: `$route->path`, `->method`, `->controller`, +`->parameters`, `->custom`. Adapters no longer depend on swagger-php at all. diff --git a/src/RouteRegistration.php b/src/RouteRegistration.php index ca873ab..51d5722 100644 --- a/src/RouteRegistration.php +++ b/src/RouteRegistration.php @@ -12,8 +12,11 @@ final readonly class RouteRegistration { /** - * @param array $parameters URI parameter metadata, keyed by name, in reverse declaration order - * @param array $custom `RoutingAdapterInterface::X_*` keys + * @param string $path URL path with OpenAPI placeholders, prefixes already composed — `/api/v1/pets/{id}` + * @param string $method HTTP method, upper case + * @param string $controller `Fully\Qualified\Class::method`; `__invoke` for a single-action controller + * @param array $parameters path parameter metadata keyed by name, in **reverse** declaration order — consecutive optional parameters nest (`/multi[/{foo}[/{bar}]]`), which requires bracketing the last one first. `type` is `string`, `integer`, `regex` or `null`; `pattern` accompanies `regex` + * @param array $custom keyed by the `RoutingAdapterInterface::X_*` constants — `X_NAME` (`?string`) and `X_MIDDLEWARE` (`list`) are always present */ public function __construct( public string $path, diff --git a/src/RoutingAdapterInterface.php b/src/RoutingAdapterInterface.php index daeb2e7..de2be85 100644 --- a/src/RoutingAdapterInterface.php +++ b/src/RoutingAdapterInterface.php @@ -3,23 +3,46 @@ namespace Radebatz\OpenApi\Routing; /** - * Routing adapter interface. + * Translates extracted routes into a framework's own router. + * + * One implementation per framework. Adapters receive plain data and do not depend on + * swagger-php. */ interface RoutingAdapterInterface { + /** + * Key in `RouteRegistration::$custom` holding the route name, or `null` for an unnamed route. + * + * Also the `x-name` vendor property, which overrides the name the router would derive. + */ public const X_NAME = 'name'; + /** + * Key in `RouteRegistration::$custom` holding the middleware list. + * + * Also the `x-middleware` vendor property, which appends to any `#[Middleware]` attributes. + */ public const X_MIDDLEWARE = 'middleware'; /** - * Register a route. + * Register one route with the framework. + * + * Called once per operation, in scan order. Implementations translate + * `RouteRegistration::$path` into the framework's own placeholder syntax — the parameter + * metadata says which placeholders are optional and which carry a type or pattern. + * + * @param RouteRegistration $route the route to register */ public function register(RouteRegistration $route): void; /** - * Register routes cached on framework level (optional). + * Load routes the framework itself has cached, skipping the scan entirely. + * + * Only consulted when reloading is off. Returning `true` means the routes are registered + * and {@see register()} will not be called; return `false` when the framework has no + * route cache or it is empty. * - * @return bool `true` if cached routes loaded + * @return bool `true` if cached routes were loaded */ public function registerCached(): bool; } From ad4b7c023a5c30759c1b485bde76563639121a52 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 13:55:34 +1200 Subject: [PATCH 09/27] Point the README at swagger-php's attribute docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every attribute except #[Middleware] is swagger-php's, so this package links rather than duplicating — a second copy would only drift. 4.x had such a link but it pointed at /guide/attributes, which no longer exists. The replacements are the spec-pipeline pages specifically: guide/spec-attributes and reference/spec-attributes cover OpenApi\Spec, whereas guide/using-attributes documents the classic OpenApi\Attributes namespace this package does not read. All three URLs verified to resolve. Also states that spec attributes are beta upstream, since the whole attribute layer this depends on carries that caveat and a user choosing the package should see it. --- README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 68fc54c..dff6b84 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,12 @@ Supported frameworks: * PHP 8.2 or higher * `zircote/swagger-php` ^6.9 -Attributes are read through swagger-php's spec pipeline (`OpenApi\Spec`). Docblock -annotations are not supported — see the [upgrade guide](docs/Upgrading.md) if you are -coming from 4.x. +Attributes are read through swagger-php's spec pipeline — the `OpenApi\Spec` namespace, not +the classic `OpenApi\Attributes` one. Docblock annotations are not supported; see the +[upgrade guide](docs/Upgrading.md) if you are coming from 4.x. + +Spec attributes are [marked beta upstream](https://zircote.github.io/swagger-php/guide/spec-attributes) +and their API may still change. ## Installation @@ -83,6 +86,20 @@ $app = AppFactory::create(); $app->run(); ``` +## Writing the attributes + +Every attribute except `#[Middleware]` is swagger-php's, and documented there: + +* [Using spec attributes](https://zircote.github.io/swagger-php/guide/spec-attributes) — how + to write them +* [Spec attribute reference](https://zircote.github.io/swagger-php/reference/spec-attributes) + — every attribute and its parameters +* [Modes](https://zircote.github.io/swagger-php/guide/modes) — how the spec pipeline differs + from classic + +This package adds one attribute of its own, [`#[Middleware]`](docs/Configuration.md), and +reads two vendor properties. + ## Generating the OpenAPI document Use swagger-php directly — its CLI or `Builder` over the same sources: From 03316183d6f68c50f77fd755840f8e5cfc6a23a8 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 14:06:31 +1200 Subject: [PATCH 10/27] Take a builder hook rather than a builder, and widen the source type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withBuilder(Builder) plus defaultBuilder() had two problems. "Default" was ambiguous — swagger-php's own default mode is classic, so the name suggested something this package would never use. And passing a builder silently made the constructor's $sources dead: sources went unused, zero routes registered, no warning. withBuilder(callable) fixes both. The router always builds, always applies sources, spec mode and logger, then hands the builder to the hook, which may mutate it or return a replacement. That is the shape swagger-php uses for withResolver() and withAugmenters(), and it removes a public method rather than adding one. $sources was typed string|array|Finder, which singled out Finder for no reason — it is merely one iterable — while excluding \SplFileInfo, \Reflector and every other iterable that Builder::addSource() accepts. Now matches Builder. The \Reflector case matters for reflector-driven sources. --- docs/Configuration.md | 38 ++++++++++++++++---------------------- src/OpenApiRouter.php | 43 ++++++++++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index 3ec7272..e2afa86 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -38,38 +38,32 @@ declaring `operationId` yourself produces hashed route names. Either declare the hashing off through the builder: ```php -$builder = $router->defaultBuilder(); -$builder->getAugmenters()->get(OpenApi\Augmenter\OperationIds::class)?->setHash(false); -$router->withBuilder($builder); +$router->withBuilder(function (Builder $builder): void { + $builder->getAugmenters()->get(OpenApi\Augmenter\OperationIds::class)?->setHash(false); +}); ``` ### `withLogger(?LoggerInterface $logger)` -A PSR-3 logger for the scan, passed to `defaultBuilder()`. Ignored when a builder is supplied -via `withBuilder()`, which carries its own. +A PSR-3 logger for the scan, applied to the builder before the `withBuilder()` hook runs. -### `withBuilder(?Builder $builder)` and `defaultBuilder()` +### `withBuilder(callable $hook)` -`defaultBuilder()` returns the `OpenApi\Builder` this package would use — sources added, spec -mode set, logger applied. `withBuilder()` replaces it. - -Start from `defaultBuilder()` to keep those defaults and adjust: +Configure the `OpenApi\Builder` before it runs. The hook receives one already carrying this +package's sources, spec mode and logger, and may modify it in place or return a replacement — +the same shape as swagger-php's own `withResolver()` and `withAugmenters()`. ```php -$builder = $router->defaultBuilder(); -$builder->withAttributeFactory( - fn ($factory) => $factory->withTranslators( - fn ($translators) => $translators->add(new MyTranslator()) - ) -); - -$router->withBuilder($builder)->registerRoutes(); +$router->withBuilder(function (Builder $builder): void { + $builder->withAttributeFactory( + fn ($factory) => $factory->withTranslators( + fn ($translators) => $translators->add(new MyTranslator()) + ) + ); +})->registerRoutes(); ``` -Both hooks take a callable and receive the thing to configure, which is swagger-php's -convention throughout `Builder`. - -A supplied builder is used as given, so it must carry its own sources and mode. +Sources and mode are always applied by the router, so the hook cannot accidentally drop them. This matters for routing when it changes *which operations are discovered* — a translator turning a framework-native attribute into a `Spec\Operation` adds routes. Configuration that diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index 23f84ae..7618349 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -9,7 +9,6 @@ use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Radebatz\OpenApi\Routing\Attributes\Middleware; -use Symfony\Component\Finder\Finder; /** * OpenApi router. @@ -26,36 +25,41 @@ class OpenApiRouter protected ?LoggerInterface $logger = null; - protected ?Builder $builder = null; + /** @var (callable(Builder): (Builder|void))|null */ + protected $builderHook; /** - * @param string|list|Finder $sources The directory(s) or filename(s) + * @param string|\SplFileInfo|\Reflector|iterable $sources directories, filenames or reflectors to scan — whatever `Builder::addSource()` accepts */ public function __construct( - protected string|array|Finder $sources, + protected string|\SplFileInfo|\Reflector|iterable $sources, protected RoutingAdapterInterface $routingAdapter, ) { } /** - * Scan with a caller-configured builder instead of {@see defaultBuilder()}. + * Configure the builder before it runs. * - * The builder is used as given — sources, mode and logger included — so start from - * `defaultBuilder()` to keep this package's defaults. + * The hook receives a builder already carrying this package's sources, spec mode and + * logger, and may modify it in place or return a replacement — the same shape as + * swagger-php's own `withResolver()` and `withAugmenters()`. + * + * Relevant to routing when it changes which operations are discovered; configuration + * that only shapes the document belongs in a direct swagger-php call. + * + * @param callable(Builder): (Builder|void) $hook */ - public function withBuilder(?Builder $builder): static + public function withBuilder(callable $hook): static { - $this->builder = $builder; + $this->builderHook = $hook; return $this; } /** - * The builder used when none is supplied. - * - * Public so it can serve as the starting point for a customised one. + * The builder for this scan, hook applied. */ - public function defaultBuilder(): Builder + protected function builder(): Builder { $builder = (new Builder()) ->addSource($this->sources) @@ -65,6 +69,13 @@ public function defaultBuilder(): Builder $builder->setLogger($this->logger); } + if ($this->builderHook !== null) { + $customised = ($this->builderHook)($builder); + if ($customised instanceof Builder) { + $builder = $customised; + } + } + return $builder; } @@ -104,9 +115,7 @@ public function withOperationIdAsName(bool $operationIdAsName = true): static } /** - * Logger for {@see defaultBuilder()}. - * - * Ignored when a builder is supplied via {@see withBuilder()}, which carries its own. + * PSR-3 logger for the scan. */ public function withLogger(?LoggerInterface $logger): static { @@ -130,7 +139,7 @@ public function registerRoutes(): ?array } $routes ??= $this->extractRoutes( - ($this->builder ?? $this->defaultBuilder())->build()->specification() ?? new Specification() + $this->builder()->build()->specification() ?? new Specification() ); foreach ($routes as $route) { From 9b454708b9a9667fca284a52edbf89a90e5e91d5 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 14:07:49 +1200 Subject: [PATCH 11/27] Use set* for values and with* for hooks swagger-php draws this line with no exceptions: all 28 set* methods take a value, all 7 with* methods take a callable. Converting the options array to fluent setters earlier claimed to mirror that Builder but used with* for everything, so four of five methods carried a prefix promising a hook and taking a bool or an object. setReload(), setCache(), setOperationIdAsName() and setLogger() are values. withBuilder() is the only hook and keeps its prefix. setLogger() now also matches Builder::setLogger() exactly. --- docs/Configuration.md | 19 +++++++++++-------- docs/Upgrading.md | 11 ++++++----- src/OpenApiRouter.php | 10 +++++----- tests/Laravel/CachingTest.php | 4 ++-- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index e2afa86..4a08520 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -3,32 +3,35 @@ `OpenApiRouter` is configured with fluent setters, each returning the router so calls chain. All are optional. +Prefixes follow swagger-php's convention: `set*` takes a value, `with*` takes a callable that +configures something. + ```php use Radebatz\OpenApi\Routing\Adapters\LaravelRoutingAdapter; use Radebatz\OpenApi\Routing\OpenApiRouter; (new OpenApiRouter([__DIR__ . '/../src/Controllers'], new LaravelRoutingAdapter($app))) - ->withReload(false) - ->withCache($psr16Cache) + ->setReload(false) + ->setCache($psr16Cache) ->registerRoutes(); ``` ## Router -### `withReload(bool $reload = true)` +### `setReload(bool $reload = true)` Rescan on every `registerRoutes()` call, bypassing both the configured cache and the adapter's own cached routes. Default `true`; turn it off in production. -### `withCache(?CacheInterface $cache)` +### `setCache(?CacheInterface $cache)` -A PSR-16 cache for the extracted routes, used when `withReload(false)` is set. Default +A PSR-16 cache for the extracted routes, used when `setReload(false)` is set. Default `null`. Routes are cached rather than the specification, because a `Spec\Operation` holds a live `\Reflector` and cannot be serialized. -### `withOperationIdAsName(bool $operationIdAsName = true)` +### `setOperationIdAsName(bool $operationIdAsName = true)` Use each operation's `operationId` as the route name. Default `true`. When off, only an explicit `x-name` names a route. @@ -43,7 +46,7 @@ $router->withBuilder(function (Builder $builder): void { }); ``` -### `withLogger(?LoggerInterface $logger)` +### `setLogger(?LoggerInterface $logger)` A PSR-3 logger for the scan, applied to the builder before the `withBuilder()` hook runs. @@ -94,7 +97,7 @@ routing behaviour — they are read by this package only. | Property | Effect | |---|---| -| `x-name` | **Replaces** the route name, whatever `withOperationIdAsName()` would have produced | +| `x-name` | **Replaces** the route name, whatever `setOperationIdAsName()` would have produced | | `x-middleware` | **Appends** to the middleware from `#[Middleware]` attributes | ```php diff --git a/docs/Upgrading.md b/docs/Upgrading.md index 8b96426..beea5e2 100644 --- a/docs/Upgrading.md +++ b/docs/Upgrading.md @@ -116,18 +116,19 @@ The options array is gone; each option is a fluent setter. -(new OpenApiRouter($sources, new SlimRoutingAdapter($app), $options)) - ->registerRoutes(); +(new OpenApiRouter($sources, new SlimRoutingAdapter($app))) -+ ->withReload(false) -+ ->withCache($cache) ++ ->setReload(false) ++ ->setCache($cache) + ->registerRoutes(); ``` | 4.x | 5.x | |---|---| -| `OPTION_RELOAD` | `withReload()` | -| `OPTION_CACHE` | `withCache()` | -| `OPTION_OA_OPERATION_ID_AS_NAME` | `withOperationIdAsName()` | +| `OPTION_RELOAD` | `setReload()` | +| `OPTION_CACHE` | `setCache()` | +| `OPTION_OA_OPERATION_ID_AS_NAME` | `setOperationIdAsName()` | | `OPTION_OA_INFO_INJECT` | removed — declare `OA\Info` yourself, or let document generation handle it | | `OPTION_AUTO_REGEX` (adapter options array) | `$autoRegex` constructor argument | +| — | `withBuilder()` is new: a callable configuring the swagger-php builder | | `RoutingAdapterInterface::OPTION_NAMESPACE` | removed with the Lumen adapter in 4.x | `CACHE_KEY_OPENAPI` is now `CACHE_KEY_ROUTES`: the extracted routes are cached rather than diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index 7618349..7da1eee 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -85,7 +85,7 @@ protected function builder(): Builder * Bypasses both the configured cache and the adapter's own cached routes. Typically off * in production. */ - public function withReload(bool $reload = true): static + public function setReload(bool $reload = true): static { $this->reload = $reload; @@ -93,9 +93,9 @@ public function withReload(bool $reload = true): static } /** - * Cache extracted routes here across requests when {@see withReload()} is off. + * Cache extracted routes here across requests when {@see setReload()} is off. */ - public function withCache(?CacheInterface $cache): static + public function setCache(?CacheInterface $cache): static { $this->cache = $cache; @@ -107,7 +107,7 @@ public function withCache(?CacheInterface $cache): static * * When off, only an explicit `x-name` vendor property names a route. */ - public function withOperationIdAsName(bool $operationIdAsName = true): static + public function setOperationIdAsName(bool $operationIdAsName = true): static { $this->operationIdAsName = $operationIdAsName; @@ -117,7 +117,7 @@ public function withOperationIdAsName(bool $operationIdAsName = true): static /** * PSR-3 logger for the scan. */ - public function withLogger(?LoggerInterface $logger): static + public function setLogger(?LoggerInterface $logger): static { $this->logger = $logger; diff --git a/tests/Laravel/CachingTest.php b/tests/Laravel/CachingTest.php index fa915c5..4db1b1a 100644 --- a/tests/Laravel/CachingTest.php +++ b/tests/Laravel/CachingTest.php @@ -29,8 +29,8 @@ public static function reloadTests(): \Iterator public function testReload(?CacheInterface $cache, bool $reload, bool $openapisCached): void { (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app = $this->getApp()))) - ->withReload($reload) - ->withCache($cache) + ->setReload($reload) + ->setCache($cache) ->registerRoutes(); /** @var Router $router */ From f5f2187fd89bed1e5f214a912d3c654a56bbbe7a Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 14:09:49 +1200 Subject: [PATCH 12/27] Add CONTEXT.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records this package's vocabulary and the words it deliberately avoids, following swagger-php's format. Their terminology is linked rather than restated; where a word belongs to both, theirs wins. The distinction the whole package rests on is operation vs route: an operation is declared, a route is registered, and this turns the first into the second — so they are never interchangeable. Also pins "middleware" to the framework's meaning rather than swagger-php's pipeline stages, and "name" to the route name rather than the operation id. Includes the set*/with* rule as a term, since getting it wrong is what prompted the file: the prefix carries meaning and a written rule catches that before the code is written rather than after. Flags one unresolved ambiguity: the rewrite is called v2 internally and the branch is v2/spec-pipeline, but it releases as 5.0. --- CONTEXT.md | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 107 insertions(+) create mode 100644 CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..1382c47 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,106 @@ +# openapi-router + +Configures a PHP framework's router from the swagger-php **spec attributes** already +describing an API, so the routing table and the OpenAPI document cannot drift apart. + +Terminology for this package. swagger-php's own vocabulary is defined in +[its CONTEXT.md](https://github.com/zircote/swagger-php/blob/master/CONTEXT.md) and is not +repeated here — where a word belongs to both, theirs wins. + +## Language + +### Core concepts + +**Route**: +One framework-level mapping of method + path to a handler. What this package produces. +_Avoid_: endpoint (an API-design word, not a framework one), path (means the URL alone) + +**Operation**: +A swagger-php `Spec\Operation` — one HTTP method on one path, as declared in an attribute. +The **input** side of what this package does; a route is the output. +_Avoid_: using it for the registered route, or as a synonym for **action** + +**Adapter**: +The per-framework translator that registers a route. One implementation per framework, +behind `RoutingAdapterInterface`. +_Avoid_: driver, bridge, integration + +**Registration**: +The act of handing one route to the framework's router, and the `RouteRegistration` value +object carrying everything needed to do it. +_Avoid_: binding, mapping (both mean something else in Laravel) + +**Controller**: +The `Class::method` string a route dispatches to. Written as a **handler** by some +frameworks. +_Avoid_: action, callable — `RouteRegistration::$controller` is the name, and it is always +`Class::method`, never a closure + +### Attributes + +**Middleware**: +This package's own attribute, and the only one it defines. An `Attachable`, so it is routing +metadata that never reaches the OpenAPI document. +_Avoid_: calling it an OpenAPI attribute — it deliberately is not, which is why it lives +outside the `OpenApi\` namespace + +**Vendor property**: +An `x-*` key on an operation that this package reads — `x-name` and `x-middleware`. +_Avoid_: extension, custom property; swagger-php calls these vendor extensions and the `x` +constructor argument carries them + +**Attachable**: +swagger-php's extension point for metadata that rides along with an attribute without +appearing in the output. Theirs, not ours — see their CONTEXT.md. + +### Configuration + +**`set*`**: +A fluent setter taking a **value**. Follows swagger-php, where the prefix means exactly this. +_Avoid_: `with*` for a value — the prefix is load-bearing + +**`with*`**: +A fluent setter taking a **callable** that configures something. `withBuilder()` is the only +one here. +_Avoid_: `with*` taking the configured object itself; swagger-php has no such method and it +reintroduces the question of what the caller must supply + +**Source**: +Whatever is scanned for attributes — directories, files or reflectors. Passed to the +constructor and forwarded to `Builder::addSource()`. +_Avoid_: path, directory (both too narrow), input + +## Relationships + +- An **adapter** receives a **registration** per **route** and translates it for its framework +- A **registration** is derived from one **operation**, plus any **middleware** attached to it + or to its containing `PathItem`, plus any **vendor properties** +- **Sources** feed swagger-php's `Builder`, which produces the `Specification` the + **operations** are read from +- `withBuilder()` configures that `Builder`; it matters to routing only when it changes + *which operations are discovered* + +## Example dialogue + +> **Dev:** "My middleware isn't being applied." +> **Domain expert:** "Is the `#[Middleware]` beside an **operation** or a `PathItem`? Anywhere +> else it has nothing to merge into, and that is an error rather than a silent no-op." + +> **Dev:** "Can I get the OpenAPI document out of the router?" +> **Domain expert:** "No — this package contributes nothing to it. Run swagger-php over the +> same **sources**; the output is identical." + +## Flagged ambiguities + +- "route" vs "operation" — **resolved**: an **operation** is declared (swagger-php's input), a + **route** is registered (the framework's output). This package turns the first into the + second. Never use them interchangeably, because the whole package is the mapping between + them. +- "middleware" — **resolved**: always means the framework's middleware, named by the + `Middleware` attribute or `x-middleware`. Never swagger-php's pipeline stages, which are + **processors** (classic) or **augmenters** (spec). +- "name" — **resolved**: the *route* name, which `setOperationIdAsName()` derives from the + `operationId` and `x-name` overrides. Not the operation id itself, and not a path. +- "v2" — **unresolved**, and worth care. The rewrite is called v2 internally and the branch is + `v2/spec-pipeline`, but it releases as **5.0** — the last tag is 4.0.0. Say "5.x" in + anything public. diff --git a/README.md b/README.md index dff6b84..ab07250 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ a layer. * [Configuration](docs/Configuration.md) * [Upgrading to 5.x](docs/Upgrading.md) +* [Terminology](CONTEXT.md) — the words this package uses, and the ones it avoids ## License From 5952747e6485d9f59b18d4452c45d48d327b5a4c Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 14:12:19 +1200 Subject: [PATCH 13/27] State the spec-pipeline constraint in the introduction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was in Requirements, but the introduction above it said routes come from "the attributes already describing your API" — which is wrong for most readers. Spec-pipeline adoption is close to zero, so a visitor almost certainly has classic attributes this does not read, and would have believed the package worked for them until ten lines later. Now stated once, at the top, with the beta caveat alongside it; Requirements is back to versions only. --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ab07250..633f2f2 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,15 @@ ## Introduction Configure framework routes from the [swagger-php](https://github.com/zircote/swagger-php) -attributes already describing your API, so the routing table and the OpenAPI document cannot -drift apart. +attributes describing your API, so the routing table and the OpenAPI document cannot drift +apart. + +**Spec attributes only.** Routes are read from the `OpenApi\Spec` namespace — swagger-php's +spec pipeline. The classic `OpenApi\Attributes` namespace and docblock annotations are not +read at all, so a codebase on classic needs converting first; see the +[upgrade guide](docs/Upgrading.md) if you are coming from 4.x. Spec attributes are +[marked beta upstream](https://zircote.github.io/swagger-php/guide/spec-attributes) and their +API may still change. Supported frameworks: @@ -20,13 +27,6 @@ Supported frameworks: * PHP 8.2 or higher * `zircote/swagger-php` ^6.9 -Attributes are read through swagger-php's spec pipeline — the `OpenApi\Spec` namespace, not -the classic `OpenApi\Attributes` one. Docblock annotations are not supported; see the -[upgrade guide](docs/Upgrading.md) if you are coming from 4.x. - -Spec attributes are [marked beta upstream](https://zircote.github.io/swagger-php/guide/spec-attributes) -and their API may still change. - ## Installation ```sh From 817abc9b35f69402486071e57fe794b4ba1b0977 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 14:15:55 +1200 Subject: [PATCH 14/27] Move attribute usage out of Configuration, correct the vendor extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configuration.md held two sections that are not configuration: the #[Middleware] attribute and the vendor extensions are what you write in a controller, not how you set up the router. They move to the README's "Writing the attributes" section, which already existed to cover exactly that, and Configuration.md is now router and adapter settings only. Both were also documented wrongly. They were described as not reaching the OpenAPI document — the opposite is true, vendor extensions are emitted, and that is the real tradeoff against #[Middleware], which is an Attachable and stays out. And the declared key is unprefixed ('name'), while the document shows x-name; the table and the example disagreed on which spelling was which. The constants hold the unprefixed form. Renames Upgrading.md to UpgradingTo5.md so the next major gets its own file rather than overwriting this one. --- CONTEXT.md | 7 ++-- README.md | 53 ++++++++++++++++++++++---- docs/Configuration.md | 31 --------------- docs/{Upgrading.md => UpgradingTo5.md} | 0 src/RoutingAdapterInterface.php | 6 ++- 5 files changed, 53 insertions(+), 44 deletions(-) rename docs/{Upgrading.md => UpgradingTo5.md} (100%) diff --git a/CONTEXT.md b/CONTEXT.md index 1382c47..30eccf8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -45,9 +45,10 @@ _Avoid_: calling it an OpenAPI attribute — it deliberately is not, which is wh outside the `OpenApi\` namespace **Vendor property**: -An `x-*` key on an operation that this package reads — `x-name` and `x-middleware`. -_Avoid_: extension, custom property; swagger-php calls these vendor extensions and the `x` -constructor argument carries them +A vendor extension on an operation that this package reads. Declared unprefixed — +`x: ['name' => ...]` — and emitted with the prefix, as `x-name`. Say which form you mean. +_Avoid_: naming the declared key `x-name`; that is the emitted spelling, and the constants +(`X_NAME`, `X_MIDDLEWARE`) hold the unprefixed one **Attachable**: swagger-php's extension point for metadata that rides along with an attribute without diff --git a/README.md b/README.md index 633f2f2..58a8bf9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ apart. **Spec attributes only.** Routes are read from the `OpenApi\Spec` namespace — swagger-php's spec pipeline. The classic `OpenApi\Attributes` namespace and docblock annotations are not read at all, so a codebase on classic needs converting first; see the -[upgrade guide](docs/Upgrading.md) if you are coming from 4.x. Spec attributes are +[upgrade guide](docs/UpgradingTo5.md) if you are coming from 4.x. Spec attributes are [marked beta upstream](https://zircote.github.io/swagger-php/guide/spec-attributes) and their API may still change. @@ -63,10 +63,6 @@ class GetController `tags`, `security` and `responses` are shared with them. It composes along the class hierarchy, so a base controller can carry what its subclasses have in common. -`Middleware` is this package's own attribute — middleware is a routing concern and has no -place in the OpenAPI document, so it never appears in the output. Stack it on the class for -every route in it, or on a single method. - **index.php** ```php @@ -97,8 +93,49 @@ Every attribute except `#[Middleware]` is swagger-php's, and documented there: * [Modes](https://zircote.github.io/swagger-php/guide/modes) — how the spec pipeline differs from classic -This package adds one attribute of its own, [`#[Middleware]`](docs/Configuration.md), and -reads two vendor properties. +### `#[Middleware]` + +The one attribute this package defines. Names middleware in whatever form the framework +expects — a class-string, or an alias such as Laravel's `auth`. + +```php +#[OA\PathItem(prefix: '/pets')] +#[Middleware(names: ['auth'])] // every route in the controller +class PetController +{ + #[OA\Operation\Get(path: '/{id}')] + #[Middleware(names: ['throttle:60,1'])] // this route only + public function show(string $id) {} +} +``` + +Both apply, class-level first. It is an `Attachable`, so it never appears in the generated +document — middleware is a routing concern, not an API one. A `#[Middleware]` with no +`OA\Operation` or `OA\PathItem` beside it is an error rather than a silent no-op. + +### Vendor extensions + +Two keys on an operation's `x` argument feed the router. Write them **without** the `x-` +prefix; swagger-php adds it when emitting. + +```php +#[OA\Operation\Get(path: '/pets', operationId: 'listPets', x: [ + 'name' => 'pets.index', + 'middleware' => ['throttle:60,1'], +])] +``` + +| Key | In the document | Effect | +|---|---|---| +| `name` | `x-name` | **Replaces** the route name | +| `middleware` | `x-middleware` | **Appends** to the `#[Middleware]` attributes | + +Unlike `#[Middleware]`, these **are** emitted into the document — that is the tradeoff +between them. Prefer the attribute unless you want the middleware visible to whatever +consumes your spec. + +The constants for the unprefixed keys are `RoutingAdapterInterface::X_NAME` and +`X_MIDDLEWARE`. ## Generating the OpenAPI document @@ -114,7 +151,7 @@ a layer. ## Documentation * [Configuration](docs/Configuration.md) -* [Upgrading to 5.x](docs/Upgrading.md) +* [Upgrading to 5.x](docs/UpgradingTo5.md) * [Terminology](CONTEXT.md) — the words this package uses, and the ones it avoids ## License diff --git a/docs/Configuration.md b/docs/Configuration.md index 4a08520..9ffdeec 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -89,34 +89,3 @@ Constrain a path parameter declared `type: 'integer'` to `[0-9]+`. Default `true Laravel expresses this as a `where()` constraint, Slim as a `{id:[0-9]+}` placeholder. A parameter whose schema carries an explicit `pattern` uses that instead, regardless of this setting. - -## Vendor properties - -Two `x-*` properties on an operation feed the router. Neither reaches the OpenAPI document's -routing behaviour — they are read by this package only. - -| Property | Effect | -|---|---| -| `x-name` | **Replaces** the route name, whatever `setOperationIdAsName()` would have produced | -| `x-middleware` | **Appends** to the middleware from `#[Middleware]` attributes | - -```php -#[OA\Operation\Get(path: '/pets', operationId: 'listPets', x: [ - 'name' => 'pets.index', - 'middleware' => ['throttle:60,1'], -])] -``` - -The constants are `RoutingAdapterInterface::X_NAME` and `X_MIDDLEWARE`. - -## Middleware - -`#[Middleware(names: [...])]` attaches middleware in whatever form the framework expects — -a class-string, or an alias such as Laravel's `auth`. - -Stack it beside an `OA\Operation` for a single route, or beside an `OA\PathItem` for every -route in that controller; both apply, and the class-level entries come first. It is an -`Attachable` and never appears in the generated document. - -A `#[Middleware]` with no `OA\Operation` or `OA\PathItem` beside it is an error rather than a -silent no-op. diff --git a/docs/Upgrading.md b/docs/UpgradingTo5.md similarity index 100% rename from docs/Upgrading.md rename to docs/UpgradingTo5.md diff --git a/src/RoutingAdapterInterface.php b/src/RoutingAdapterInterface.php index de2be85..a24f52e 100644 --- a/src/RoutingAdapterInterface.php +++ b/src/RoutingAdapterInterface.php @@ -13,14 +13,16 @@ interface RoutingAdapterInterface /** * Key in `RouteRegistration::$custom` holding the route name, or `null` for an unnamed route. * - * Also the `x-name` vendor property, which overrides the name the router would derive. + * Doubles as the vendor extension key — declared as `x: ['name' => ...]` and emitted as + * `x-name` — which overrides the name the router would derive. */ public const X_NAME = 'name'; /** * Key in `RouteRegistration::$custom` holding the middleware list. * - * Also the `x-middleware` vendor property, which appends to any `#[Middleware]` attributes. + * Doubles as the vendor extension key — declared as `x: ['middleware' => [...]]` and + * emitted as `x-middleware` — which appends to any `#[Middleware]` attributes. */ public const X_MIDDLEWARE = 'middleware'; From 87a9202cc1485591db07352ca09ded1b2c5d3141 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 14:17:16 +1200 Subject: [PATCH 15/27] Drop internal framing from the user-facing docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "An error rather than a silent no-op" describes the behaviour before the change and the reasoning for making it — neither of which a reader writing a controller has any context for. The rule is what they need: it must sit beside an OA\Operation or an OA\PathItem, anywhere else raises an error. Same pattern in two more places, where "rather than" set up a contrast with something the reader never expected in the first place. The upgrade guide keeps its before/after framing, which is the one place it belongs. --- CONTEXT.md | 8 ++++---- README.md | 4 ++-- docs/Configuration.md | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 30eccf8..972a864 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,8 +41,8 @@ _Avoid_: action, callable — `RouteRegistration::$controller` is the name, and **Middleware**: This package's own attribute, and the only one it defines. An `Attachable`, so it is routing metadata that never reaches the OpenAPI document. -_Avoid_: calling it an OpenAPI attribute — it deliberately is not, which is why it lives -outside the `OpenApi\` namespace +_Avoid_: calling it an OpenAPI attribute — it is not one, which is why it lives outside the +`OpenApi\` namespace **Vendor property**: A vendor extension on an operation that this package reads. Declared unprefixed — @@ -84,8 +84,8 @@ _Avoid_: path, directory (both too narrow), input ## Example dialogue > **Dev:** "My middleware isn't being applied." -> **Domain expert:** "Is the `#[Middleware]` beside an **operation** or a `PathItem`? Anywhere -> else it has nothing to merge into, and that is an error rather than a silent no-op." +> **Domain expert:** "Is the `#[Middleware]` beside an **operation** or a `PathItem`? It has +> nothing to merge into anywhere else, so the scan raises an error." > **Dev:** "Can I get the OpenAPI document out of the router?" > **Domain expert:** "No — this package contributes nothing to it. Run swagger-php over the diff --git a/README.md b/README.md index 58a8bf9..c4f6845 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,8 @@ class PetController ``` Both apply, class-level first. It is an `Attachable`, so it never appears in the generated -document — middleware is a routing concern, not an API one. A `#[Middleware]` with no -`OA\Operation` or `OA\PathItem` beside it is an error rather than a silent no-op. +document — middleware is a routing concern, not an API one. It must sit beside an +`OA\Operation` or an `OA\PathItem`; anywhere else raises an error. ### Vendor extensions diff --git a/docs/Configuration.md b/docs/Configuration.md index 9ffdeec..3d90eca 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -28,8 +28,8 @@ adapter's own cached routes. Default `true`; turn it off in production. A PSR-16 cache for the extracted routes, used when `setReload(false)` is set. Default `null`. -Routes are cached rather than the specification, because a `Spec\Operation` holds a live -`\Reflector` and cannot be serialized. +It holds the routes, not the specification — a `Spec\Operation` carries a live `\Reflector` +and cannot be serialized. ### `setOperationIdAsName(bool $operationIdAsName = true)` From 25447a9e9f241a41915af1fb24871ac2ddf63489 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 14:22:30 +1200 Subject: [PATCH 16/27] Prose pass over the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substantive: the upgrade guide said two Controller features had no equivalent and listed middlewares and inherit:false. It missed headers, which cloned shared headers onto every response in the controller and has no PathItem counterpart — declare them per response, or on a shared response component. Anyone using it would have hit silence. Also points the operation-namespacing note at swagger-php's spec attributes guide, which covers both operations and PathItem, instead of the docs root. Prose: removed the remaining clauses that justify a decision rather than state the rule — "that is the tradeoff between them", "it was bespoke", "so routing it through here would only add a layer", "the reason it is worth doing", "Composition also improved". CONTEXT.md still described attributes as "already describing an API", the same wrong assumption fixed in the README earlier. Checked mechanically: no marketing filler, no volatile counts, no unverified hedges, every backticked identifier resolves against the source, every 4.x claim verified against main, all links resolve. --- CONTEXT.md | 2 +- README.md | 12 +++++------- docs/Configuration.md | 11 +++++------ docs/UpgradingTo5.md | 35 ++++++++++++++++++++--------------- 4 files changed, 31 insertions(+), 29 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 972a864..73520eb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # openapi-router -Configures a PHP framework's router from the swagger-php **spec attributes** already +Configures a PHP framework's router from the swagger-php **spec attributes** describing an API, so the routing table and the OpenAPI document cannot drift apart. Terminology for this package. swagger-php's own vocabulary is defined in diff --git a/README.md b/README.md index c4f6845..59088a8 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,8 @@ class PetController ``` Both apply, class-level first. It is an `Attachable`, so it never appears in the generated -document — middleware is a routing concern, not an API one. It must sit beside an -`OA\Operation` or an `OA\PathItem`; anywhere else raises an error. +document. It must sit beside an `OA\Operation` or an `OA\PathItem`; anywhere else raises an +error. ### Vendor extensions @@ -130,9 +130,8 @@ prefix; swagger-php adds it when emitting. | `name` | `x-name` | **Replaces** the route name | | `middleware` | `x-middleware` | **Appends** to the `#[Middleware]` attributes | -Unlike `#[Middleware]`, these **are** emitted into the document — that is the tradeoff -between them. Prefer the attribute unless you want the middleware visible to whatever -consumes your spec. +Unlike `#[Middleware]`, these **are** emitted into the document. Prefer the attribute unless +you want the middleware visible to whatever consumes your spec. The constants for the unprefixed keys are `RoutingAdapterInterface::X_NAME` and `X_MIDDLEWARE`. @@ -145,8 +144,7 @@ Use swagger-php directly — its CLI or `Builder` over the same sources: ./vendor/bin/openapi --mode spec -o openapi.yaml src/controllers ``` -This package contributes nothing to the document, so routing it through here would only add -a layer. +The output is identical either way — this package adds nothing to the document. ## Documentation diff --git a/docs/Configuration.md b/docs/Configuration.md index 3d90eca..f10e9b9 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -36,8 +36,8 @@ and cannot be serialized. Use each operation's `operationId` as the route name. Default `true`. When off, only an explicit `x-name` names a route. -Note swagger-php hashes generated operation ids by default, so relying on this without -declaring `operationId` yourself produces hashed route names. Either declare them, or turn +swagger-php hashes generated operation ids by default, so relying on this without declaring +`operationId` yourself produces hashed route names. Either declare them, or turn hashing off through the builder: ```php @@ -66,12 +66,11 @@ $router->withBuilder(function (Builder $builder): void { })->registerRoutes(); ``` -Sources and mode are always applied by the router, so the hook cannot accidentally drop them. +The router applies sources and mode itself, so the hook does not need to. -This matters for routing when it changes *which operations are discovered* — a translator +Use it for configuration that changes *which operations are discovered* — a translator turning a framework-native attribute into a `Spec\Operation` adds routes. Configuration that -only shapes the document belongs in a direct swagger-php call instead; this package does not -generate documents. +only shapes the document belongs in a direct swagger-php call. ## Adapters diff --git a/docs/UpgradingTo5.md b/docs/UpgradingTo5.md index beea5e2..f4aebe6 100644 --- a/docs/UpgradingTo5.md +++ b/docs/UpgradingTo5.md @@ -4,8 +4,8 @@ classic annotation pipeline, and drops `radebatz/openapi-extras`. Every controller attribute changes, and so does the router's configuration API. -The reason it is worth doing: `OA\PathItem` — core swagger-php — does everything -openapi-extras' `Controller` did and several things it could not. +`OA\PathItem` — core swagger-php — does everything openapi-extras' `Controller` did, and +several things it could not. ## Requirements @@ -40,14 +40,19 @@ way to express: - **`security`** — cloned to contained operations the way tags and responses are - **`ref`** — the path item can itself be a `$ref` -Composition also improved: prefixes compose along the whole class hierarchy, and tags, -security, responses and parameters accumulate from every ancestor with deduplication by -value, scheme, status code and name+in respectively. +Composition runs the whole class hierarchy: prefixes compose, and tags, security, responses +and parameters accumulate from every ancestor, deduplicated by value, scheme, status code and +name+in respectively. -**Two `Controller` features have no equivalent.** `middlewares` moves to this package's own -attribute (below). **`inherit: false`**, which stopped the ancestor walk at a given class, -is gone — `PathItem` composition is unconditional. If you relied on it, flatten the -hierarchy or stop inheriting from the ancestor carrying the unwanted metadata. +**Three `Controller` features have no equivalent:** + +- **`middlewares`** moves to this package's own attribute (below). +- **`inherit: false`** stopped the ancestor walk at a given class. `PathItem` composition is + unconditional, so flatten the hierarchy or stop inheriting from the ancestor carrying the + unwanted metadata. +- **`headers`** cloned shared headers onto every response in the controller. `PathItem` has + no `headers`; declare them on each `OA\Response(headers: [...])`, or on a shared response + component the operations `$ref`. ### Operation attributes are namespaced @@ -56,9 +61,9 @@ hierarchy or stop inheriting from the ancestor carrying the unwanted metadata. +#[OA\Operation\Get(path: '/pets/{id}')] ``` -Likewise `Post`, `Put`, `Patch`, `Delete`, `Head`, `Options`, `Trace`. See swagger-php's -[migration notes](https://zircote.github.io/swagger-php/) for the full attribute mapping — -this package only changes the two things it owns. +Likewise `Post`, `Put`, `Patch`, `Delete`, `Head`, `Options`, `Trace`. swagger-php's +[spec attributes guide](https://zircote.github.io/swagger-php/guide/spec-attributes) covers +the rest of the namespace. ### `OAX\Middleware` becomes `Middleware` @@ -95,9 +100,9 @@ Shorthand for JSON content. Write it out: ### `JsonResponse`'s `wrap` -`wrap: 'data'` nested the payload under an envelope key. There is no equivalent — it was -bespoke. Either declare the wrapper schema explicitly, or reimplement it as a swagger-php -augmenter and register it through [`withBuilder()`](Configuration.md). +`wrap: 'data'` nested the payload under an envelope key. There is no equivalent: declare the +wrapper schema explicitly, or reimplement it as a swagger-php augmenter and register it +through [`withBuilder()`](Configuration.md). ### `EnumDescription` and `Customizers` From 5dff5e129bc8f961ea136c55c4b054c9930aa4be Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 15:04:18 +1200 Subject: [PATCH 17/27] Resolve routing metadata against the PathItem hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swagger-php resolves a class without its own PathItem against its ancestors, which is how a base controller's prefix reaches a subclass's operations. Two things read off a PathItem did not walk that same hierarchy, so both silently applied to nothing. Middleware was looked up by the operation's exact declaring class, so a base controller carrying `#[Middleware]` contributed none — the prefix composed, the middleware did not. Path parameters were read only from the operation, but the PathItems augmenter clones tags, security and responses down to operations and deliberately not parameters, since those are emitted at path level. A placeholder declared once for the whole controller therefore reached the router with no type, pattern or optionality, which is precisely the case the upgrade guide recommends. Both now read the governing PathItem chain, outermost ancestor first. A parameter the operation declares itself still wins, keeping the position the path item gave it, because the reverse ordering the optional-parameter nesting relies on is path order. Also in passing, in the same call paths: - dedupe middleware after the `x-middleware` merge rather than before, so an attribute and a vendor extension naming the same middleware collapse - stop rewriting the route cache on a cache hit, and check the cached value is an array before trusting it - anchor the Laravel adapter's `::__invoke` strip to the end of the controller string Co-Authored-By: Claude Opus 5 --- src/Adapters/LaravelRoutingAdapter.php | 5 +- src/Attributes/Middleware.php | 4 +- src/OpenApiRouter.php | 124 ++++++++++++------ src/RouteRegistration.php | 2 +- .../Controllers/BaseApiController.php | 17 +++ .../Controllers/InheritedController.php | 18 +++ .../Controllers/SharedParameterController.php | 21 +++ tests/Laravel/ParametersTest.php | 9 ++ tests/Slim/ParametersTest.php | 7 + 9 files changed, 166 insertions(+), 41 deletions(-) create mode 100644 tests/Fixtures/Controllers/BaseApiController.php create mode 100644 tests/Fixtures/Controllers/InheritedController.php create mode 100644 tests/Fixtures/Controllers/SharedParameterController.php diff --git a/src/Adapters/LaravelRoutingAdapter.php b/src/Adapters/LaravelRoutingAdapter.php index 874ffbc..5a769e3 100644 --- a/src/Adapters/LaravelRoutingAdapter.php +++ b/src/Adapters/LaravelRoutingAdapter.php @@ -49,7 +49,10 @@ public function register(RouteRegistration $route): void } } - $controller = str_replace('::__invoke', '', $route->controller); + // anchored: only a trailing `::__invoke` is the single-action form + $controller = str_ends_with($route->controller, '::__invoke') + ? substr($route->controller, 0, -strlen('::__invoke')) + : $route->controller; /** @var Router $router */ $router = $this->app->get('router'); diff --git a/src/Attributes/Middleware.php b/src/Attributes/Middleware.php index c7f53f2..46cafdd 100644 --- a/src/Attributes/Middleware.php +++ b/src/Attributes/Middleware.php @@ -11,7 +11,9 @@ * * Stack beside an `Operation` (per-route) or a `PathItem` (per-controller). Controller-level * inheritance is resolved by `OpenApiRouter`, since swagger-php's `PathItems` augmenter does - * not clone `attachables` down to operations. + * not clone `attachables` down to operations. It follows the class hierarchy the same way a + * `PathItem` prefix does, so a base controller's middleware applies to every subclass, in + * order from the outermost ancestor down to the operation itself. * * #[OA\PathItem(prefix: '/users')] * #[Middleware(names: ['auth'])] diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index 7da1eee..f9d2eaa 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -133,21 +133,21 @@ public function registerRoutes(): ?array return null; } - $routes = null; - if ($this->cache instanceof CacheInterface && !$this->reload) { - $routes = $this->cache->get(self::CACHE_KEY_ROUTES); - } + $cache = $this->reload ? null : $this->cache; - $routes ??= $this->extractRoutes( - $this->builder()->build()->specification() ?? new Specification() - ); + /** @var list|null $routes */ + $routes = is_array($cached = $cache?->get(self::CACHE_KEY_ROUTES)) ? array_values($cached) : null; - foreach ($routes as $route) { - $this->routingAdapter->register($route); + if ($routes === null) { + $routes = $this->extractRoutes( + $this->builder()->build()->specification() ?? new Specification() + ); + + $cache?->set(self::CACHE_KEY_ROUTES, $routes); } - if ($this->cache instanceof CacheInterface && !$this->reload) { - $this->cache->set(self::CACHE_KEY_ROUTES, $routes); + foreach ($routes as $route) { + $this->routingAdapter->register($route); } return $routes; @@ -174,20 +174,14 @@ protected function extractRoutes(Specification $specification): array } $controller = $operation->getClassName() . '::' . $reflector->getName(); - - $parameters = []; - foreach ($operation->parameters ?? [] as $parameter) { - if ('path' === $parameter->in) { - $parameters[] = $parameter; - } - } + $pathItems = $this->governingPathItems($operation->getClassName(), $classToPathItem); $routes[] = new RouteRegistration( path: $operation->path ?? '', method: strtoupper($operation->method ?? 'GET'), controller: $controller, - parameters: $this->parameterMetadata(array_reverse($parameters)), - custom: $this->customProperties($operation, $classToPathItem), + parameters: $this->parameterMetadata(array_reverse($this->pathParameters($operation, $pathItems))), + custom: $this->customProperties($operation, $pathItems), ); } @@ -195,43 +189,97 @@ protected function extractRoutes(Specification $specification): array } /** + * The `PathItem` chain governing an operation, outermost ancestor first. + * + * swagger-php resolves a class without its own `PathItem` against its ancestors — which + * is how a base controller's prefix reaches a subclass's operations — so anything read + * off a `PathItem` here has to walk the same hierarchy or it silently applies to nothing. + * * @param array $classToPathItem * + * @return list + */ + protected function governingPathItems(?string $className, array $classToPathItem): array + { + if ($className === null || !class_exists($className)) { + return []; + } + + $pathItems = []; + for ($current = new \ReflectionClass($className); $current !== false; $current = $current->getParentClass()) { + if (isset($classToPathItem[$current->getName()])) { + $pathItems[] = $classToPathItem[$current->getName()]; + } + } + + return array_reverse($pathItems); + } + + /** + * The path parameters applying to an operation, in path order. + * + * `PathItem::$parameters` are shared by every operation under it and are emitted at path + * level, so swagger-php never copies them onto the operations themselves — but routing + * still needs them, or a placeholder declared once for the whole controller ends up with + * no type, pattern or optionality. A parameter the operation declares itself wins, while + * keeping the position the path item gave it. + * + * @param list $pathItems + * + * @return list + */ + protected function pathParameters(OA\Operation $operation, array $pathItems): array + { + $parameters = []; + + foreach ([...$pathItems, $operation] as $holder) { + foreach ($holder->parameters ?? [] as $parameter) { + if ('path' === $parameter->in && $parameter->name !== null) { + $parameters[$parameter->name] = $parameter; + } + } + } + + return array_values($parameters); + } + + /** + * @param list $pathItems + * * @return array */ - protected function customProperties(OA\Operation $operation, array $classToPathItem): array + protected function customProperties(OA\Operation $operation, array $pathItems): array { // PathItem's own class-level attachables don't clone down to its operations the way // tags/security/responses do (swagger-php's `PathItems` augmenter only clones those // three) — resolved here instead, so a controller-level `#[Middleware]` still applies - // to every operation under it. - $attachables = $operation->attachables ?? []; - $className = $operation->getClassName(); - if ($className !== null && isset($classToPathItem[$className])) { - $attachables = [...$classToPathItem[$className]->attachables ?? [], ...$attachables]; + // to every operation under it, inherited ones included. + $attachables = []; + foreach ([...$pathItems, $operation] as $holder) { + $attachables = [...$attachables, ...$holder->attachables ?? []]; } $middleware = []; foreach ($attachables as $attachable) { if ($attachable instanceof Middleware) { - $middleware = array_merge($middleware, $attachable->names); + $middleware = [...$middleware, ...$attachable->names]; } } - $custom = [ - RoutingAdapterInterface::X_NAME => $this->operationIdAsName ? $operation->operationId : null, - RoutingAdapterInterface::X_MIDDLEWARE => array_values(array_unique($middleware)), - ]; + $name = $this->operationIdAsName ? $operation->operationId : null; - foreach (array_keys($custom) as $xKey) { - if (array_key_exists($xKey, $operation->x ?? [])) { - $custom[$xKey] = is_array($custom[$xKey]) - ? array_merge($custom[$xKey], (array) $operation->x[$xKey]) - : $operation->x[$xKey]; - } + $x = $operation->x ?? []; + if (array_key_exists(RoutingAdapterInterface::X_NAME, $x)) { + $name = $x[RoutingAdapterInterface::X_NAME]; + } + if (array_key_exists(RoutingAdapterInterface::X_MIDDLEWARE, $x)) { + $middleware = [...$middleware, ...array_values((array) $x[RoutingAdapterInterface::X_MIDDLEWARE])]; } - return $custom; + return [ + RoutingAdapterInterface::X_NAME => $name, + RoutingAdapterInterface::X_MIDDLEWARE => array_values(array_unique($middleware)), + ]; } /** diff --git a/src/RouteRegistration.php b/src/RouteRegistration.php index 51d5722..ea5d1f7 100644 --- a/src/RouteRegistration.php +++ b/src/RouteRegistration.php @@ -15,7 +15,7 @@ * @param string $path URL path with OpenAPI placeholders, prefixes already composed — `/api/v1/pets/{id}` * @param string $method HTTP method, upper case * @param string $controller `Fully\Qualified\Class::method`; `__invoke` for a single-action controller - * @param array $parameters path parameter metadata keyed by name, in **reverse** declaration order — consecutive optional parameters nest (`/multi[/{foo}[/{bar}]]`), which requires bracketing the last one first. `type` is `string`, `integer`, `regex` or `null`; `pattern` accompanies `regex` + * @param array $parameters path parameter metadata keyed by name, merged from the governing `PathItem` chain and the operation, in **reverse** path order — consecutive optional parameters nest (`/multi[/{foo}[/{bar}]]`), which requires bracketing the last one first. `type` is `string`, `integer`, `regex` or `null`; `pattern` accompanies `regex` * @param array $custom keyed by the `RoutingAdapterInterface::X_*` constants — `X_NAME` (`?string`) and `X_MIDDLEWARE` (`list`) are always present */ public function __construct( diff --git a/tests/Fixtures/Controllers/BaseApiController.php b/tests/Fixtures/Controllers/BaseApiController.php new file mode 100644 index 0000000..e422f98 --- /dev/null +++ b/tests/Fixtures/Controllers/BaseApiController.php @@ -0,0 +1,17 @@ + 'inherited'])] + #[OA\Response(response: 200, description: 'All good')] + #[Middleware(names: [BarMiddleware::class])] + public function inherited() + { + return FakeResponse::create('Inherited'); + } +} diff --git a/tests/Fixtures/Controllers/SharedParameterController.php b/tests/Fixtures/Controllers/SharedParameterController.php new file mode 100644 index 0000000..20a695f --- /dev/null +++ b/tests/Fixtures/Controllers/SharedParameterController.php @@ -0,0 +1,21 @@ + 'shared'])] + #[OA\Response(response: 200, description: 'All good')] + public function items($tenant = null) + { + return FakeResponse::create(sprintf('Tenant: %s', $tenant)); + } +} diff --git a/tests/Laravel/ParametersTest.php b/tests/Laravel/ParametersTest.php index 7cf38b9..d01dc22 100644 --- a/tests/Laravel/ParametersTest.php +++ b/tests/Laravel/ParametersTest.php @@ -33,6 +33,15 @@ public function typedParameter(): void $this->assertFalse($route->matches(Request::create('id/12x3'))); } + #[Test] + public function pathItemParameter(): void + { + $this->assertInstanceOf(Route::class, $route = $this->getRouter()->getRoutes()->getByName('shared')); + $this->assertSame('shared/{tenant}/items', $route->uri()); + $this->assertTrue($route->matches(Request::create('shared/42/items'))); + $this->assertFalse($route->matches(Request::create('shared/nope/items'))); + } + #[Test] public function regexParameter(): void { diff --git a/tests/Slim/ParametersTest.php b/tests/Slim/ParametersTest.php index b552b76..ba1e737 100644 --- a/tests/Slim/ParametersTest.php +++ b/tests/Slim/ParametersTest.php @@ -30,6 +30,13 @@ public function multiOptionalParameter(): void $this->assertSame('/multi[/{foo}[/{bar}]]', $route->getPattern()); } + #[Test] + public function pathItemParameter(): void + { + $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('shared')); + $this->assertSame('/shared/{tenant:[0-9]+}/items', $route->getPattern()); + } + #[Test] public function typedParameter(): void { From 59d25280f731cd43687b4859d5a1b136624a9c3d Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 15:04:36 +1200 Subject: [PATCH 18/27] Cover the 5.x configuration API, and stop the caching sets reading backwards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withBuilder()`, `setOperationIdAsName()` and `setLogger()` had no test at all despite being the centrepiece of the options-array-to-fluent-setters rewrite, and `CachingTest` only asserted the cache key exists — never that a hit registers what the scan did, which is the whole reason `RouteRegistration` is plain data. RouterTest covers those framework-agnostically through a recording adapter: the three setters, both `registerCached()` paths, a real PSR-16 round-trip through a serialising ArrayAdapter, the OpenAPI 3.1 type-list handling in `routableType()`, and the two hierarchy fixes. Its fixtures live outside Fixtures/Controllers so the framework tests keep a fixed route table. The caching data sets named the two cache cases the wrong way round — 'cache-reload' ran with reload off — so they asserted the opposite of what they said while still passing. Corrected, and switched to named data-set arguments: two adjacent bools are too easy to transpose positionally, and a transposed pair passes just as happily. Also drops a leftover `echo` in LaravelTest that printed a response body into the PHPUnit progress output, and gives the Slim request path its missing leading slash. Co-Authored-By: Claude Opus 5 --- .../Routing/OperationIdController.php | 21 ++ .../Fixtures/Routing/UnionTypeController.php | 29 +++ tests/Laravel/CachingTest.php | 16 +- tests/Laravel/LaravelTest.php | 11 +- tests/RecordingAdapter.php | 41 +++ tests/RouterTest.php | 245 ++++++++++++++++++ tests/Slim/SlimTest.php | 17 +- 7 files changed, 372 insertions(+), 8 deletions(-) create mode 100644 tests/Fixtures/Routing/OperationIdController.php create mode 100644 tests/Fixtures/Routing/UnionTypeController.php create mode 100644 tests/RecordingAdapter.php create mode 100644 tests/RouterTest.php diff --git a/tests/Fixtures/Routing/OperationIdController.php b/tests/Fixtures/Routing/OperationIdController.php new file mode 100644 index 0000000..fbeeb46 --- /dev/null +++ b/tests/Fixtures/Routing/OperationIdController.php @@ -0,0 +1,21 @@ + 'nullable'])] + #[OA\Response(response: 200, description: 'All good')] + public function nullable( + #[OA\Parameter\Path(name: 'id', schema: new OA\Schema(type: ['integer', 'null']))] + $id = null + ) { + } + + #[OA\Operation\Get(path: '/union/{id}', x: ['name' => 'union'])] + #[OA\Response(response: 200, description: 'All good')] + public function union( + #[OA\Parameter\Path(name: 'id', schema: new OA\Schema(type: ['integer', 'string']))] + $id = null + ) { + } +} diff --git a/tests/Laravel/CachingTest.php b/tests/Laravel/CachingTest.php index 4db1b1a..7df212d 100644 --- a/tests/Laravel/CachingTest.php +++ b/tests/Laravel/CachingTest.php @@ -17,16 +17,20 @@ final class CachingTest extends LaravelTestCase { use CallsApplicationTrait; + /** + * Named arguments: two adjacent bools are too easy to transpose positionally, and a + * transposed pair still passes — it just asserts the opposite of what the name says. + */ public static function reloadTests(): \Iterator { - yield 'no-cache-reload' => [null, true, false]; - yield 'no-cache-no-reload' => [null, false, false]; - yield 'cache-reload' => [new Psr16Cache(new ArrayAdapter()), false, true]; - yield 'cache-no-reload' => [new Psr16Cache(new ArrayAdapter()), true, false]; + yield 'no-cache-reload' => ['cache' => null, 'reload' => true, 'routesCached' => false]; + yield 'no-cache-no-reload' => ['cache' => null, 'reload' => false, 'routesCached' => false]; + yield 'cache-reload' => ['cache' => new Psr16Cache(new ArrayAdapter()), 'reload' => true, 'routesCached' => false]; + yield 'cache-no-reload' => ['cache' => new Psr16Cache(new ArrayAdapter()), 'reload' => false, 'routesCached' => true]; } #[DataProvider('reloadTests')] - public function testReload(?CacheInterface $cache, bool $reload, bool $openapisCached): void + public function testReload(?CacheInterface $cache, bool $reload, bool $routesCached): void { (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app = $this->getApp()))) ->setReload($reload) @@ -37,7 +41,7 @@ public function testReload(?CacheInterface $cache, bool $reload, bool $openapisC $router = $app['router']; $this->assertInstanceOf(Route::class, $router->getRoutes()->getByName('getya')); - $this->assertSame($openapisCached, $cache instanceof CacheInterface && $cache->has(OpenApiRouter::CACHE_KEY_ROUTES)); + $this->assertSame($routesCached, $cache instanceof CacheInterface && $cache->has(OpenApiRouter::CACHE_KEY_ROUTES)); } protected function getApp(): Application diff --git a/tests/Laravel/LaravelTest.php b/tests/Laravel/LaravelTest.php index 0ffb79e..3c0ecd6 100644 --- a/tests/Laravel/LaravelTest.php +++ b/tests/Laravel/LaravelTest.php @@ -40,7 +40,6 @@ public function prefixed(): void public function attributesPrefixed(): void { $response = $this->get('attributes/prefixed'); - echo $response->getContent(); $response->assertStatus(200); } @@ -52,4 +51,14 @@ public function attributesMiddleware(): void $this->assertInstanceOf(Route::class, $route); $this->assertSame([FooMiddleware::class, BarMiddleware::class], $route->gatherMiddleware()); } + + #[Test] + public function inheritedMiddleware(): void + { + $route = $this->getRouter()->getRoutes()->getByName('inherited'); + + $this->assertInstanceOf(Route::class, $route); + $this->assertSame('base/inherited', $route->uri()); + $this->assertSame([FooMiddleware::class, BarMiddleware::class], $route->gatherMiddleware()); + } } diff --git a/tests/RecordingAdapter.php b/tests/RecordingAdapter.php new file mode 100644 index 0000000..580c75f --- /dev/null +++ b/tests/RecordingAdapter.php @@ -0,0 +1,41 @@ + */ + public array $routes = []; + + public function __construct( + private readonly bool $hasCachedRoutes = false, + ) { + } + + public function register(RouteRegistration $route): void + { + $this->routes[] = $route; + } + + public function registerCached(): bool + { + return $this->hasCachedRoutes; + } + + public function route(string $name): ?RouteRegistration + { + foreach ($this->routes as $route) { + if ($route->custom[RoutingAdapterInterface::X_NAME] === $name) { + return $route; + } + } + + return null; + } +} diff --git a/tests/RouterTest.php b/tests/RouterTest.php new file mode 100644 index 0000000..90f2959 --- /dev/null +++ b/tests/RouterTest.php @@ -0,0 +1,245 @@ +register(InheritedController::class)->route('inherited'); + + $this->assertInstanceOf(RouteRegistration::class, $route); + $this->assertSame('/base/inherited', $route->path); + $this->assertSame( + [FooMiddleware::class, BarMiddleware::class], + $route->custom[RoutingAdapterInterface::X_MIDDLEWARE], + "ancestor PathItem middleware applies first, then the operation's own", + ); + } + + #[Test] + public function readsPathParametersDeclaredOnThePathItem(): void + { + $route = $this->register(SharedParameterController::class)->route('shared'); + + $this->assertInstanceOf(RouteRegistration::class, $route); + $this->assertSame('/shared/{tenant}/items', $route->path); + $this->assertSame( + ['tenant' => ['required' => true, 'type' => 'integer', 'pattern' => null]], + $route->parameters, + ); + } + + #[Test] + public function aNullableTypeStillConstrainsTheParameter(): void + { + $route = $this->register(UnionTypeController::class)->route('nullable'); + + $this->assertInstanceOf(RouteRegistration::class, $route); + $this->assertSame( + ['id' => ['required' => true, 'type' => 'integer', 'pattern' => null]], + $route->parameters, + 'OpenAPI 3.1 spells nullability as a type list; `null` is not routable, so it drops out', + ); + } + + #[Test] + public function aGenuineUnionConstrainsNothing(): void + { + $route = $this->register(UnionTypeController::class)->route('union'); + + $this->assertInstanceOf(RouteRegistration::class, $route); + $this->assertSame( + ['id' => ['required' => true, 'type' => null, 'pattern' => null]], + $route->parameters, + 'two routable types leave no single constraint to apply', + ); + } + + #[Test] + public function operationIdBecomesTheRouteNameByDefault(): void + { + $route = $this->register(OperationIdController::class)->routes[0] ?? null; + + $this->assertInstanceOf(RouteRegistration::class, $route); + $this->assertSame('listWidgets', $route->custom[RoutingAdapterInterface::X_NAME]); + } + + #[Test] + public function operationIdAsNameCanBeTurnedOff(): void + { + $adapter = new RecordingAdapter(); + + (new OpenApiRouter($this->reflect(OperationIdController::class), $adapter)) + ->setOperationIdAsName(false) + ->registerRoutes(); + + $route = $adapter->routes[0] ?? null; + + $this->assertInstanceOf(RouteRegistration::class, $route); + $this->assertNull($route->custom[RoutingAdapterInterface::X_NAME]); + } + + #[Test] + public function anExplicitNameWinsOverTheOperationId(): void + { + $adapter = new RecordingAdapter(); + + (new OpenApiRouter($this->reflect(InheritedController::class), $adapter)) + ->setOperationIdAsName(false) + ->registerRoutes(); + + $this->assertInstanceOf(RouteRegistration::class, $adapter->route('inherited'), 'x-name names a route even with the operationId off'); + } + + #[Test] + public function withBuilderCanReplaceTheBuilder(): void + { + $adapter = new RecordingAdapter(); + $received = null; + + (new OpenApiRouter($this->reflect(NamedRouteController::class), $adapter)) + ->withBuilder(function (Builder $builder) use (&$received): Builder { + $received = $builder; + + return $builder; + }) + ->registerRoutes(); + + $this->assertInstanceOf(Builder::class, $received, 'the hook receives the prepared builder'); + $this->assertNotEmpty($adapter->routes, 'a returned builder replaces the prepared one'); + } + + #[Test] + public function loggerIsHandedToTheBuilder(): void + { + $logger = new class () extends AbstractLogger { + /** @var list */ + public array $records = []; + + public function log($level, \Stringable|string $message, array $context = []): void + { + $this->records[] = (string) $message; + } + }; + + $seen = null; + + (new OpenApiRouter($this->reflect(NamedRouteController::class), new RecordingAdapter())) + ->setLogger($logger) + ->withBuilder(function (Builder $builder) use (&$seen): void { + // `Builder::getLogger()` is protected; the point here is that the router set + // it before handing the builder over, so read it where the hook would care + $seen = (new \ReflectionProperty(Builder::class, 'logger'))->getValue($builder); + }) + ->registerRoutes(); + + $this->assertInstanceOf(LoggerInterface::class, $seen); + $this->assertSame($logger, $seen, 'setLogger() applies before the withBuilder() hook runs'); + } + + #[Test] + public function cachedRoutesSurviveAPsr16RoundTrip(): void + { + // serialised, so the cached value really is the plain data that came out of the scan + $cache = new Psr16Cache(new ArrayAdapter(storeSerialized: true)); + + $first = new RecordingAdapter(); + (new OpenApiRouter($this->getFixtureFinder(), $first)) + ->setReload(false) + ->setCache($cache) + ->registerRoutes(); + + $this->assertNotEmpty($first->routes); + $this->assertTrue($cache->has(OpenApiRouter::CACHE_KEY_ROUTES)); + + $second = new RecordingAdapter(); + $routes = (new OpenApiRouter($this->getFixtureFinder(), $second)) + ->setReload(false) + ->setCache($cache) + ->registerRoutes(); + + $this->assertNotNull($routes); + $this->assertContainsOnlyInstancesOf(RouteRegistration::class, $second->routes); + $this->assertEquals($first->routes, $second->routes, 'a cache hit registers exactly what the scan did'); + } + + #[Test] + public function anAdapterWithItsOwnRouteCacheSkipsTheScan(): void + { + $adapter = new RecordingAdapter(hasCachedRoutes: true); + + $routes = (new OpenApiRouter($this->getFixtureFinder(), $adapter)) + ->setReload(false) + ->registerRoutes(); + + $this->assertNull($routes); + $this->assertSame([], $adapter->routes); + } + + #[Test] + public function reloadingIgnoresTheAdaptersOwnRouteCache(): void + { + $adapter = new RecordingAdapter(hasCachedRoutes: true); + + $routes = (new OpenApiRouter($this->getFixtureFinder(), $adapter))->registerRoutes(); + + $this->assertNotNull($routes); + $this->assertNotEmpty($adapter->routes); + } + + /** + * @param class-string $class + */ + private function register(string $class): RecordingAdapter + { + $adapter = new RecordingAdapter(); + (new OpenApiRouter($this->reflect($class), $adapter))->registerRoutes(); + + return $adapter; + } + + /** + * The class plus its ancestors — `addSource()` collects only what it is handed, so a + * `PathItem` on a base class has to be in the sources to be seen at all. + * + * @param class-string $class + * + * @return list<\ReflectionClass> + */ + private function reflect(string $class): array + { + $reflectors = []; + for ($current = new \ReflectionClass($class); $current !== false; $current = $current->getParentClass()) { + $reflectors[] = $current; + } + + return array_reverse($reflectors); + } +} diff --git a/tests/Slim/SlimTest.php b/tests/Slim/SlimTest.php index d7ea8b5..5ce1bed 100644 --- a/tests/Slim/SlimTest.php +++ b/tests/Slim/SlimTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Radebatz\OpenApi\Routing\Adapters\SlimRoutingAdapter; final class SlimTest extends TestCase { @@ -45,7 +46,21 @@ public function middleware(): void #[Test] public function attributesPrefixed(): void { - $response = $this->call('attributes/prefixed'); + $response = $this->call('/attributes/prefixed'); $this->assertSame(200, $response->getStatusCode()); } + + #[Test] + public function hasNoRouteCacheOfItsOwn(): void + { + // Slim has no route cache to load, so the scan always runs + $this->assertFalse((new SlimRoutingAdapter($this->getApp()))->registerCached()); + } + + #[Test] + public function inheritedPathItem(): void + { + $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('inherited')); + $this->assertSame('/base/inherited', $route->getPattern()); + } } From 3f1e8aceba5dbfedc651b249de7cd20007360520 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 15:04:45 +1200 Subject: [PATCH 19/27] Document duplicate routes, and correct the package metadata Nothing validates that route names or paths are unique, and the two frameworks disagree about the outcome, so the README now says what each does. It is not "last one wins": both keep the *first* route scanned for a duplicate name, while a duplicate method and path loses the earlier route in Laravel and throws from FastRoute in Slim. Scan order over a directory is filesystem order, so neither outcome is worth relying on. The composer description still advertised annotations, which is exactly what 5.x stopped reading. symfony/finder is used directly by the test suite and was arriving transitively; declared now, and it still resolves at --prefer-lowest. CONTEXT.md is linked from the README as user-facing terminology but kept the internal framing an earlier commit set out to remove, naming the milestone and the branch; replaced with the release line it actually ships as. Its relationships section also now describes the PathItem chain rather than a single path item. Co-Authored-By: Claude Opus 5 --- CONTEXT.md | 10 +++++----- README.md | 23 ++++++++++++++++++++--- composer.json | 3 ++- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 73520eb..1aeace9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -74,8 +74,9 @@ _Avoid_: path, directory (both too narrow), input ## Relationships - An **adapter** receives a **registration** per **route** and translates it for its framework -- A **registration** is derived from one **operation**, plus any **middleware** attached to it - or to its containing `PathItem`, plus any **vendor properties** +- A **registration** is derived from one **operation**, plus the **middleware** and path + parameters on the `PathItem` chain governing it — its own and every ancestor's — plus any + **vendor properties** - **Sources** feed swagger-php's `Builder`, which produces the `Specification` the **operations** are read from - `withBuilder()` configures that `Builder`; it matters to routing only when it changes @@ -102,6 +103,5 @@ _Avoid_: path, directory (both too narrow), input **processors** (classic) or **augmenters** (spec). - "name" — **resolved**: the *route* name, which `setOperationIdAsName()` derives from the `operationId` and `x-name` overrides. Not the operation id itself, and not a path. -- "v2" — **unresolved**, and worth care. The rewrite is called v2 internally and the branch is - `v2/spec-pipeline`, but it releases as **5.0** — the last tag is 4.0.0. Say "5.x" in - anything public. +- "version" — **resolved**: this release line is **5.x**, the previous one 4.x. Say "5.x" in + anything public; never a milestone or branch name. diff --git a/README.md b/README.md index 59088a8..5135b16 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,12 @@ class PetController } ``` -Both apply, class-level first. It is an `Attachable`, so it never appears in the generated -document. It must sit beside an `OA\Operation` or an `OA\PathItem`; anywhere else raises an -error. +Both apply, class-level first. Class-level middleware follows the class hierarchy the same +way a `PathItem` prefix does, so a base controller's middleware applies to every subclass, +outermost ancestor first. + +It is an `Attachable`, so it never appears in the generated document. It must sit beside an +`OA\Operation` or an `OA\PathItem`; anywhere else raises an error. ### Vendor extensions @@ -136,6 +139,20 @@ you want the middleware visible to whatever consumes your spec. The constants for the unprefixed keys are `RoutingAdapterInterface::X_NAME` and `X_MIDDLEWARE`. +### Duplicate names and paths + +Nothing here checks that route names or paths are unique — the framework decides, and the two +disagree: + +| Clash | Laravel | Slim | +|---|---|---| +| Same **name**, different paths | the first route scanned keeps the name; the later one still dispatches but `route()` cannot reach it | the same, through `getNamedRoute()` | +| Same **method and path** | only the later route survives; the earlier name may still generate a URL, but it dispatches to the later route | `FastRoute\BadRouteException` on the first request | + +"First scanned" means scan order, which for a directory source is filesystem order — not +something to depend on. Keep `operationId` and `x-name` unique: a clash either silently loses +a route or resolves to whichever one the scan happened to reach first. + ## Generating the OpenAPI document Use swagger-php directly — its CLI or `Builder` over the same sources: diff --git a/composer.json b/composer.json index a06445b..f655c0f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "radebatz/openapi-router", - "description": "Routing adapter for OpenApi annotations.", + "description": "Configure framework routes from the swagger-php spec attributes describing your API.", "license": [ "MIT" ], @@ -61,6 +61,7 @@ "rector/rector": "^1.2 || ^2.0", "slim/slim": "^4.0", "symfony/cache": "^6.0 || ^7.0", + "symfony/finder": "^6.4 || ^7.0 || ^8.0", "symfony/psr-http-message-bridge": "^6.0 || ^7.0" }, "suggest": { From b57b0b9922241f603350068afce1c5fa495ab4fe Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sun, 20 Sep 2026 16:17:23 +1200 Subject: [PATCH 20/27] Carry the checkout bump into the workflows this branch rewrote --- .github/workflows/build.yml | 2 +- .github/workflows/static-analysis.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3b0d5b..9716b43 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,7 +32,7 @@ jobs: name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} on ${{ matrix.operating-system }} with ${{ matrix.dependencies }} dependencies steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 name: Checkout repository - name: Setup PHP diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 6d8a653..d060f95 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -12,7 +12,7 @@ jobs: static-analysis: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 name: Checkout repository - name: Setup PHP From fed09fed5a5b266d930d129fcd5d79ae82eef804 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Sat, 26 Sep 2026 19:54:18 +1200 Subject: [PATCH 21/27] refactor: take the PathItem chain from swagger-php instead of walking it here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swagger-php 6.11.0 exposes the walk as Specification::buildPathItemHierarchy(), so the private copy goes. It answered the same question — which PathItems govern an operation's class, outermost ancestor first — and two implementations of one rule drift silently: anything read off a PathItem that does not walk the chain applies to nothing, with no error to notice. Raises the swagger-php floor to ^6.11, which is free here because this branch is unreleased. Doing it after 5.0 instead would ship a private copy of a rule the library exposes and need a follow-up minor to remove it. pathParameters() and customProperties() still take a list and are unchanged; customProperties() still merges attachables across the chain itself, since the augmenter clones only tags, security and responses. --- composer.json | 2 +- src/OpenApiRouter.php | 36 ++---------------------------------- 2 files changed, 3 insertions(+), 35 deletions(-) diff --git a/composer.json b/composer.json index f655c0f..8d920e2 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "php": ">=8.2", "psr/log": "^1.1 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", - "zircote/swagger-php": "^6.9" + "zircote/swagger-php": "^6.11" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.62.0", diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index f9d2eaa..b7253b3 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -158,12 +158,7 @@ public function registerRoutes(): ?array */ protected function extractRoutes(Specification $specification): array { - $classToPathItem = []; - foreach ($specification->pathItems as $pathItem) { - if (($className = $pathItem->getClassName()) !== null) { - $classToPathItem[$className] = $pathItem; - } - } + $hierarchy = $specification->buildPathItemHierarchy(); $routes = []; foreach ($specification->operations as $operation) { @@ -174,7 +169,7 @@ protected function extractRoutes(Specification $specification): array } $controller = $operation->getClassName() . '::' . $reflector->getName(); - $pathItems = $this->governingPathItems($operation->getClassName(), $classToPathItem); + $pathItems = $hierarchy->forOperation($operation); $routes[] = new RouteRegistration( path: $operation->path ?? '', @@ -188,33 +183,6 @@ protected function extractRoutes(Specification $specification): array return $routes; } - /** - * The `PathItem` chain governing an operation, outermost ancestor first. - * - * swagger-php resolves a class without its own `PathItem` against its ancestors — which - * is how a base controller's prefix reaches a subclass's operations — so anything read - * off a `PathItem` here has to walk the same hierarchy or it silently applies to nothing. - * - * @param array $classToPathItem - * - * @return list - */ - protected function governingPathItems(?string $className, array $classToPathItem): array - { - if ($className === null || !class_exists($className)) { - return []; - } - - $pathItems = []; - for ($current = new \ReflectionClass($className); $current !== false; $current = $current->getParentClass()) { - if (isset($classToPathItem[$current->getName()])) { - $pathItems[] = $classToPathItem[$current->getName()]; - } - } - - return array_reverse($pathItems); - } - /** * The path parameters applying to an operation, in path order. * From 9e713ea92f360c475d4b7767a268a1d651fac37a Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 14 Oct 2026 16:58:56 +1300 Subject: [PATCH 22/27] fix: route a path parameter a PathItem declares by reference Take the PathItem chain's parameters from swagger-php's parametersFor(), and resolve a ref to its component, where the schema and required are. Requires swagger-php 6.13. --- README.md | 2 +- composer.json | 2 +- docs/UpgradingTo5.md | 2 +- src/OpenApiRouter.php | 30 ++++++++++++------- .../Routing/RefParameterController.php | 22 ++++++++++++++ tests/Fixtures/Routing/TenantParameter.php | 14 +++++++++ tests/RouterTest.php | 22 ++++++++++++-- 7 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 tests/Fixtures/Routing/RefParameterController.php create mode 100644 tests/Fixtures/Routing/TenantParameter.php diff --git a/README.md b/README.md index 5135b16..d948c24 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Supported frameworks: ## Requirements * PHP 8.2 or higher -* `zircote/swagger-php` ^6.9 +* `zircote/swagger-php` ^6.13 ## Installation diff --git a/composer.json b/composer.json index 8d920e2..2116b4a 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "php": ">=8.2", "psr/log": "^1.1 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", - "zircote/swagger-php": "^6.11" + "zircote/swagger-php": "^6.13" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.62.0", diff --git a/docs/UpgradingTo5.md b/docs/UpgradingTo5.md index f4aebe6..b07abea 100644 --- a/docs/UpgradingTo5.md +++ b/docs/UpgradingTo5.md @@ -12,7 +12,7 @@ several things it could not. | | 4.x | 5.x | |---|---|---| | PHP | 8.1+ | 8.2+ | -| swagger-php | `^4.11 \|\| ^5.0 \|\| ^6.0` | `^6.9` | +| swagger-php | `^4.11 \|\| ^5.0 \|\| ^6.0` | `^6.13` | | `radebatz/openapi-extras` | required | removed | | docblock annotations | supported | not read at all | diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index b7253b3..ab50004 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -6,6 +6,8 @@ use OpenApi\Builder\Mode; use OpenApi\Spec as OA; use OpenApi\Specification; +use OpenApi\Specification\ComponentIndex; +use OpenApi\Specification\PathItemHierarchy; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Radebatz\OpenApi\Routing\Attributes\Middleware; @@ -159,6 +161,7 @@ public function registerRoutes(): ?array protected function extractRoutes(Specification $specification): array { $hierarchy = $specification->buildPathItemHierarchy(); + $index = $specification->buildComponentIndex(); $routes = []; foreach ($specification->operations as $operation) { @@ -175,7 +178,7 @@ protected function extractRoutes(Specification $specification): array path: $operation->path ?? '', method: strtoupper($operation->method ?? 'GET'), controller: $controller, - parameters: $this->parameterMetadata(array_reverse($this->pathParameters($operation, $pathItems))), + parameters: $this->parameterMetadata(array_reverse($this->pathParameters($operation, $hierarchy, $index))), custom: $this->customProperties($operation, $pathItems), ); } @@ -192,23 +195,30 @@ protected function extractRoutes(Specification $specification): array * no type, pattern or optionality. A parameter the operation declares itself wins, while * keeping the position the path item gave it. * - * @param list $pathItems + * A parameter that is only a `ref` is returned as the component it points to, since that + * is where its schema and `required` are. * * @return list */ - protected function pathParameters(OA\Operation $operation, array $pathItems): array + protected function pathParameters(OA\Operation $operation, PathItemHierarchy $hierarchy, ComponentIndex $index): array { - $parameters = []; + $parameters = $hierarchy->parametersFor($operation); + foreach ($operation->parameters ?? [] as $parameter) { + if (($key = $index->parameterKey($parameter)) !== null) { + $parameters[$key] = $parameter; + } + } - foreach ([...$pathItems, $operation] as $holder) { - foreach ($holder->parameters ?? [] as $parameter) { - if ('path' === $parameter->in && $parameter->name !== null) { - $parameters[$parameter->name] = $parameter; - } + $pathParameters = []; + foreach ($parameters as $parameter) { + $ref = $parameter->ref instanceof OA\Schema\Ref ? $parameter->ref->ref : $parameter->ref; + $parameter = (is_string($ref) ? $index->findParameter($ref) : null) ?? $parameter; + if ('path' === $parameter->in && $parameter->name !== null) { + $pathParameters[] = $parameter; } } - return array_values($parameters); + return $pathParameters; } /** diff --git a/tests/Fixtures/Routing/RefParameterController.php b/tests/Fixtures/Routing/RefParameterController.php new file mode 100644 index 0000000..0b25af8 --- /dev/null +++ b/tests/Fixtures/Routing/RefParameterController.php @@ -0,0 +1,22 @@ + 'ref'])] + #[OA\Response(response: 200, description: 'All good')] + public function items($tenant = null) + { + } +} diff --git a/tests/Fixtures/Routing/TenantParameter.php b/tests/Fixtures/Routing/TenantParameter.php new file mode 100644 index 0000000..aaad44e --- /dev/null +++ b/tests/Fixtures/Routing/TenantParameter.php @@ -0,0 +1,14 @@ +register(TenantParameter::class, RefParameterController::class)->route('ref'); + + $this->assertInstanceOf(RouteRegistration::class, $route); + $this->assertSame('/ref/{tenant}/items', $route->path); + $this->assertSame( + ['tenant' => ['required' => true, 'type' => 'integer', 'pattern' => null]], + $route->parameters, + 'the schema and `required` come from the component the reference points to', + ); + } + #[Test] public function aNullableTypeStillConstrainsTheParameter(): void { @@ -215,12 +231,12 @@ public function reloadingIgnoresTheAdaptersOwnRouteCache(): void } /** - * @param class-string $class + * @param class-string ...$classes */ - private function register(string $class): RecordingAdapter + private function register(string ...$classes): RecordingAdapter { $adapter = new RecordingAdapter(); - (new OpenApiRouter($this->reflect($class), $adapter))->registerRoutes(); + (new OpenApiRouter(array_merge(...array_map($this->reflect(...), $classes)), $adapter))->registerRoutes(); return $adapter; } From 07425a73a9072ce93ff33bd771d0dcfdc8d252b8 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 21 Oct 2026 12:31:52 +1300 Subject: [PATCH 23/27] docs: say what swagger-php v7 and v8 change for 5.x Also anchor the namespace statements to swagger-php 6.x and 7.x, and update the beta caveat for the 6.18.0 freeze. --- README.md | 14 +++++++++----- docs/UpgradingTo5.md | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d948c24..f529ba5 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,15 @@ attributes describing your API, so the routing table and the OpenAPI document ca apart. **Spec attributes only.** Routes are read from the `OpenApi\Spec` namespace — swagger-php's -spec pipeline. The classic `OpenApi\Attributes` namespace and docblock annotations are not -read at all, so a codebase on classic needs converting first; see the -[upgrade guide](docs/UpgradingTo5.md) if you are coming from 4.x. Spec attributes are -[marked beta upstream](https://zircote.github.io/swagger-php/guide/spec-attributes) and their -API may still change. +spec pipeline. In swagger-php 6.x and 7.x, the classic `OpenApi\Attributes` namespace and +docblock annotations are not read at all, so a codebase on classic needs converting first; see +the [upgrade guide](docs/UpgradingTo5.md) if you are coming from 4.x. The spec attribute API is +frozen since swagger-php 6.18.0, and spec mode is +[marked beta upstream](https://zircote.github.io/swagger-php/guide/modes) until v7. + +swagger-php v8 moves the spec attributes to `OpenApi\Attributes`, and this package follows +with a 6.0 release. [What v7 and v8 change](docs/UpgradingTo5.md#swagger-php-v7-and-v8) is in +the upgrade guide. Supported frameworks: diff --git a/docs/UpgradingTo5.md b/docs/UpgradingTo5.md index b07abea..f56f2ba 100644 --- a/docs/UpgradingTo5.md +++ b/docs/UpgradingTo5.md @@ -31,6 +31,9 @@ several things it could not. class PetController ``` +`OpenApi\Spec` is the spec namespace in swagger-php 6.x and 7.x. In v8 it becomes +`OpenApi\Attributes`, see [below](#swagger-php-v7-and-v8). + `prefix`, `tags` and `responses` behave as before. `PathItem` adds what `Controller` had no way to express: @@ -165,3 +168,14 @@ arguments: The values are the same, as properties: `$route->path`, `->method`, `->controller`, `->parameters`, `->custom`. Adapters no longer depend on swagger-php at all. + +## swagger-php v7 and v8 + +- **v7** makes hybrid the default mode and deprecates classic. This package sets spec mode + itself, so nothing changes here. Code that still uses classic gets swagger-php's deprecation + notices. +- **v8** removes classic and moves the spec attributes from `OpenApi\Spec` to + `OpenApi\Attributes`. In a controller that is one `use` line, and swagger-php plans a rector + rule for it. This package reads the spec attributes itself, so it moves too. + +openapi-router 5.x covers swagger-php 6.x and 7.x. openapi-router 6.0 follows swagger-php v8. From 751ab4f122e76c82e0eb922601389af30f828c3c Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 21 Oct 2026 12:36:55 +1300 Subject: [PATCH 24/27] Require laravel/framework for the tests, and state the supported framework versions Also update the README's controller example to Slim 4. --- .github/workflows/build.yml | 2 +- README.md | 8 +++++--- composer.json | 2 +- tests/Laravel/CallsApplicationTrait.php | 7 ++++++- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9716b43..2629e34 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: coverage: pcov - name: Pin Laravel version - run: composer require --dev laravel/laravel "^${{ matrix.laravel }}" -W --no-update + run: composer require --dev laravel/framework "^${{ matrix.laravel }}" -W --no-update - name: Composer install uses: ramsey/composer-install@v4 diff --git a/README.md b/README.md index f529ba5..1886d16 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ the upgrade guide. Supported frameworks: -* [Laravel](https://github.com/laravel/laravel) -* [Slim](https://github.com/slimphp/Slim) +* [Laravel](https://github.com/laravel/framework) 12 and 13 +* [Slim](https://github.com/slimphp/Slim) 4 ## Requirements @@ -58,7 +58,9 @@ class GetController #[OA\Response(response: 400, description: 'Not good enough')] public function getme($request, $response) { - return $response->write('Get me'); + $response->getBody()->write('Get me'); + + return $response; } } ``` diff --git a/composer.json b/composer.json index 2116b4a..1340329 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.62.0", - "laravel/laravel": "^12.0 || ^13.0", + "laravel/framework": "^12.0 || ^13.0", "nyholm/psr7": "^1.4", "nyholm/psr7-server": "^1.0", "phpstan/phpstan": "^2.1", diff --git a/tests/Laravel/CallsApplicationTrait.php b/tests/Laravel/CallsApplicationTrait.php index ef29b48..1f3f390 100644 --- a/tests/Laravel/CallsApplicationTrait.php +++ b/tests/Laravel/CallsApplicationTrait.php @@ -31,7 +31,12 @@ protected function setUp(): void public function createApplication() { if (!$this->app) { - $app = require __DIR__ . '/../../vendor/laravel/laravel/bootstrap/app.php'; + // a bare application; the base path only needs somewhere to write its bootstrap cache + $basePath = sys_get_temp_dir() . '/openapi-router-laravel'; + if (!is_dir($basePath . '/bootstrap/cache')) { + mkdir($basePath . '/bootstrap/cache', 0777, true); + } + $app = Application::configure(basePath: $basePath)->create(); $app->make(Kernel::class)->bootstrap(); $app['config']->set([ 'app.environment' => 'local', From 6afaeb62fd7fcc23b0fcdab48d94b5c24adc1bac Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 21 Oct 2026 12:57:02 +1300 Subject: [PATCH 25/27] Constrain path parameters by enum and format, and apply schema defaults A path parameter's enum, and a uuid or ulid format, become the route constraint after an explicit pattern. A schema default becomes the route default. A schema that is a reference is read from its component. --- README.md | 26 ++++++ docs/Configuration.md | 5 +- src/Adapters/LaravelRoutingAdapter.php | 8 ++ src/Adapters/SlimRoutingAdapter.php | 6 ++ src/OpenApiRouter.php | 80 +++++++++++++++---- src/RouteRegistration.php | 10 +-- .../Controllers/ConstraintController.php | 35 ++++++++ tests/Fixtures/Routing/FacetsController.php | 69 ++++++++++++++++ tests/Fixtures/Routing/Lang.php | 12 +++ tests/Laravel/ParametersTest.php | 24 ++++++ tests/RouterTest.php | 40 +++++++++- tests/Slim/ParametersTest.php | 22 +++++ 12 files changed, 310 insertions(+), 27 deletions(-) create mode 100644 tests/Fixtures/Controllers/ConstraintController.php create mode 100644 tests/Fixtures/Routing/FacetsController.php create mode 100644 tests/Fixtures/Routing/Lang.php diff --git a/README.md b/README.md index 1886d16..12b08f8 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,32 @@ you want the middleware visible to whatever consumes your spec. The constants for the unprefixed keys are `RoutingAdapterInterface::X_NAME` and `X_MIDDLEWARE`. +### Path parameters + +A path parameter's schema becomes the route's constraint, so a URL the document does not allow +does not match: + +| Schema | Constraint | +|---|---| +| `pattern: '[0-9a-f]+'` | the pattern as written | +| `enum: ['en', 'de']` | `en\|de` | +| `format: 'uuid'` or `'ulid'` | the expressions Laravel's `whereUuid()` and `whereUlid()` use | +| `type: 'integer'` | `[0-9]+`, unless [`$autoRegex`](docs/Configuration.md#autoregex) is off | + +The first one that applies wins. A schema that is a `$ref`, such as a backed enum with its own +`#[OA\Schema]`, is read from the component it points to. + +A `default` on the schema becomes the route's default, through `defaults()` in Laravel and a +route argument in Slim. That is mostly useful for an optional parameter: + +```php +#[OA\Operation\Get(path: '/pets/{page}')] +public function list( + #[OA\Parameter\Path(required: false, schema: new OA\Schema(type: 'integer', default: 1))] + $page +) {} +``` + ### Duplicate names and paths Nothing here checks that route names or paths are unique — the framework decides, and the two diff --git a/docs/Configuration.md b/docs/Configuration.md index f10e9b9..f27d1dd 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -86,5 +86,6 @@ new SlimRoutingAdapter($app, autoRegex: false); Constrain a path parameter declared `type: 'integer'` to `[0-9]+`. Default `true`. Laravel expresses this as a `where()` constraint, Slim as a `{id:[0-9]+}` placeholder. A -parameter whose schema carries an explicit `pattern` uses that instead, regardless of this -setting. +parameter whose schema carries a `pattern`, an `enum` or a `uuid` or `ulid` format is +constrained by that instead, regardless of this setting. See +[Path parameters](../README.md#path-parameters). diff --git a/src/Adapters/LaravelRoutingAdapter.php b/src/Adapters/LaravelRoutingAdapter.php index 5a769e3..00dd148 100644 --- a/src/Adapters/LaravelRoutingAdapter.php +++ b/src/Adapters/LaravelRoutingAdapter.php @@ -29,6 +29,7 @@ public function register(RouteRegistration $route): void $path = $route->path; $where = []; + $defaults = []; foreach ($route->parameters as $name => $parameter) { if (!$parameter['required'] && str_contains($path, $needle = "/{{$name}}")) { $path = str_replace($needle, "/{{$name}?}", $path); @@ -47,6 +48,10 @@ public function register(RouteRegistration $route): void } break; } + + if ($parameter['default'] !== null) { + $defaults[$name] = $parameter['default']; + } } // anchored: only a trailing `::__invoke` is the single-action form @@ -69,6 +74,9 @@ public function register(RouteRegistration $route): void $laravelRoute = $router->addRoute(strtoupper($route->method), $path, $action); $laravelRoute->middleware($route->custom[static::X_MIDDLEWARE]); $laravelRoute->where($where); + foreach ($defaults as $name => $default) { + $laravelRoute->defaults($name, $default); + } } /** diff --git a/src/Adapters/SlimRoutingAdapter.php b/src/Adapters/SlimRoutingAdapter.php index e847c82..af4aa0d 100644 --- a/src/Adapters/SlimRoutingAdapter.php +++ b/src/Adapters/SlimRoutingAdapter.php @@ -61,6 +61,12 @@ public function register(RouteRegistration $route): void $slimRoute->setName($route->custom[static::X_NAME]); } + foreach ($route->parameters as $name => $parameter) { + if ($parameter['default'] !== null) { + $slimRoute->setArgument($name, (string) $parameter['default']); + } + } + foreach ($route->custom[static::X_MIDDLEWARE] as $middleware) { $slimRoute->add($middleware); } diff --git a/src/OpenApiRouter.php b/src/OpenApiRouter.php index ab50004..1feaea8 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -8,6 +8,7 @@ use OpenApi\Specification; use OpenApi\Specification\ComponentIndex; use OpenApi\Specification\PathItemHierarchy; +use OpenApi\Undefined; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Radebatz\OpenApi\Routing\Attributes\Middleware; @@ -178,7 +179,7 @@ protected function extractRoutes(Specification $specification): array path: $operation->path ?? '', method: strtoupper($operation->method ?? 'GET'), controller: $controller, - parameters: $this->parameterMetadata(array_reverse($this->pathParameters($operation, $hierarchy, $index))), + parameters: $this->parameterMetadata(array_reverse($this->pathParameters($operation, $hierarchy, $index)), $index), custom: $this->customProperties($operation, $pathItems), ); } @@ -211,8 +212,7 @@ protected function pathParameters(OA\Operation $operation, PathItemHierarchy $hi $pathParameters = []; foreach ($parameters as $parameter) { - $ref = $parameter->ref instanceof OA\Schema\Ref ? $parameter->ref->ref : $parameter->ref; - $parameter = (is_string($ref) ? $index->findParameter($ref) : null) ?? $parameter; + $parameter = (is_string($parameter->ref) ? $index->findParameter($parameter->ref) : null) ?? $parameter; if ('path' === $parameter->in && $parameter->name !== null) { $pathParameters[] = $parameter; } @@ -263,11 +263,14 @@ protected function customProperties(OA\Operation $operation, array $pathItems): /** * Extract URI parameter metadata. * + * A schema that is only a `ref` is read from the component it points to, as a backed enum + * parameter usually is. + * * @param list $parameters * - * @return array + * @return array */ - protected function parameterMetadata(array $parameters): array + protected function parameterMetadata(array $parameters, ComponentIndex $index): array { $metadata = []; @@ -278,27 +281,72 @@ protected function parameterMetadata(array $parameters): array 'required' => (bool) $parameter->required, 'type' => null, 'pattern' => null, + 'default' => null, ]; if ($schema = $parameter->schema) { - switch ($this->routableType($schema->type)) { - case 'string': - $metadata[$name]['type'] = 'string'; - if ($pattern = $schema->pattern) { - $metadata[$name]['type'] = 'regex'; - $metadata[$name]['pattern'] = $pattern; - } - break; - case 'integer': - $metadata[$name]['type'] = 'integer'; - break; + $schema = (is_string($schema->ref) ? $index->findSchema($schema->ref) : null) ?? $schema; + + $type = $this->routableType($schema->type); + if (in_array($type, ['string', 'integer'], true)) { + $metadata[$name]['type'] = $type; + } + + if (($pattern = $this->constraint($schema)) !== null) { + $metadata[$name]['type'] = 'regex'; + $metadata[$name]['pattern'] = $pattern; } + + $metadata[$name]['default'] = $this->routeValue($schema->default); } } return $metadata; } + /** + * The regex a schema constrains a path parameter to. + * + * An explicit `pattern` wins, then `enum`, then a `format` with a known shape. The `uuid` + * and `ulid` expressions are the ones Laravel's `whereUuid()` and `whereUlid()` use. + */ + protected function constraint(OA\Schema $schema): ?string + { + if ($schema->pattern) { + return $schema->pattern; + } + + $values = array_filter( + array_map($this->routeValue(...), $schema->enum ?? []), + static fn (int|float|string|null $value): bool => $value !== null, + ); + if ($values !== []) { + return implode('|', array_map(static fn (int|float|string $value): string => preg_quote((string) $value), $values)); + } + + return match ($schema->format) { + 'uuid' => '[\da-fA-F]{8}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{12}', + 'ulid' => '[0-7][0-9a-hjkmnp-tv-zA-HJKMNP-TV-Z]{25}', + default => null, + }; + } + + /** + * A schema value as it appears in a URL: a scalar, or an enum case's value or name. + * + * `null`, booleans, arrays and an undefined value have no place in a path. + */ + protected function routeValue(mixed $value): int|float|string|null + { + return match (true) { + $value instanceof \BackedEnum => $value->value, + $value instanceof \UnitEnum => $value->name, + Undefined::isDefault($value) => null, + is_int($value), is_float($value), is_string($value) => $value, + default => null, + }; + } + /** * Reduce a schema type to the single scalar type routing cares about. * diff --git a/src/RouteRegistration.php b/src/RouteRegistration.php index ea5d1f7..708a526 100644 --- a/src/RouteRegistration.php +++ b/src/RouteRegistration.php @@ -12,11 +12,11 @@ final readonly class RouteRegistration { /** - * @param string $path URL path with OpenAPI placeholders, prefixes already composed — `/api/v1/pets/{id}` - * @param string $method HTTP method, upper case - * @param string $controller `Fully\Qualified\Class::method`; `__invoke` for a single-action controller - * @param array $parameters path parameter metadata keyed by name, merged from the governing `PathItem` chain and the operation, in **reverse** path order — consecutive optional parameters nest (`/multi[/{foo}[/{bar}]]`), which requires bracketing the last one first. `type` is `string`, `integer`, `regex` or `null`; `pattern` accompanies `regex` - * @param array $custom keyed by the `RoutingAdapterInterface::X_*` constants — `X_NAME` (`?string`) and `X_MIDDLEWARE` (`list`) are always present + * @param string $path URL path with OpenAPI placeholders, prefixes already composed — `/api/v1/pets/{id}` + * @param string $method HTTP method, upper case + * @param string $controller `Fully\Qualified\Class::method`; `__invoke` for a single-action controller + * @param array $parameters path parameter metadata keyed by name, merged from the governing `PathItem` chain and the operation, in **reverse** path order — consecutive optional parameters nest (`/multi[/{foo}[/{bar}]]`), which requires bracketing the last one first. `type` is `string`, `integer`, `regex` or `null`; `pattern` accompanies `regex`, from the schema's `pattern`, `enum` or `format`; `default` is the schema's default, if it has a place in a URL + * @param array $custom keyed by the `RoutingAdapterInterface::X_*` constants — `X_NAME` (`?string`) and `X_MIDDLEWARE` (`list`) are always present */ public function __construct( public string $path, diff --git a/tests/Fixtures/Controllers/ConstraintController.php b/tests/Fixtures/Controllers/ConstraintController.php new file mode 100644 index 0000000..aff112d --- /dev/null +++ b/tests/Fixtures/Controllers/ConstraintController.php @@ -0,0 +1,35 @@ + 'lang'])] + #[OA\Response(response: 200, description: 'All good')] + public function lang( + #[OA\Parameter\Path(description: 'The language', schema: new OA\Schema(type: 'string', enum: ['en', 'de']))] + $lang + ) { + return FakeResponse::create(sprintf('Lang: %s', $lang)); + } + + #[OA\Operation\Get(path: '/item/{id}', x: ['name' => 'item'])] + #[OA\Response(response: 200, description: 'All good')] + public function item( + #[OA\Parameter\Path(description: 'The id', schema: new OA\Schema(type: 'string', format: 'uuid'))] + $id + ) { + return FakeResponse::create(sprintf('Item: %s', $id)); + } + + #[OA\Operation\Get(path: '/list/{page}', x: ['name' => 'list'])] + #[OA\Response(response: 200, description: 'All good')] + public function list( + #[OA\Parameter\Path(description: 'The page', required: false, schema: new OA\Schema(type: 'integer', default: 1))] + $page + ) { + return FakeResponse::create(sprintf('Page: %s', $page)); + } +} diff --git a/tests/Fixtures/Routing/FacetsController.php b/tests/Fixtures/Routing/FacetsController.php new file mode 100644 index 0000000..0f0624f --- /dev/null +++ b/tests/Fixtures/Routing/FacetsController.php @@ -0,0 +1,69 @@ + 'enum'])] + #[OA\Response(response: 200, description: 'All good')] + public function enum( + #[OA\Parameter\Path(name: 'lang', schema: new OA\Schema(type: 'string', enum: ['en', 'de']))] + $lang + ) { + } + + #[OA\Operation\Get(path: '/backed/{lang}', x: ['name' => 'backed'])] + #[OA\Response(response: 200, description: 'All good')] + public function backed( + #[OA\Parameter\Path(name: 'lang')] + Lang $lang + ) { + } + + #[OA\Operation\Get(path: '/number/{id}', x: ['name' => 'number'])] + #[OA\Response(response: 200, description: 'All good')] + public function number( + #[OA\Parameter\Path(name: 'id', schema: new OA\Schema(type: 'integer', enum: [1, 2]))] + $id + ) { + } + + #[OA\Operation\Get(path: '/pattern/{lang}', x: ['name' => 'pattern'])] + #[OA\Response(response: 200, description: 'All good')] + public function pattern( + #[OA\Parameter\Path(name: 'lang', schema: new OA\Schema(type: 'string', pattern: '[a-z]{2}', enum: ['en', 'de']))] + $lang + ) { + } + + #[OA\Operation\Get(path: '/uuid/{id}', x: ['name' => 'uuid'])] + #[OA\Response(response: 200, description: 'All good')] + public function uuid( + #[OA\Parameter\Path(name: 'id', schema: new OA\Schema(type: 'string', format: 'uuid'))] + $id + ) { + } + + #[OA\Operation\Get(path: '/ulid/{id}', x: ['name' => 'ulid'])] + #[OA\Response(response: 200, description: 'All good')] + public function ulid( + #[OA\Parameter\Path(name: 'id', schema: new OA\Schema(type: 'string', format: 'ulid'))] + $id + ) { + } + + #[OA\Operation\Get(path: '/page/{page}', x: ['name' => 'page'])] + #[OA\Response(response: 200, description: 'All good')] + public function page( + #[OA\Parameter\Path(name: 'page', required: false, schema: new OA\Schema(type: 'integer', default: 1))] + $page = 1 + ) { + } +} diff --git a/tests/Fixtures/Routing/Lang.php b/tests/Fixtures/Routing/Lang.php new file mode 100644 index 0000000..835587f --- /dev/null +++ b/tests/Fixtures/Routing/Lang.php @@ -0,0 +1,12 @@ +assertTrue($route->matches(Request::create('hid/a1b2c3'))); $this->assertFalse($route->matches(Request::create('hid/z12x3'))); } + + #[Test] + public function enumParameter(): void + { + $this->assertInstanceOf(Route::class, $route = $this->getRouter()->getRoutes()->getByName('lang')); + $this->assertTrue($route->matches(Request::create('lang/en'))); + $this->assertFalse($route->matches(Request::create('lang/fr'))); + } + + #[Test] + public function uuidParameter(): void + { + $this->assertInstanceOf(Route::class, $route = $this->getRouter()->getRoutes()->getByName('item')); + $this->assertTrue($route->matches(Request::create('item/0b5e7a6c-3f1d-4c2a-9e8b-7d6f5a4b3c2d'))); + $this->assertFalse($route->matches(Request::create('item/123'))); + } + + #[Test] + public function defaultParameter(): void + { + $this->assertInstanceOf(Route::class, $route = $this->getRouter()->getRoutes()->getByName('list')); + $this->assertSame(['page' => 1], $route->defaults); + $this->get('list')->assertSee('Page: 1'); + } } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index d66612b..5ede3fe 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -3,6 +3,7 @@ namespace Radebatz\OpenApi\Routing\Tests; use OpenApi\Builder; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Psr\Log\AbstractLogger; @@ -16,6 +17,8 @@ use Radebatz\OpenApi\Routing\Tests\Fixtures\Controllers\SharedParameterController; use Radebatz\OpenApi\Routing\Tests\Fixtures\Middleware\BarMiddleware; use Radebatz\OpenApi\Routing\Tests\Fixtures\Middleware\FooMiddleware; +use Radebatz\OpenApi\Routing\Tests\Fixtures\Routing\FacetsController; +use Radebatz\OpenApi\Routing\Tests\Fixtures\Routing\Lang; use Radebatz\OpenApi\Routing\Tests\Fixtures\Routing\OperationIdController; use Radebatz\OpenApi\Routing\Tests\Fixtures\Routing\RefParameterController; use Radebatz\OpenApi\Routing\Tests\Fixtures\Routing\TenantParameter; @@ -53,7 +56,7 @@ public function readsPathParametersDeclaredOnThePathItem(): void $this->assertInstanceOf(RouteRegistration::class, $route); $this->assertSame('/shared/{tenant}/items', $route->path); $this->assertSame( - ['tenant' => ['required' => true, 'type' => 'integer', 'pattern' => null]], + ['tenant' => ['required' => true, 'type' => 'integer', 'pattern' => null, 'default' => null]], $route->parameters, ); } @@ -66,7 +69,7 @@ public function readsAPathParameterThePathItemDeclaresByReference(): void $this->assertInstanceOf(RouteRegistration::class, $route); $this->assertSame('/ref/{tenant}/items', $route->path); $this->assertSame( - ['tenant' => ['required' => true, 'type' => 'integer', 'pattern' => null]], + ['tenant' => ['required' => true, 'type' => 'integer', 'pattern' => null, 'default' => null]], $route->parameters, 'the schema and `required` come from the component the reference points to', ); @@ -79,7 +82,7 @@ public function aNullableTypeStillConstrainsTheParameter(): void $this->assertInstanceOf(RouteRegistration::class, $route); $this->assertSame( - ['id' => ['required' => true, 'type' => 'integer', 'pattern' => null]], + ['id' => ['required' => true, 'type' => 'integer', 'pattern' => null, 'default' => null]], $route->parameters, 'OpenAPI 3.1 spells nullability as a type list; `null` is not routable, so it drops out', ); @@ -92,12 +95,41 @@ public function aGenuineUnionConstrainsNothing(): void $this->assertInstanceOf(RouteRegistration::class, $route); $this->assertSame( - ['id' => ['required' => true, 'type' => null, 'pattern' => null]], + ['id' => ['required' => true, 'type' => null, 'pattern' => null, 'default' => null]], $route->parameters, 'two routable types leave no single constraint to apply', ); } + /** + * @return iterable + */ + public static function facets(): iterable + { + $uuid = '[\\da-fA-F]{8}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{12}'; + + yield 'enum' => ['enum', ['required' => true, 'type' => 'regex', 'pattern' => 'en|de', 'default' => null]]; + yield 'enum by reference' => ['backed', ['required' => true, 'type' => 'regex', 'pattern' => 'en|de', 'default' => null]]; + yield 'integer enum' => ['number', ['required' => true, 'type' => 'regex', 'pattern' => '1|2', 'default' => null]]; + yield 'pattern wins over enum' => ['pattern', ['required' => true, 'type' => 'regex', 'pattern' => '[a-z]{2}', 'default' => null]]; + yield 'uuid' => ['uuid', ['required' => true, 'type' => 'regex', 'pattern' => $uuid, 'default' => null]]; + yield 'ulid' => ['ulid', ['required' => true, 'type' => 'regex', 'pattern' => '[0-7][0-9a-hjkmnp-tv-zA-HJKMNP-TV-Z]{25}', 'default' => null]]; + yield 'default' => ['page', ['required' => false, 'type' => 'integer', 'pattern' => null, 'default' => 1]]; + } + + /** + * @param array{required: bool, type: ?string, pattern: ?string, default: int|float|string|null} $expected + */ + #[Test] + #[DataProvider('facets')] + public function schemaFacetsBecomeRouteConstraints(string $name, array $expected): void + { + $route = $this->register(Lang::class, FacetsController::class)->route($name); + + $this->assertInstanceOf(RouteRegistration::class, $route); + $this->assertSame([array_key_first($route->parameters) => $expected], $route->parameters); + } + #[Test] public function operationIdBecomesTheRouteNameByDefault(): void { diff --git a/tests/Slim/ParametersTest.php b/tests/Slim/ParametersTest.php index ba1e737..74195b3 100644 --- a/tests/Slim/ParametersTest.php +++ b/tests/Slim/ParametersTest.php @@ -50,4 +50,26 @@ public function regexParameter(): void $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('hid')); $this->assertSame('/hid/{hid:[0-9a-f]+}', $route->getPattern()); } + + #[Test] + public function enumParameter(): void + { + $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('lang')); + $this->assertSame('/lang/{lang:en|de}', $route->getPattern()); + } + + #[Test] + public function uuidParameter(): void + { + $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('item')); + $this->assertSame('/item/{id:[\\da-fA-F]{8}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{12}}', $route->getPattern()); + } + + #[Test] + public function defaultParameter(): void + { + $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('list')); + $this->assertSame('/list[/{page:[0-9]+}]', $route->getPattern()); + $this->assertSame('1', $route->getArgument('page')); + } } From aef2e3cf7361c6b1542a668c77a901bba30607f7 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Wed, 21 Oct 2026 13:03:36 +1300 Subject: [PATCH 26/27] Allow swagger-php 7.0 --- README.md | 2 +- composer.json | 2 +- docs/UpgradingTo5.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 12b08f8..d191843 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Supported frameworks: ## Requirements * PHP 8.2 or higher -* `zircote/swagger-php` ^6.13 +* `zircote/swagger-php` ^6.13 or ^7.0 ## Installation diff --git a/composer.json b/composer.json index 1340329..3df2ba4 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "php": ">=8.2", "psr/log": "^1.1 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", - "zircote/swagger-php": "^6.13" + "zircote/swagger-php": "^6.13 || ^7.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.62.0", diff --git a/docs/UpgradingTo5.md b/docs/UpgradingTo5.md index f56f2ba..a4fda05 100644 --- a/docs/UpgradingTo5.md +++ b/docs/UpgradingTo5.md @@ -12,7 +12,7 @@ several things it could not. | | 4.x | 5.x | |---|---|---| | PHP | 8.1+ | 8.2+ | -| swagger-php | `^4.11 \|\| ^5.0 \|\| ^6.0` | `^6.13` | +| swagger-php | `^4.11 \|\| ^5.0 \|\| ^6.0` | `^6.13 \|\| ^7.0` | | `radebatz/openapi-extras` | required | removed | | docblock annotations | supported | not read at all | From a47a07af4f3e4b65f5676f37bdd369dc63cdd4bc Mon Sep 17 00:00:00 2001 From: Martin Rademacher Date: Fri, 23 Oct 2026 09:13:32 +1300 Subject: [PATCH 27/27] Suggest openapi-verifier for checking responses against the document (#66) --- README.md | 17 +++++++++++++++++ composer.json | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d191843..5f721ca 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,23 @@ Use swagger-php directly — its CLI or `Builder` over the same sources: The output is identical either way — this package adds nothing to the document. +## Verifying responses + +The routes are built from the attributes, so they match the document. +[openapi-verifier](https://github.com/DerManoMann/openapi-verifier) checks the other half: that +the responses match it too. It has adapters for Laravel and Slim tests, PSR-15 middleware and +Guzzle or PSR-18 clients. + +Give it the document generated above. Its Laravel and Slim adapters look for +`tests/openapi.json` first, so generating there needs no further setup: + +```sh +./vendor/bin/openapi --mode spec -o tests/openapi.json src/controllers +``` + +Without a document, the verifier generates one in swagger-php's default mode, which on 6.x +reads no spec attributes. + ## Documentation * [Configuration](docs/Configuration.md) diff --git a/composer.json b/composer.json index 3df2ba4..477ad37 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,6 @@ "symfony/psr-http-message-bridge": "^6.0 || ^7.0" }, "suggest": { - "radebatz/openapi-verifier": "Allows your PHPUnit tests to validate your controller response against your OpenAPI spec." + "radebatz/openapi-verifier": "Verifies responses against the document, in Laravel and Slim tests, as PSR-15 middleware or in Guzzle and PSR-18 clients" } }