diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 503da53..2629e34 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@v7 + 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/framework "^${{ 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 efcff48..a3c5395 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 c63cac1..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@v7 - 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 3f24da9..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@v7 - 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..d060f95 --- /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@v7 + 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..2823918 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ .idea/ .php_cs.cache .php-cs-fixer.cache +.phpunit.cache/ .phpunit.result.cache composer.lock vendor/ tests/storage/ -tests/*/openapi.yaml /.claude/settings.local.json 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/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..1aeace9 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,107 @@ +# openapi-router + +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 +[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 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 — +`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 +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 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 + *which operations are discovered* + +## Example dialogue + +> **Dev:** "My middleware isn't being applied." +> **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 +> 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. +- "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 ea4ea4f..5f721ca 100644 --- a/README.md +++ b/README.md @@ -5,79 +5,218 @@ [![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: -* [Laravel](https://github.com/laravel/laravel) -* [Slim](https://github.com/slimphp/Slim) +Configure framework routes from the [swagger-php](https://github.com/zircote/swagger-php) +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. 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. -## Requirements -* [PHP 8.1 or higher](http://www.php.net/) - depending on framework version. +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. -## Installation +Supported frameworks: -You can use **composer** or simply **download the release**. +* [Laravel](https://github.com/laravel/framework) 12 and 13 +* [Slim](https://github.com/slimphp/Slim) 4 -**Composer** +## Requirements -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. +* PHP 8.2 or higher +* `zircote/swagger-php` ^6.13 or ^7.0 -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'); + public function getme($request, $response) + { + $response->getBody()->write('Get me'); + + return $response; } } ``` +`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. + **index.php** + ```php registerRoutes(); $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 + +### `#[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. 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 + +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. 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`. + +### 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 +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: + +```sh +./vendor/bin/openapi --mode spec -o openapi.yaml src/controllers +``` + +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) +* [Upgrading to 5.x](docs/UpgradingTo5.md) +* [Terminology](CONTEXT.md) — the words this package uses, and the ones it avoids ## License diff --git a/composer.json b/composer.json index 1f40b64..477ad37 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" ], @@ -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 }, @@ -39,30 +42,29 @@ }, "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.13 || ^7.0" }, "require-dev": { - "doctrine/annotations": "^2.0", "friendsofphp/php-cs-fixer": "^3.62.0", + "laravel/framework": "^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/finder": "^6.4 || ^7.0 || ^8.0", "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." - }, - "conflict": { - "zircote/swagger-php": "5.3.0" + "radebatz/openapi-verifier": "Verifies responses against the document, in Laravel and Slim tests, as PSR-15 middleware or in Guzzle and PSR-18 clients" } } diff --git a/docs/Configuration.md b/docs/Configuration.md index 21f677a..f27d1dd 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -1,113 +1,91 @@ # 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`. +Prefixes follow swagger-php's convention: `set*` takes a value, `with*` takes a callable that +configures something. -**`OPTION_RELOAD`** ---- -Enforces loading of route annotations on each request. - -Typically you want this turned off on production. Requires a cache confgured (annotation caching) or caching support implemented by the used adapter. - -**Note**: When using a framework it is recommended to rely on the framework caching rather than using the (simple) build in cache. - -Default: `true` +```php +use Radebatz\OpenApi\Routing\Adapters\LaravelRoutingAdapter; +use Radebatz\OpenApi\Routing\OpenApiRouter; -**`OPTION_CACHE`** ---- -Instance of a PSR-16 simple cache. +(new OpenApiRouter([__DIR__ . '/../src/Controllers'], new LaravelRoutingAdapter($app))) + ->setReload(false) + ->setCache($psr16Cache) + ->registerRoutes(); +``` -Used for caching of parsed OpenApi annotations if the `reload` option is disabled. +## Router -Default: `null` +### `setReload(bool $reload = true)` -**`OPTION_OA_INFO_INJECT`** ---- -Controls whether to inject a default `@OA\Info` instance while scanning. +Rescan on every `registerRoutes()` call, bypassing both the configured cache and the +adapter's own cached routes. Default `true`; turn it off in production. -This can be useful for testing or small projects. +### `setCache(?CacheInterface $cache)` -Default: `false` +A PSR-16 cache for the extracted routes, used when `setReload(false)` is set. Default +`null`. -**`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. +It holds the routes, not the specification — a `Spec\Operation` carries a live `\Reflector` +and cannot be serialized. -Allows to set the route name via the standard `operationId` rather than the vendor `x-name`. +### `setOperationIdAsName(bool $operationIdAsName = true)` -**Note**: The default for `operationId` in `swagger-php` is to generate an operationId and hash it if it is explicitely set. generated +Use each operation's `operationId` as the route name. Default `true`. When off, only an +explicit `x-name` names a route. -Default: `true` +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: -### Example use ```php - true, - OpenApiRouter::OPTION_CACHE => new ArrayCache(), - ]; - - (new OpenApiRouter([__DIR__ . '/Fixtures/Laravel'], new LaravelRoutingAdapter($app), $options)) - ->registerRoutes(); +$router->withBuilder(function (Builder $builder): void { + $builder->getAugmenters()->get(OpenApi\Augmenter\OperationIds::class)?->setHash(false); +}); ``` -## 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. +### `setLogger(?LoggerInterface $logger)` -Right now these options are available: +A PSR-3 logger for the scan, applied to the builder before the `withBuilder()` hook runs. -**`OPTION_AUTO_REGEX`** ---- -When enabled the adapter will automatically configure a `[0-9]+` regex for any path elements defined as integer. +### `withBuilder(callable $hook)` -Available for: -* All adapters +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()`. - default: `true` +```php +$router->withBuilder(function (Builder $builder): void { + $builder->withAttributeFactory( + fn ($factory) => $factory->withTranslators( + fn ($translators) => $translators->add(new MyTranslator()) + ) + ); +})->registerRoutes(); +``` -**`OPTION_NAMESPACE`** ---- -Specifies a base namespace for all controllers. If set this will be removed from the controller classes passed into the -framework router. +The router applies sources and mode itself, so the hook does not need to. -Available for: -* `LaravelRouteringAdapter` +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. - default: `'App\\Http\\Controllers\\'` -* `LumneRoutingAdapter` - - default: `'App\\Http\\Controllers\\'` +## Adapters -### Example use +Both adapters take the framework application and one option: ```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(); +new LaravelRoutingAdapter($app, autoRegex: false); +new SlimRoutingAdapter($app, autoRegex: false); ``` + +### `$autoRegex` + +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 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/docs/UpgradingTo5.md b/docs/UpgradingTo5.md new file mode 100644 index 0000000..a4fda05 --- /dev/null +++ b/docs/UpgradingTo5.md @@ -0,0 +1,181 @@ +# 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. + +`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.13 \|\| ^7.0` | +| `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 +``` + +`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: + +- **`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 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. + +**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 + +```diff +-#[OA\Get(path: '/pets/{id}')] ++#[OA\Operation\Get(path: '/pets/{id}')] +``` + +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` + +```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: 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))) ++ ->setReload(false) ++ ->setCache($cache) ++ ->registerRoutes(); +``` + +| 4.x | 5.x | +|---|---| +| `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 +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. + +## 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. 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 93b220d..00dd148 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; /** @@ -13,28 +12,26 @@ */ 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, + ) { } /** * @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) { - if (!$parameter['required'] && false !== strpos($path, $needle = "/{{$name}}")) { + $defaults = []; + foreach ($route->parameters as $name => $parameter) { + if (!$parameter['required'] && str_contains($path, $needle = "/{{$name}}")) { $path = str_replace($needle, "/{{$name}?}", $path); } @@ -46,14 +43,21 @@ public function register(Operation $operation, string $controller, array $parame break; case 'integer': - if ($this->options[static::OPTION_AUTO_REGEX]) { + if ($this->autoRegex) { $where[$name] = '[0-9]+'; } break; } + + if ($parameter['default'] !== null) { + $defaults[$name] = $parameter['default']; + } } - $controller = str_replace('::__invoke', '', $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'); @@ -61,14 +65,18 @@ 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]) - ->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); + foreach ($defaults as $name => $default) { + $laravelRoute->defaults($name, $default); + } } /** diff --git a/src/Adapters/SlimRoutingAdapter.php b/src/Adapters/SlimRoutingAdapter.php index 35f686f..af4aa0d 100644 --- a/src/Adapters/SlimRoutingAdapter.php +++ b/src/Adapters/SlimRoutingAdapter.php @@ -2,7 +2,8 @@ namespace Radebatz\OpenApi\Routing\Adapters; -use OpenApi\Annotations as OA; +use Psr\Container\ContainerInterface; +use Radebatz\OpenApi\Routing\RouteRegistration; use Radebatz\OpenApi\Routing\RoutingAdapterInterface; use Slim\App; @@ -11,30 +12,28 @@ */ 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 App $app + * @param bool $autoRegex Constrain an `integer`-typed path parameter to `[0-9]+` + */ + public function __construct( + protected App $app, + protected bool $autoRegex = true, + ) { } /** * @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}}[/{")) { + if (str_contains($path, $needle = "/{{$name}}[/{")) { // multiple optional parameters $path = preg_replace("#/{{$name}}(\[?.*}\])#", "[/{{$name}}$1]", $path); } else { @@ -50,20 +49,26 @@ public function register(OA\Operation $operation, string $controller, array $par break; case 'integer': - if ($this->options[static::OPTION_AUTO_REGEX]) { + if ($this->autoRegex) { $path = str_replace("{{$name}}", "{{$name}:[0-9]+}", $path); } break; } } - $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 ($route->parameters as $name => $parameter) { + if ($parameter['default'] !== null) { + $slimRoute->setArgument($name, (string) $parameter['default']); + } } - 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..46cafdd --- /dev/null +++ b/src/Attributes/Middleware.php @@ -0,0 +1,62 @@ + $names Middleware names or class-strings, as the adapter expects them + */ + public function __construct( + public array $names = [], + ) { + } + + /** + * Nest into the `Operation` or `PathItem` this is stacked beside. + * + * Without it the attribute stays unmerged and is silently orphaned. + * + * @return array + */ + public function merge(): array + { + return [ + Operation::class => 'attachables[]', + 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 26b1206..1feaea8 100644 --- a/src/OpenApiRouter.php +++ b/src/OpenApiRouter.php @@ -2,151 +2,275 @@ 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\Spec as OA; +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\Extras\OpenApiBuilder; -use Radebatz\OpenApi\Extras\Annotations\Middleware; -use Symfony\Component\Finder\Finder; +use Radebatz\OpenApi\Routing\Attributes\Middleware; /** * OpenApi router. */ class OpenApiRouter { - public const OPTION_RELOAD = 'relaod'; - 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_ROUTES = 'openapi-router.routes'; - public const CACHE_KEY_OPENAPI = 'openapi-router.openapi'; + protected bool $reload = true; - protected string|array|Finder $sources; - protected ?OA\OpenApi $openapi; - protected RoutingAdapterInterface $routingAdapter; - protected array $options; + protected ?CacheInterface $cache = null; + + protected bool $operationIdAsName = true; + + protected ?LoggerInterface $logger = null; + + /** @var (callable(Builder): (Builder|void))|null */ + protected $builderHook; + + /** + * @param string|\SplFileInfo|\Reflector|iterable $sources directories, filenames or reflectors to scan — whatever `Builder::addSource()` accepts + */ + public function __construct( + protected string|\SplFileInfo|\Reflector|iterable $sources, + protected RoutingAdapterInterface $routingAdapter, + ) { + } /** - * Create new routes. + * Configure the builder before it runs. + * + * 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()`. * - * @param string|array|Finder $sources The directory(s) or filename(s) - * @param RoutingAdapterInterface $routingAdapter the framework adapter - * @param array $options Optional configuration options + * 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 __construct($sources, RoutingAdapterInterface $routingAdapter, array $options = []) + public function withBuilder(callable $hook): static { - $this->sources = $sources; - $this->routingAdapter = $routingAdapter; - $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, - ]; + $this->builderHook = $hook; + + return $this; + } + + /** + * The builder for this scan, hook applied. + */ + protected function builder(): Builder + { + $builder = (new Builder()) + ->addSource($this->sources) + ->setMode(Mode::SPEC); + + if ($this->logger instanceof LoggerInterface) { + $builder->setLogger($this->logger); + } + + if ($this->builderHook !== null) { + $customised = ($this->builderHook)($builder); + if ($customised instanceof Builder) { + $builder = $customised; + } + } + + return $builder; + } + + /** + * Rescan on every {@see registerRoutes()} call. + * + * Bypasses both the configured cache and the adapter's own cached routes. Typically off + * in production. + */ + public function setReload(bool $reload = true): static + { + $this->reload = $reload; + + return $this; } - public function registerRoutes(): ?OA\OpenApi + /** + * Cache extracted routes here across requests when {@see setReload()} is off. + */ + public function setCache(?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 setOperationIdAsName(bool $operationIdAsName = true): static + { + $this->operationIdAsName = $operationIdAsName; + + return $this; + } + + /** + * PSR-3 logger for the scan. + */ + public function setLogger(?LoggerInterface $logger): static { - if (!$this->options[self::OPTION_RELOAD] && $this->routingAdapter->registerCached()) { + $this->logger = $logger; + + return $this; + } + + /** + * @return list|null `null` when the adapter's own route cache was used instead + */ + public function registerRoutes(): ?array + { + if (!$this->reload && $this->routingAdapter->registerCached()) { return null; } - $openapi = 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); + $cache = $this->reload ? null : $this->cache; + + /** @var list|null $routes */ + $routes = is_array($cached = $cache?->get(self::CACHE_KEY_ROUTES)) ? array_values($cached) : null; + + if ($routes === null) { + $routes = $this->extractRoutes( + $this->builder()->build()->specification() ?? new Specification() + ); + + $cache?->set(self::CACHE_KEY_ROUTES, $routes); + } + + foreach ($routes as $route) { + $this->routingAdapter->register($route); + } + + return $routes; + } + + /** + * @return list + */ + protected function extractRoutes(Specification $specification): array + { + $hierarchy = $specification->buildPathItemHierarchy(); + $index = $specification->buildComponentIndex(); + + $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; + } + + $controller = $operation->getClassName() . '::' . $reflector->getName(); + $pathItems = $hierarchy->forOperation($operation); + + $routes[] = new RouteRegistration( + path: $operation->path ?? '', + method: strtoupper($operation->method ?? 'GET'), + controller: $controller, + parameters: $this->parameterMetadata(array_reverse($this->pathParameters($operation, $hierarchy, $index)), $index), + custom: $this->customProperties($operation, $pathItems), + ); } - $this->registerOpenApi($openapi ?: ($openapi = $this->scan())); + return $routes; + } - if ($cache && !$this->options[self::OPTION_RELOAD]) { - $cache->set(self::CACHE_KEY_OPENAPI, $openapi); + /** + * 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. + * + * 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, PathItemHierarchy $hierarchy, ComponentIndex $index): array + { + $parameters = $hierarchy->parametersFor($operation); + foreach ($operation->parameters ?? [] as $parameter) { + if (($key = $index->parameterKey($parameter)) !== null) { + $parameters[$key] = $parameter; + } } - return $openapi; + $pathParameters = []; + foreach ($parameters as $parameter) { + $parameter = (is_string($parameter->ref) ? $index->findParameter($parameter->ref) : null) ?? $parameter; + if ('path' === $parameter->in && $parameter->name !== null) { + $pathParameters[] = $parameter; + } + } + + return $pathParameters; } - protected function registerOpenApi(OA\OpenApi $openapi) + /** + * @param list $pathItems + * + * @return array + */ + protected function customProperties(OA\Operation $operation, array $pathItems): 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; - } - } - } - - $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; - } - } - - $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]; - } - } - } - - $this->routingAdapter->register( - $operation, - $controller, - $this->parameterMetadata(array_reverse($parameters)), - $custom - ); - } - } + // 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, inherited ones included. + $attachables = []; + foreach ([...$pathItems, $operation] as $holder) { + $attachables = [...$attachables, ...$holder->attachables ?? []]; + } + + $middleware = []; + foreach ($attachables as $attachable) { + if ($attachable instanceof Middleware) { + $middleware = [...$middleware, ...$attachable->names]; } } + + $name = $this->operationIdAsName ? $operation->operationId : null; + + $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 [ + RoutingAdapterInterface::X_NAME => $name, + RoutingAdapterInterface::X_MIDDLEWARE => array_values(array_unique($middleware)), + ]; } /** - * Extract (uri) parameter meta data. + * 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 * - * @param OA\Parameter[] $parameters + * @return array */ - protected function parameterMetadata(array $parameters): array + protected function parameterMetadata(array $parameters, ComponentIndex $index): array { $metadata = []; @@ -154,45 +278,92 @@ 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, + 'default' => null, ]; - if (!Generator::isDefault($parameter->schema)) { - $schema = $parameter->schema; - switch ($schema->type) { - case 'string': - $metadata[$name]['type'] = $schema->type; - if (!Generator::isDefault($pattern = $schema->pattern)) { - $metadata[$name]['type'] = 'regex'; - $metadata[$name]['pattern'] = $pattern; - } - break; - case 'integer': - $metadata[$name]['type'] = $schema->type; - break; + if ($schema = $parameter->schema) { + $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; } - public function scan(?LoggerInterface $logger = null): OA\OpenApi + /** + * 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 { - $generator = (new OpenApiBuilder())->build($logger); + if ($schema->pattern) { + return $schema->pattern; + } - // 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); - } + $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. + * + * 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; + } - return $analysis; - }); + $types = array_values(array_filter($type, static fn (string $candidate): bool => $candidate !== 'null')); - return $generator - ->generate($this->sources, $analysis); + return count($types) === 1 ? $types[0] : null; } } 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..708a526 --- /dev/null +++ b/src/RouteRegistration.php @@ -0,0 +1,29 @@ + $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, + public string $method, + public string $controller, + public array $parameters, + public array $custom, + ) { + } +} diff --git a/src/RoutingAdapterInterface.php b/src/RoutingAdapterInterface.php index b98a8e7..a24f52e 100644 --- a/src/RoutingAdapterInterface.php +++ b/src/RoutingAdapterInterface.php @@ -2,32 +2,49 @@ namespace Radebatz\OpenApi\Routing; -use OpenApi\Annotations\Operation; -use OpenApi\Annotations\Parameter; - /** - * 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. + * + * 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'; - public const X_MIDDLEWARE = 'middleware'; - public const OPTION_AUTO_REGEX = 'autoregex'; + /** + * Key in `RouteRegistration::$custom` holding the middleware list. + * + * 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'; /** - * Register a route. + * Register one route with the framework. * - * @param Operation $operation The route URI pattern - * @param array $parameters URI parameter meta data in reverse order - * @param array $custom Custom properties + * 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(Operation $operation, string $controller, array $parameters, array $custom): void; + 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; } 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/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/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 @@ + '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/Controllers/InheritedController.php b/tests/Fixtures/Controllers/InheritedController.php new file mode 100644 index 0000000..1507434 --- /dev/null +++ b/tests/Fixtures/Controllers/InheritedController.php @@ -0,0 +1,18 @@ + '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/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/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/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 @@ + '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 @@ + '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 @@ + '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 58f58cd..7df212d 100644 --- a/tests/Laravel/CachingTest.php +++ b/tests/Laravel/CachingTest.php @@ -3,47 +3,45 @@ 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 - { - 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], - ]; - } - /** - * @dataProvider reloadTests + * 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 function testReload(?CacheInterface $cache, bool $reload, bool $openapisCached): void + public static function reloadTests(): \Iterator { - $options = [ - OpenApiRouter::OPTION_OA_INFO_INJECT => true, - OpenApiRouter::OPTION_RELOAD => $reload, - OpenApiRouter::OPTION_CACHE => $cache, - ]; + 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]; + } - (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app = $this->getApp()), $options)) + #[DataProvider('reloadTests')] + public function testReload(?CacheInterface $cache, bool $reload, bool $routesCached): void + { + (new OpenApiRouter($this->getFixtureFinder(), new LaravelRoutingAdapter($app = $this->getApp()))) + ->setReload($reload) + ->setCache($cache) ->registerRoutes(); /** @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_OPENAPI)); + $this->assertSame($routesCached, $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 d81f055..1f3f390 100644 --- a/tests/Laravel/CallsApplicationTrait.php +++ b/tests/Laravel/CallsApplicationTrait.php @@ -18,18 +18,25 @@ trait CallsApplicationTrait protected function setUp(): void { - if (!class_exists('\\Illuminate\\Foundation\\Application')) { + if (!class_exists(Application::class)) { $this->markTestSkipped('not installed.'); } parent::setUp(); } - /** @inheritdoc */ + /** + * @inheritdoc + */ 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', @@ -37,14 +44,8 @@ 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)) - ->scan(); - file_put_contents(__DIR__ . '/openapi.yaml', $openapi->toYaml()); $this->app = $app; } @@ -59,7 +60,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..3c0ecd6 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,29 @@ 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()); + } + + #[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/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..6a0b09b 100644 --- a/tests/Laravel/ParametersTest.php +++ b/tests/Laravel/ParametersTest.php @@ -4,39 +4,73 @@ 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 pathItemParameter(): void { - $this->assertNotNull($route = $this->getRouter()->getRoutes()->getByName('hid')); + $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 + { + $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'))); } + + #[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/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..5ede3fe --- /dev/null +++ b/tests/RouterTest.php @@ -0,0 +1,293 @@ +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, 'default' => null]], + $route->parameters, + ); + } + + #[Test] + public function readsAPathParameterThePathItemDeclaresByReference(): void + { + $route = $this->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, 'default' => null]], + $route->parameters, + 'the schema and `required` come from the component the reference points to', + ); + } + + #[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, 'default' => 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, '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 + { + $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 ...$classes + */ + private function register(string ...$classes): RecordingAdapter + { + $adapter = new RecordingAdapter(); + (new OpenApiRouter(array_merge(...array_map($this->reflect(...), $classes)), $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/CallsControllerTrait.php b/tests/Slim/CallsControllerTrait.php index 1dca745..96deede 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,23 +33,20 @@ protected function getRouteCollector(?App $app = null): RouteCollectorInterface return $app->getRouteCollector(); } + /** + * @return App + */ 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)) - ->scan(); - file_put_contents(__DIR__ . '/openapi.yaml', $openapi->toYaml()); 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..74195b3 100644 --- a/tests/Slim/ParametersTest.php +++ b/tests/Slim/ParametersTest.php @@ -2,44 +2,74 @@ 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 pathItemParameter(): void + { + $this->assertNotNull($route = $this->getRouteCollector()->getNamedRoute('shared')); + $this->assertSame('/shared/{tenant:[0-9]+}/items', $route->getPattern()); + } + + #[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()); + } + + #[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')); } } diff --git a/tests/Slim/SlimTest.php b/tests/Slim/SlimTest.php index 55069f0..5ce1bed 100644 --- a/tests/Slim/SlimTest.php +++ b/tests/Slim/SlimTest.php @@ -2,53 +2,65 @@ namespace Radebatz\OpenApi\Routing\Tests\Slim; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Radebatz\OpenApi\Routing\Adapters\SlimRoutingAdapter; -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()); + $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()); } }