From b0ca188ed5e8ac83d9e334ed2d0875d28a0f8474 Mon Sep 17 00:00:00 2001 From: Skripchenko Denis Date: Thu, 1 Oct 2026 22:43:55 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BB=D1=8F=20@input/@output=20[method]=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=BE=20input.und?= =?UTF-8?q?eclared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Один метод контроллера может обслуживать много маршрутов — обобщённый CRUD-контроллер регистрируется под ключом на каждую сущность, и набор полей зависит от маршрута. `[method]` вызывался без аргументов и не мог ответить за конкретный маршрут; на практике это значило «ни одного поля». - OperationContext (версия, класс Api, ключ контроллера и действия, класс и метод контроллера, HTTP-метод, input/output, опции действия) уходит в метод через контейнер: по типу или по именам $version, $controllerKey, $actionKey, $httpMethod. Метод без этих параметров вызывается как раньше. - `[method]` может вернуть JSON Schema объекта — с ограничениями, nullable, enum; сливается с остальными @input (POST — JSON-тело, GET — query). - `@output [method]` с тем же контекстом. - `$tags[]` без потомка — массив скаляров. - Вложенная нотация сохраняет required; корень, объявленный после детей, их не затирает; вложенный @output не теряет плоских соседей. - Линтер: input.undeclared — метод валидирует вход и не объявляет @input. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 40 ++ README.md | 36 +- docs/de/README.md | 37 +- docs/de/docblock-tags.md | 101 +++- docs/de/linting.md | 24 +- docs/en/docblock-tags.md | 99 +++- docs/en/linting.md | 23 +- docs/ru/README.md | 37 +- docs/ru/docblock-tags.md | 97 +++- docs/ru/linting.md | 23 +- docs/zh/README.md | 34 +- docs/zh/docblock-tags.md | 92 ++- docs/zh/linting.md | 21 +- src/Services/Linter/OpenApiLinter.php | 107 +++- src/Services/OpenApi/OperationContext.php | 97 ++++ src/Traits/OpenApiTrait.php | 524 +++++++++++++++--- tests/Feature/Linter/ApiLintTest.php | 47 ++ tests/Fixtures/Linter/UndeclaredInputApi.php | 38 ++ .../Linter/UndeclaredInputController.php | 128 +++++ tests/Fixtures/OpenApi/ContextApi.php | 38 ++ tests/Fixtures/OpenApi/ContextController.php | 161 ++++++ tests/Fixtures/OpenApi/NestedApi.php | 29 + tests/Fixtures/OpenApi/NestedController.php | 40 ++ tests/Unit/Traits/NestedNotationTest.php | 44 ++ .../Traits/OperationContextSchemaTest.php | 97 ++++ 25 files changed, 1915 insertions(+), 99 deletions(-) create mode 100644 src/Services/OpenApi/OperationContext.php create mode 100644 tests/Fixtures/Linter/UndeclaredInputApi.php create mode 100644 tests/Fixtures/Linter/UndeclaredInputController.php create mode 100644 tests/Fixtures/OpenApi/ContextApi.php create mode 100644 tests/Fixtures/OpenApi/ContextController.php create mode 100644 tests/Fixtures/OpenApi/NestedApi.php create mode 100644 tests/Fixtures/OpenApi/NestedController.php create mode 100644 tests/Unit/Traits/NestedNotationTest.php create mode 100644 tests/Unit/Traits/OperationContextSchemaTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 684cf9f..326316c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`@input [method]` is told which operation it describes.** One controller + method may serve many routes — a generic CRUD controller registered under a + controller key per entity — and the fields depend on the route. The method + was called with nothing, so it could only answer for all routes at once, + which in practice meant describing no fields at all. It now receives an + `OperationContext` (version, Api class, controller key, action key, + controller class and method, HTTP verb, `input`/`output`, the action's + options) through the container: declare a parameter typed `OperationContext`, + or any of `$version`, `$controllerKey`, `$actionKey`, `$httpMethod`. A method + declaring none of them is called exactly as before. +- **A `[method]` may return a JSON Schema object** instead of docblock lines. + It goes into the spec as it is — `minimum`, `maxLength`, `pattern`, + `nullable`, `enum`, nested `items` — merged with the operation's other + `@input` lines (`required` lists united). For POST it is a JSON request body; + for GET each top-level property becomes a query parameter with its schema. +- **`@output [method]`**, with the same context and the same two return forms. +- **`$tags[]` describes a list of scalars**: `@input string $abilities[]` + becomes `{type: array, items: {type: string}}`. +- **Linter: `input.undeclared`** (warning). An action whose method visibly + validates its input — `validate()`, `Validator::make()`, `validator()`, a + `FormRequest` parameter — while its docblock declares no `@input` at all. + Narrowed to validating methods so that actions which take nothing are not + reported. + +### Fixed + +- Nested notation keeps `required`: `$widgets[].slug` lands in `items.required`, + `$address.city` in the object's `required`, and a required root line in the + body's `required`. They were all dropped. +- A root declared after its children (`$widgets[].slug` before `$widgets`) no + longer wipes them. +- `@output` with nested fields no longer drops the flat siblings: `@output + integer $id` next to `@output string $owner.name` used to leave `id` out of + the schema. +- The linter accepts the `[method]` form on `@output`. + ## [5.10.1] — 2026-09-14 ### Fixed diff --git a/README.md b/README.md index f2a55b8..14ac70f 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,38 @@ Documentation is generated automatically from PHP docblocks. No YAML or JSON fil * @input integer $items[].quantity Quantity */ ``` +A list of scalars takes `[]` with no child: `@input string $tags[]`. + +### Fields known only at runtime + +When the fields depend on the route — one generic controller registered under a +key per entity — `@input [method]` asks a controller method, and the method is +told which operation is being described: + +```php +use Dskripchenko\LaravelApi\Services\OpenApi\OperationContext; + +/** + * @input integer $id + * @input [entityFields] + */ +public function update(Request $request) { /* ... */ } + +public function entityFields(OperationContext $context): array +{ + // $context->controllerKey is `users` on /v1/users/update, `posts` on /v1/posts/update + return [ + 'type' => 'object', + 'properties' => ['email' => ['type' => 'string', 'format' => 'email', 'maxLength' => 255]], + 'required' => ['email'], + ]; +} +``` + +The method may return docblock lines (`['string $email Email']`) or a JSON +Schema object, which goes into the spec as it is — constraints and all. `@output +[method]` works the same way. See [docs/docblock-tags.md](docs/en/docblock-tags.md#dynamic-inputs-from-a-method). + ### Headers, security, responses ```php @@ -560,7 +592,9 @@ php artisan api:lint --unrouted # also: public methods no action points at ``` It reads the route map and the docblocks with the same parser the OpenAPI -generator uses. Full list of rules: [docs/linting.md](docs/en/linting.md). +generator uses, and also reports an action that validates its input without +declaring any (`input.undeclared`). Full list of rules: +[docs/linting.md](docs/en/linting.md). ## API Export diff --git a/docs/de/README.md b/docs/de/README.md index c537358..a22a5fe 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -287,6 +287,39 @@ Die Dokumentation wird automatisch aus PHP-Docblöcken generiert. Keine YAML- od * @input integer $items[].quantity Quantity */ ``` +Eine Liste von Skalaren schreibt man mit `[]` ohne Kindfeld: `@input string $tags[]`. + +### Felder, die erst zur Laufzeit bekannt sind + +Hängen die Felder von der Route ab — ein generischer Controller ist pro Entität +unter einem eigenen Schlüssel registriert —, fragt `@input [method]` eine +Controller-Methode, und der Methode wird mitgeteilt, welche Operation gerade +beschrieben wird: + +```php +use Dskripchenko\LaravelApi\Services\OpenApi\OperationContext; + +/** + * @input integer $id + * @input [entityFields] + */ +public function update(Request $request) { /* ... */ } + +public function entityFields(OperationContext $context): array +{ + // $context->controllerKey is `users` on /v1/users/update, `posts` on /v1/posts/update + return [ + 'type' => 'object', + 'properties' => ['email' => ['type' => 'string', 'format' => 'email', 'maxLength' => 255]], + 'required' => ['email'], + ]; +} +``` + +Die Methode darf Docblock-Zeilen (`['string $email Email']`) oder ein JSON-Schema-Objekt +zurückgeben, das unverändert in die Spezifikation übernommen wird — samt aller +Einschränkungen. `@output [method]` funktioniert genauso. Siehe [docblock-tags.md](docblock-tags.md#dynamische-eingaben-aus-einer-methode). + ### Header, Sicherheit, Antworten ```php @@ -463,7 +496,9 @@ php artisan api:lint --unrouted # zusätzlich: öffentliche Methoden ohne Aktion ``` Liest Routen-Tabelle und Docblocks mit demselben Parser wie der -OpenAPI-Generator. Vollständige Regelliste: [linting.md](linting.md). +OpenAPI-Generator und meldet außerdem eine Aktion, die ihre Eingaben validiert, +ohne welche zu deklarieren (`input.undeclared`). Vollständige Regelliste: +[linting.md](linting.md). ## API-Export diff --git a/docs/de/docblock-tags.md b/docs/de/docblock-tags.md index 5b426b3..6aebfd7 100644 --- a/docs/de/docblock-tags.md +++ b/docs/de/docblock-tags.md @@ -112,13 +112,96 @@ Erzeugt: Erzeugt `$ref: '#/components/schemas/OrderCreateRequest'` im requestBody. Das Modell muss in `getOpenApiTemplates()` definiert sein. -### Dynamische Eingaben aus Methode +### Liste von Skalaren + +```php +@input array ?$abilities Abilities +@input string $abilities[] One ability +``` + +`[]` ohne Kindfeld beschreibt das Element selbst: +`{"type": "array", "items": {"type": "string"}}`. + +### Pflichtfelder in verschachtelten Strukturen + +Eine Zeile ohne `?` ist auf ihrer eigenen Ebene erforderlich: `$widgets[].slug` +landet in `items.required`, `$address.city` im `required` von `address`, und die +Wurzelzeile (`$widgets`, `$address`) im `required` des Bodys. Eine Wurzel, die nur +ueber ihre Kinder bekannt ist, sagt nichts ueber sich selbst aus und wird nicht als +erforderlich markiert. + +### Dynamische Eingaben aus einer Methode ```php @input [getOpenApiMetaInputs] ``` -Ruft die Methode auf dem Controller auf und fuegt die zurueckgegebenen Eingaben zusammen. +Ruft die Methode auf dem Controller auf und fuegt das Ergebnis in die Eingaben der +Operation ein. Gedacht fuer Felder, die erst zur Laufzeit bekannt sind — gebaut +aus einem Modell, einer Konfiguration oder der Route selbst. + +**Der Operationskontext.** Eine Controller-Methode kann viele Routen bedienen: ein +generischer CRUD-Controller wird pro Entitaet unter einem eigenen Controller-Schluessel +registriert, und die Felder haengen von der Entitaet ab. Deshalb wird der Methode +mitgeteilt, welche Operation beschrieben wird. Sie erhaelt ueber den Container das, +was sie davon deklariert: + +| Parameter | Wert | +|---|---| +| `OperationContext $any` (nach Typ) | der gesamte Kontext, siehe unten | +| `string $version` | die API-Version, `v1` | +| `string $controllerKey` | der Controller-Schluessel aus `getMethods()` | +| `string $actionKey` | der Aktions-Schluessel aus `getMethods()` | +| `string $httpMethod` | das HTTP-Verb, kleingeschrieben | + +`Dskripchenko\LaravelApi\Services\OpenApi\OperationContext` enthaelt +`version`, `apiClass` (die `BaseApi`-Unterklasse), `controllerKey`, `actionKey`, +`controllerClass`, `controllerMethod`, `httpMethod`, `tag` (`input` oder +`output`) und `actionOptions` (die Definition der Aktion aus `getMethods()`, so +wie sie geschrieben ist), dazu `operationId()`. + +Eine Methode, die nichts davon deklariert, wird genau wie bisher aufgerufen; +bestehende `[method]`-Implementierungen funktionieren also unveraendert weiter. + +**Was die Methode zurueckgibt.** Entweder: + +- eine Liste von Docblock-Zeilen, die historische Form — + `['string $name Name', 'integer ?$age Age']`; +- ein JSON-Schema-**Objekt** — ein Array mit `properties` oder `type: object`. Es + wird unveraendert in die Spezifikation uebernommen, daher steht alles zur + Verfuegung, was die Zeilensyntax nicht ausdruecken kann: `minimum`, `maxLength`, + `pattern`, `nullable`, `enum`, verschachtelte `items` und so weiter. + +Ein zurueckgegebenes Schema wird mit den uebrigen `@input`-Zeilen der Operation +zusammengefuehrt — zuerst deren Properties, danach die des Schemas, `required`-Listen +werden vereinigt. Bei POST entsteht ein JSON-Request-Body; bei GET wird jede +Property der obersten Ebene zu einem Query-Parameter mit eigenem Schema. + +```php +use Dskripchenko\LaravelApi\Services\OpenApi\OperationContext; + +/** + * Update an entity + * + * @input integer $id Identifier + * @input [entityFields] + */ +public function update(Request $request): JsonResponse { /* ... */ } + +public function entityFields(OperationContext $context): array +{ + $entity = Entities::find($context->controllerKey); + + return [ + 'type' => 'object', + 'properties' => $entity->jsonSchemaProperties(), + 'required' => $context->actionKey === 'create' ? $entity->requiredFields() : [], + ]; +} +``` + +Die Spezifikation zeigt nun `/v1/users/update` mit den Feldern des Users und +`/v1/posts/update` mit denen des Posts — aus einer einzigen Methode. --- @@ -158,6 +241,20 @@ Variablennamen werden mit `?` vorangestellt, um ein Antwortfeld als optional zu --- +### Dynamische Ausgaben aus einer Methode + +```php +@output integer $id Identifier +@output [entityOutput] +``` + +Dasselbe wie `@input [method]`, nur ist `tag` im Kontext `output`: die Methode +liefert Zeilen oder ein Objektschema, das ueber die uebrigen `@output`-Zeilen gelegt +wird. Eine `@output {Template}`-Zeile hat weiterhin Vorrang, und `@response`-Tags +ersetzen `@output`, sofern vorhanden, vollstaendig. + +--- + ## @header — Anfrage-Header Definiert Header-Parameter fuer die Operation. diff --git a/docs/de/linting.md b/docs/de/linting.md index 39c2eab..62940e2 100644 --- a/docs/de/linting.md +++ b/docs/de/linting.md @@ -76,10 +76,10 @@ Gruppe zu unterscheiden, geht nicht ohne die ganze Anwendung hochzufahren. |---|---|---| | `tag.malformed` | Fehler | Der Tag-Inhalt lässt sich nicht parsen, und der Generator verwirft ihn kommentarlos | | `tag.empty` | Warnung | Ein Tag, hinter dem nichts steht | -| `tag.callable-misplaced` | Warnung | Die `[method]`-Form an einem anderen Tag als `@input` | +| `tag.callable-misplaced` | Warnung | Die `[method]`-Form an einem anderen Tag als `@input` und `@output` | | `tag.template-misplaced` | Warnung | Die `{Template}`-Form an einem anderen Tag als `@output` | | `tag.unknown-template` | Fehler | `@input @Model` / `@output @Model[]` nennt ein nicht definiertes Template | -| `tag.callable-missing` | Fehler | `@input [method]` nennt eine Methode, die der Controller nicht hat | +| `tag.callable-missing` | Fehler | `@input [method]` / `@output [method]` nennt eine Methode, die der Controller nicht hat | | `tag.unknown-type` | Warnung | Ein Typ außerhalb der bekannten Menge — er wird stillschweigend zu `string` | | `tag.duplicate-variable` | Warnung | Dieselbe Variable zweimal deklariert; die letzte gewinnt | | `tag.orphan-nesting` | Warnung | `$address.city` ohne deklariertes `$address` | @@ -97,6 +97,26 @@ Gruppe zu unterscheiden, geht nicht ohne die ganze Anwendung hochzufahren. Von Middleware beigesteuerte Eingaben werden berücksichtigt: ein `@default` für eine dort deklarierte Variable ist legitim und wird nicht gemeldet. +### Fehlende Auszeichnung + +| Regel | Schwere | Bedeutung | +|---|---|---| +| `input.undeclared` | Warnung | Die Methode validiert ihre Eingaben, und ihr Docblock hat überhaupt kein `@input` | + +Alles oben prüft die vorhandene Auszeichnung; diese Regel betrifft die fehlende. +„Kein `@input`“ allein wäre Rauschen — viele Aktionen nehmen nichts entgegen —, +deshalb schlägt die Regel nur dort an, wo die Methode sichtbar etwas validiert: +ein Aufruf von `validate()` / `validateWithBag()`, `Validator::make()`, der +Helper `validator()` oder ein `FormRequest`-Parameter. Ob die Regeln als Literal +dastehen oder zur Laufzeit zusammengesetzt werden, spielt keine Rolle: so oder so +hat der Endpunkt Felder, und die Spezifikation beschreibt keines davon. Kommentare +werden ignoriert, und es zählen nur die eigenen Tags der Methode — ein von +Middleware beigesteuertes `@input` beschreibt die Eingaben der Middleware, nicht +die Felder, die die Methode prüft. + +Sind die Felder erst zur Laufzeit bekannt, deklarieren Sie sie mit +[`@input [method]`](docblock-tags.md#dynamische-eingaben-aus-einer-methode). + ## Den Bericht lesen ``` diff --git a/docs/en/docblock-tags.md b/docs/en/docblock-tags.md index 58ed1ad..b60bcb6 100644 --- a/docs/en/docblock-tags.md +++ b/docs/en/docblock-tags.md @@ -112,13 +112,94 @@ Generates: Generates `$ref: '#/components/schemas/OrderCreateRequest'` in requestBody. The model must be defined in `getOpenApiTemplates()`. -### Dynamic inputs from method +### List of scalars + +```php +@input array ?$abilities Abilities +@input string $abilities[] One ability +``` + +`[]` with no child describes the element itself: +`{"type": "array", "items": {"type": "string"}}`. + +### Required fields in nested structures + +A line without `?` is required at its own level: `$widgets[].slug` lands in +`items.required`, `$address.city` in the `required` of `address`, and the root +line (`$widgets`, `$address`) in the `required` of the body. A root known only +through its children says nothing about itself and is not marked required. + +### Dynamic inputs from a method ```php @input [getOpenApiMetaInputs] ``` -Calls the method on the controller and merges returned inputs. +Calls the method on the controller and merges what it returns into the +operation's inputs. It is meant for fields that are only known at runtime — +built from a model, a config, or the route itself. + +**The operation context.** One controller method may serve many routes: a +generic CRUD controller is registered under one controller key per entity, and +the fields depend on the entity. So the method is told which operation is being +described. It receives, through the container, whatever it declares of: + +| Parameter | Value | +|---|---| +| `OperationContext $any` (by type) | the whole context, see below | +| `string $version` | the API version, `v1` | +| `string $controllerKey` | the controller key from `getMethods()` | +| `string $actionKey` | the action key from `getMethods()` | +| `string $httpMethod` | the verb, lowercase | + +`Dskripchenko\LaravelApi\Services\OpenApi\OperationContext` carries +`version`, `apiClass` (the `BaseApi` subclass), `controllerKey`, `actionKey`, +`controllerClass`, `controllerMethod`, `httpMethod`, `tag` (`input` or +`output`) and `actionOptions` (the action's definition from `getMethods()`, as +written), plus `operationId()`. + +A method that declares none of these is called exactly as before, so existing +`[method]` implementations keep working unchanged. + +**What the method returns.** Either of: + +- a list of docblock lines, the historical form — + `['string $name Name', 'integer ?$age Age']`; +- a JSON Schema **object** — an array with `properties` or `type: object`. It + goes into the spec as it is, so everything the line syntax cannot say is + available: `minimum`, `maxLength`, `pattern`, `nullable`, `enum`, nested + `items`, and so on. + +A returned schema is merged with the other `@input` lines of the operation — +their properties first, the schema's after, `required` lists united. For a POST +the result is a JSON request body; for a GET every top-level property becomes a +query parameter carrying its own schema. + +```php +use Dskripchenko\LaravelApi\Services\OpenApi\OperationContext; + +/** + * Update an entity + * + * @input integer $id Identifier + * @input [entityFields] + */ +public function update(Request $request): JsonResponse { /* ... */ } + +public function entityFields(OperationContext $context): array +{ + $entity = Entities::find($context->controllerKey); + + return [ + 'type' => 'object', + 'properties' => $entity->jsonSchemaProperties(), + 'required' => $context->actionKey === 'create' ? $entity->requiredFields() : [], + ]; +} +``` + +The spec now shows `/v1/users/update` with the user's fields and +`/v1/posts/update` with the post's, from one method. --- @@ -158,6 +239,20 @@ Prefix variable name with `?` to mark a response field as optional. Required fie --- +### Dynamic outputs from a method + +```php +@output integer $id Identifier +@output [entityOutput] +``` + +The same as `@input [method]`, with `tag` set to `output` in the context: the +method returns lines or an object schema, merged over the other `@output` +lines. An `@output {Template}` line still wins, and `@response` tags, when +present, replace `@output` altogether. + +--- + ## @header — Request headers Defines header parameters for the operation. diff --git a/docs/en/linting.md b/docs/en/linting.md index 1bb6ac8..87941bb 100644 --- a/docs/en/linting.md +++ b/docs/en/linting.md @@ -73,10 +73,10 @@ heard of is not possible without booting the whole application. |---|---|---| | `tag.malformed` | error | The tag body does not parse, and the generator drops it without a word | | `tag.empty` | warning | A tag with nothing after it | -| `tag.callable-misplaced` | warning | The `[method]` form on a tag other than `@input` | +| `tag.callable-misplaced` | warning | The `[method]` form on a tag other than `@input` and `@output` | | `tag.template-misplaced` | warning | The `{Template}` form on a tag other than `@output` | | `tag.unknown-template` | error | `@input @Model` / `@output @Model[]` names a template that is not defined | -| `tag.callable-missing` | error | `@input [method]` names a method the controller lacks | +| `tag.callable-missing` | error | `@input [method]` / `@output [method]` names a method the controller lacks | | `tag.unknown-type` | warning | A type outside the known set — it silently becomes `string` | | `tag.duplicate-variable` | warning | The same variable declared twice; the last one wins | | `tag.orphan-nesting` | warning | `$address.city` without a declared `$address` | @@ -94,6 +94,25 @@ heard of is not possible without booting the whole application. Inputs contributed by middleware are taken into account: a `@default` for a variable one of them declares is legitimate and is not reported. +### Missing markup + +| Rule | Severity | What it means | +|---|---|---| +| `input.undeclared` | warning | The method validates its input and its docblock has no `@input` at all | + +Everything above checks the markup that is there; this one is about markup that +is missing. "No `@input`" on its own would be noise — plenty of actions take +nothing — so the rule only fires where the method visibly validates something: +a `validate()` / `validateWithBag()` call, `Validator::make()`, the +`validator()` helper, or a `FormRequest` parameter. It does not matter whether +the rules are a literal or assembled at runtime: either way the endpoint has +fields and the spec describes none of them. Comments are ignored, and only the +method's own tags count — `@input` contributed by middleware describes the +middleware's input, not the fields the method checks. + +When the fields are only known at runtime, declare them with +[`@input [method]`](docblock-tags.md#dynamic-inputs-from-a-method). + ## Reading the report ``` diff --git a/docs/ru/README.md b/docs/ru/README.md index 4068b58..65b559c 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -287,6 +287,38 @@ V2 автоматически наследует `list`, `show`, `create`, `upda * @input integer $items[].quantity Количество */ ``` +Список скаляров записывается через `[]` без дочернего поля: `@input string $tags[]`. + +### Поля, известные только во время выполнения + +Когда поля зависят от маршрута — один обобщённый контроллер зарегистрирован под +своим ключом для каждой сущности, — `@input [method]` обращается к методу +контроллера, и методу сообщается, какая операция описывается: + +```php +use Dskripchenko\LaravelApi\Services\OpenApi\OperationContext; + +/** + * @input integer $id + * @input [entityFields] + */ +public function update(Request $request) { /* ... */ } + +public function entityFields(OperationContext $context): array +{ + // $context->controllerKey is `users` on /v1/users/update, `posts` on /v1/posts/update + return [ + 'type' => 'object', + 'properties' => ['email' => ['type' => 'string', 'format' => 'email', 'maxLength' => 255]], + 'required' => ['email'], + ]; +} +``` + +Метод может вернуть строки докблока (`['string $email Email']`) или объект JSON +Schema, который попадает в спецификацию как есть — вместе со всеми ограничениями. +`@output [method]` работает так же. Подробнее: [docblock-tags.md](docblock-tags.md#динамические-входные-данные-из-метода). + ### Заголовки, безопасность, ответы ```php @@ -461,8 +493,9 @@ php artisan api:lint --strict # для CI: провал и на предупр php artisan api:lint --unrouted # плюс публичные методы, на которые никто не ведёт ``` -Читает карту маршрутов и докблоки тем же парсером, что и генератор OpenAPI. -Полный список правил: [linting.md](linting.md). +Читает карту маршрутов и докблоки тем же парсером, что и генератор OpenAPI, а +также сообщает о действии, которое валидирует вход, но ничего не объявляет +(`input.undeclared`). Полный список правил: [linting.md](linting.md). ## Экспорт API diff --git a/docs/ru/docblock-tags.md b/docs/ru/docblock-tags.md index 4471a4f..2426378 100644 --- a/docs/ru/docblock-tags.md +++ b/docs/ru/docblock-tags.md @@ -112,13 +112,94 @@ Генерирует `$ref: '#/components/schemas/OrderCreateRequest'` в requestBody. Модель должна быть определена в `getOpenApiTemplates()`. +### Список скаляров + +```php +@input array ?$abilities Abilities +@input string $abilities[] One ability +``` + +`[]` без дочернего поля описывает сам элемент: +`{"type": "array", "items": {"type": "string"}}`. + +### Обязательные поля во вложенных структурах + +Строка без `?` обязательна на своём уровне: `$widgets[].slug` попадает в +`items.required`, `$address.city` — в `required` объекта `address`, а корневая +строка (`$widgets`, `$address`) — в `required` тела. Корень, известный только +через дочерние поля, ничего не говорит о себе сам и обязательным не помечается. + ### Динамические входные данные из метода ```php @input [getOpenApiMetaInputs] ``` -Вызывает метод контроллера и объединяет возвращённые входные данные. +Вызывает метод контроллера и объединяет то, что он вернул, с входными данными +операции. Предназначено для полей, которые известны только во время выполнения — +строятся по модели, по конфигу или по самому маршруту. + +**Контекст операции.** Один метод контроллера может обслуживать много маршрутов: +обобщённый CRUD-контроллер регистрируется под своим ключом контроллера для каждой +сущности, а поля зависят от сущности. Поэтому методу сообщается, какая операция +описывается. Через контейнер он получает то из перечисленного, что объявит: + +| Параметр | Значение | +|---|---| +| `OperationContext $any` (по типу) | весь контекст, см. ниже | +| `string $version` | версия API, `v1` | +| `string $controllerKey` | ключ контроллера из `getMethods()` | +| `string $actionKey` | ключ действия из `getMethods()` | +| `string $httpMethod` | HTTP-метод в нижнем регистре | + +`Dskripchenko\LaravelApi\Services\OpenApi\OperationContext` содержит +`version`, `apiClass` (наследник `BaseApi`), `controllerKey`, `actionKey`, +`controllerClass`, `controllerMethod`, `httpMethod`, `tag` (`input` или +`output`) и `actionOptions` (определение действия из `getMethods()` в том виде, +в каком оно записано), а также `operationId()`. + +Метод, который ничего из этого не объявляет, вызывается ровно как раньше, так что +существующие реализации `[method]` продолжают работать без изменений. + +**Что возвращает метод.** Одно из двух: + +- список строк докблока, историческая форма — + `['string $name Name', 'integer ?$age Age']`; +- **объект** JSON Schema — массив с `properties` или `type: object`. Он попадает + в спецификацию как есть, поэтому доступно всё, что не выразить синтаксисом + строк: `minimum`, `maxLength`, `pattern`, `nullable`, `enum`, вложенные + `items` и так далее. + +Возвращённая схема объединяется с остальными строками `@input` операции — сначала +их свойства, затем свойства схемы, списки `required` объединяются. Для POST +результатом становится JSON-тело запроса; для GET каждое свойство верхнего уровня +становится query-параметром со своей схемой. + +```php +use Dskripchenko\LaravelApi\Services\OpenApi\OperationContext; + +/** + * Update an entity + * + * @input integer $id Identifier + * @input [entityFields] + */ +public function update(Request $request): JsonResponse { /* ... */ } + +public function entityFields(OperationContext $context): array +{ + $entity = Entities::find($context->controllerKey); + + return [ + 'type' => 'object', + 'properties' => $entity->jsonSchemaProperties(), + 'required' => $context->actionKey === 'create' ? $entity->requiredFields() : [], + ]; +} +``` + +Теперь спецификация показывает `/v1/users/update` с полями пользователя, а +`/v1/posts/update` — с полями поста, и всё это из одного метода. --- @@ -158,6 +239,20 @@ --- +### Динамические выходные данные из метода + +```php +@output integer $id Identifier +@output [entityOutput] +``` + +То же, что `@input [method]`, только в контексте `tag` равен `output`: метод +возвращает строки или объектную схему, которая накладывается поверх остальных +строк `@output`. Строка `@output {Template}` по-прежнему главнее, а теги +`@response`, если они есть, полностью заменяют `@output`. + +--- + ## @header — Заголовки запроса Определяет параметры заголовков для операции. diff --git a/docs/ru/linting.md b/docs/ru/linting.md index 0480b14..f288873 100644 --- a/docs/ru/linting.md +++ b/docs/ru/linting.md @@ -73,10 +73,10 @@ php artisan api:lint |---|---|---| | `tag.malformed` | ошибка | Тело тега не разбирается, и генератор молча его выбрасывает | | `tag.empty` | предупреждение | Тег, после которого ничего нет | -| `tag.callable-misplaced` | предупреждение | Форма `[method]` не у `@input` | +| `tag.callable-misplaced` | предупреждение | Форма `[method]` не у `@input` и не у `@output` | | `tag.template-misplaced` | предупреждение | Форма `{Template}` не у `@output` | | `tag.unknown-template` | ошибка | `@input @Model` / `@output @Model[]` ссылается на неопределённый шаблон | -| `tag.callable-missing` | ошибка | `@input [method]` ссылается на метод, которого у контроллера нет | +| `tag.callable-missing` | ошибка | `@input [method]` / `@output [method]` ссылается на метод, которого у контроллера нет | | `tag.unknown-type` | предупреждение | Тип вне известного набора — молча станет `string` | | `tag.duplicate-variable` | предупреждение | Переменная объявлена дважды, побеждает последняя | | `tag.orphan-nesting` | предупреждение | `$address.city` без объявленного `$address` | @@ -94,6 +94,25 @@ php artisan api:lint Поля, которые добавляет middleware, учитываются: `@default` для объявленной там переменной законен и в отчёт не попадает. +### Отсутствующая разметка + +| Правило | Уровень | Смысл | +|---|---|---| +| `input.undeclared` | предупреждение | Метод валидирует вход, а в его докблоке нет ни одного `@input` | + +Всё, что выше, проверяет разметку, которая есть; это правило — о разметке, +которой нет. «Нет `@input`» само по себе было бы шумом — многие действия ничего +не принимают, — поэтому правило срабатывает только там, где метод явно что-то +валидирует: вызов `validate()` / `validateWithBag()`, `Validator::make()`, хелпер +`validator()` или параметр типа `FormRequest`. Неважно, записаны правила +литералом или собираются во время выполнения: в любом случае у эндпоинта есть +поля, а спецификация не описывает ни одного из них. Комментарии игнорируются, и +учитываются только собственные теги метода — `@input`, добавленный middleware, +описывает вход middleware, а не поля, которые проверяет метод. + +Если поля известны только во время выполнения, объявите их через +[`@input [method]`](docblock-tags.md#динамические-входные-данные-из-метода). + ## Как читать отчёт ``` diff --git a/docs/zh/README.md b/docs/zh/README.md index 9c2e658..2817756 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -287,6 +287,37 @@ V2自动继承V1中的`list`、`show`、`create`、`update`,同时覆盖控制 * @input integer $items[].quantity 数量 */ ``` +标量列表用不带子字段的 `[]` 表示:`@input string $tags[]`。 + +### 仅在运行时才知道的字段 + +当字段取决于路由时——同一个通用控制器按每个实体各注册一个键——`@input [method]` +会询问控制器的方法,并告诉该方法当前描述的是哪个操作: + +```php +use Dskripchenko\LaravelApi\Services\OpenApi\OperationContext; + +/** + * @input integer $id + * @input [entityFields] + */ +public function update(Request $request) { /* ... */ } + +public function entityFields(OperationContext $context): array +{ + // $context->controllerKey is `users` on /v1/users/update, `posts` on /v1/posts/update + return [ + 'type' => 'object', + 'properties' => ['email' => ['type' => 'string', 'format' => 'email', 'maxLength' => 255]], + 'required' => ['email'], + ]; +} +``` + +方法可以返回 docblock 行(`['string $email Email']`),也可以返回一个 JSON Schema +对象,后者会原样写入规范,所有约束一并保留。`@output [method]` 的用法相同。详见 +[docblock-tags.md](docblock-tags.md#从方法动态获取输入)。 + ### 头部、安全性、响应 ```php @@ -459,7 +490,8 @@ php artisan api:lint --strict # 用于 CI:警告也算失败 php artisan api:lint --unrouted # 另外检查没有动作指向的公有方法 ``` -它用与 OpenAPI 生成器相同的解析器读取路由表和 docblock。完整规则列表: +它用与 OpenAPI 生成器相同的解析器读取路由表和 docblock,并且还会报告校验了输入 +却没有声明任何输入的动作(`input.undeclared`)。完整规则列表: [linting.md](linting.md)。 ## API 导出 diff --git a/docs/zh/docblock-tags.md b/docs/zh/docblock-tags.md index 1731bd1..14e9e02 100644 --- a/docs/zh/docblock-tags.md +++ b/docs/zh/docblock-tags.md @@ -112,13 +112,90 @@ 在 requestBody 中生成 `$ref: '#/components/schemas/OrderCreateRequest'`。该模型必须在 `getOpenApiTemplates()` 中定义。 +### 标量列表 + +```php +@input array ?$abilities Abilities +@input string $abilities[] One ability +``` + +不带子字段的 `[]` 描述元素本身: +`{"type": "array", "items": {"type": "string"}}`。 + +### 嵌套结构中的必填字段 + +不带 `?` 的行在其所在层级上是必填的:`$widgets[].slug` 进入 `items.required`, +`$address.city` 进入 `address` 的 `required`,根行(`$widgets`、`$address`)进入 +请求体的 `required`。仅通过子字段得知的根没有对自身作出任何声明,因此不会被标记 +为必填。 + ### 从方法动态获取输入 ```php @input [getOpenApiMetaInputs] ``` -调用控制器上的方法并合并返回的输入参数。 +调用控制器上的方法,并将其返回结果合并到操作的输入中。适用于只有在运行时才知道的 +字段——根据模型、配置或路由本身构建。 + +**操作上下文。** 一个控制器方法可能服务于多个路由:通用 CRUD 控制器会按每个实体 +各注册一个控制器键,而字段取决于实体。因此会告诉该方法当前描述的是哪个操作。它通过 +容器获得自己声明的以下任意参数: + +| 参数 | 值 | +|---|---| +| `OperationContext $any`(按类型) | 完整上下文,见下文 | +| `string $version` | API 版本,`v1` | +| `string $controllerKey` | `getMethods()` 中的控制器键 | +| `string $actionKey` | `getMethods()` 中的动作键 | +| `string $httpMethod` | HTTP 方法,小写 | + +`Dskripchenko\LaravelApi\Services\OpenApi\OperationContext` 包含 +`version`、`apiClass`(`BaseApi` 子类)、`controllerKey`、`actionKey`、 +`controllerClass`、`controllerMethod`、`httpMethod`、`tag`(`input` 或 +`output`)以及 `actionOptions`(`getMethods()` 中按原样书写的动作定义),另有 +`operationId()`。 + +不声明其中任何参数的方法会像以前一样被调用,因此现有的 `[method]` 实现无需修改 +即可继续工作。 + +**方法返回什么。** 以下两者之一: + +- docblock 行的列表,即历史形式—— + `['string $name Name', 'integer ?$age Age']`; +- JSON Schema **对象**——带有 `properties` 或 `type: object` 的数组。它会原样 + 写入规范,因此行语法无法表达的一切都可以使用:`minimum`、`maxLength`、 + `pattern`、`nullable`、`enum`、嵌套的 `items` 等等。 + +返回的 schema 会与该操作的其他 `@input` 行合并——先是它们的属性,然后是 schema +的属性,`required` 列表取并集。对于 POST,结果是 JSON 请求体;对于 GET,每个顶层 +属性都会成为带有自身 schema 的查询参数。 + +```php +use Dskripchenko\LaravelApi\Services\OpenApi\OperationContext; + +/** + * Update an entity + * + * @input integer $id Identifier + * @input [entityFields] + */ +public function update(Request $request): JsonResponse { /* ... */ } + +public function entityFields(OperationContext $context): array +{ + $entity = Entities::find($context->controllerKey); + + return [ + 'type' => 'object', + 'properties' => $entity->jsonSchemaProperties(), + 'required' => $context->actionKey === 'create' ? $entity->requiredFields() : [], + ]; +} +``` + +现在规范中 `/v1/users/update` 显示用户的字段,`/v1/posts/update` 显示帖子的字段, +全部出自同一个方法。 --- @@ -158,6 +235,19 @@ --- +### 从方法动态获取输出 + +```php +@output integer $id Identifier +@output [entityOutput] +``` + +与 `@input [method]` 相同,只是上下文中的 `tag` 为 `output`:方法返回行或对象 +schema,合并到其他 `@output` 行之上。`@output {Template}` 行仍然优先,而存在 +`@response` 标签时,它们会完全取代 `@output`。 + +--- + ## @header — 请求头 定义操作的请求头参数。 diff --git a/docs/zh/linting.md b/docs/zh/linting.md index 4cf60b2..13a3b7e 100644 --- a/docs/zh/linting.md +++ b/docs/zh/linting.md @@ -68,10 +68,10 @@ php artisan api:lint |---|---|---| | `tag.malformed` | 错误 | 标签内容无法解析,生成器会一声不响地丢弃它 | | `tag.empty` | 警告 | 标签后面什么都没有 | -| `tag.callable-misplaced` | 警告 | 在 `@input` 之外的标签上使用了 `[method]` 形式 | +| `tag.callable-misplaced` | 警告 | 在 `@input` 和 `@output` 之外的标签上使用了 `[method]` 形式 | | `tag.template-misplaced` | 警告 | 在 `@output` 之外的标签上使用了 `{Template}` 形式 | | `tag.unknown-template` | 错误 | `@input @Model` / `@output @Model[]` 引用了未定义的模板 | -| `tag.callable-missing` | 错误 | `@input [method]` 引用了控制器没有的方法 | +| `tag.callable-missing` | 错误 | `@input [method]` / `@output [method]` 引用了控制器没有的方法 | | `tag.unknown-type` | 警告 | 类型不在已知集合内 —— 会被悄悄当作 `string` | | `tag.duplicate-variable` | 警告 | 同一变量声明了两次,以最后一次为准 | | `tag.orphan-nesting` | 警告 | 有 `$address.city` 却没有声明 `$address` | @@ -89,6 +89,23 @@ php artisan api:lint 由中间件提供的输入会被计入:为其中声明的变量写 `@default` 是合法的,不会被 报告。 +### 缺失的标记 + +| 规则 | 级别 | 含义 | +|---|---|---| +| `input.undeclared` | 警告 | 方法校验了输入,但其 docblock 中没有任何 `@input` | + +上面的规则检查的都是已有的标记;这一条针对的是缺失的标记。单凭「没有 `@input`」 +会产生噪音——很多动作本来就不接收任何参数——因此该规则只在方法明显在做校验时 +触发:调用 `validate()` / `validateWithBag()`、`Validator::make()`、`validator()` +辅助函数,或带有 `FormRequest` 参数。规则是字面量还是在运行时拼装都无关紧要: +无论哪种情况,端点都有字段,而规范一个也没有描述。注释会被忽略,并且只计入方法 +自身的标签——由中间件提供的 `@input` 描述的是中间件的输入,而不是方法所校验的 +字段。 + +如果字段只有在运行时才知道,请用 +[`@input [method]`](docblock-tags.md#从方法动态获取输入) 声明。 + ## 如何阅读报告 ``` diff --git a/src/Services/Linter/OpenApiLinter.php b/src/Services/Linter/OpenApiLinter.php index bfeadbb..1f10b20 100644 --- a/src/Services/Linter/OpenApiLinter.php +++ b/src/Services/Linter/OpenApiLinter.php @@ -267,6 +267,105 @@ private function lintAction( $docBlock = $this->docBlock($method->getDocComment()); $this->lintTags($where, $docBlock, $reflection, $templates, $securitySchemes, $middlewareList, $apiClass); + $this->lintUndeclaredInput($where, $docBlock, $method); + } + + /** + * An action that validates its input and declares none of it. + * + * The rest of the linter checks the markup that is there; this is the one + * check for markup that is missing. "No @input at all" on its own would be + * noise — plenty of actions take nothing — so it is narrowed to the + * methods that visibly validate something: a `validate()` call, a + * validator built in the body, or a FormRequest parameter. Whatever the + * rules are, literal or assembled at runtime, they mean the endpoint has + * fields, and the spec describes none of them. + * + * Only the method's own @input tags count: the ones middleware contribute + * describe the middleware's input, not the fields this method checks. + */ + private function lintUndeclaredInput(string $where, DocBlock $docBlock, \ReflectionMethod $method): void + { + if ($docBlock->getTagsByName('input') !== []) { + return; + } + + $evidence = $this->validationEvidence($method); + if ($evidence === null) { + return; + } + + $this->issues[] = LintIssue::warning( + 'input.undeclared', + $where, + "{$method->getDeclaringClass()->getName()}::{$method->getName()}() validates its input ({$evidence}) and declares no @input.", + 'Describe the fields with @input tags — or with @input [method] when they are only known at runtime.' + ); + } + + /** + * What gives away that a method validates its input, or null. + */ + private function validationEvidence(\ReflectionMethod $method): ?string + { + foreach ($method->getParameters() as $parameter) { + $type = $parameter->getType(); + if ($type instanceof \ReflectionNamedType + && ! $type->isBuiltin() + && is_subclass_of($type->getName(), 'Illuminate\\Foundation\\Http\\FormRequest') + ) { + return 'a FormRequest parameter'; + } + } + + $source = $this->methodBodyWithoutComments($method); + if ($source === null) { + return null; + } + + $patterns = [ + '/\bValidator\s*::\s*make\s*\(/' => 'Validator::make()', + '/(?:])validator\s*\(/' => 'validator()', + '/->\s*validate(?:WithBag)?\s*\(/i' => 'validate()', + ]; + foreach ($patterns as $pattern => $label) { + if (preg_match($pattern, $source) === 1) { + return $label; + } + } + + return null; + } + + private function methodBodyWithoutComments(\ReflectionMethod $method): ?string + { + $file = $method->getFileName(); + $start = $method->getStartLine(); + $end = $method->getEndLine(); + if ($file === false || $start === false || $end === false || ! is_readable($file)) { + return null; + } + + $lines = file($file); + if ($lines === false) { + return null; + } + + $code = implode('', array_slice($lines, $start - 1, $end - $start + 1)); + $tokens = token_get_all('issues[] = LintIssue::warning( 'tag.callable-misplaced', $where, - "@{$tagName} does not support the [method] form; only @input does.", + "@{$tagName} does not support the [method] form; only @input and @output do.", ); continue; @@ -426,7 +525,7 @@ private function lintParameterTags( $this->issues[] = LintIssue::error( 'tag.callable-missing', $where, - "@input [{$callable['callable']}] refers to a method the controller does not have.", + "@{$tagName} [{$callable['callable']}] refers to a method the controller does not have.", ); } diff --git a/src/Services/OpenApi/OperationContext.php b/src/Services/OpenApi/OperationContext.php new file mode 100644 index 0000000..f9b1ff0 --- /dev/null +++ b/src/Services/OpenApi/OperationContext.php @@ -0,0 +1,97 @@ + $actionOptions The action's definition from getMethods(), as written there. + */ + public function __construct( + public readonly string $version, + public readonly string $apiClass, + public readonly string $controllerKey, + public readonly string $actionKey, + public readonly string $controllerClass, + public readonly string $controllerMethod, + public readonly string $httpMethod, + public readonly string $tag = 'input', + public readonly array $actionOptions = [], + ) { + } + + /** The operationId the generator gives this operation. */ + public function operationId(): string + { + return "{$this->controllerKey}_{$this->actionKey}"; + } + + public function isInput(): bool + { + return $this->tag === 'input'; + } + + public function isOutput(): bool + { + return $this->tag === 'output'; + } + + /** A copy of the context asking about the other side of the operation. */ + public function forTag(string $tag): self + { + return new self( + $this->version, + $this->apiClass, + $this->controllerKey, + $this->actionKey, + $this->controllerClass, + $this->controllerMethod, + $this->httpMethod, + $tag, + $this->actionOptions, + ); + } + + /** + * The parameters handed to `app()->call()`. Keyed by the class name, the + * context binds to a parameter of that type whatever it is called; keyed by + * name, the scalars bind to parameters called exactly that. + * + * @return array + */ + public function callParameters(): array + { + return [ + self::class => $this, + 'version' => $this->version, + 'controllerKey' => $this->controllerKey, + 'actionKey' => $this->actionKey, + 'httpMethod' => $this->httpMethod, + ]; + } +} diff --git a/src/Traits/OpenApiTrait.php b/src/Traits/OpenApiTrait.php index eedc01f..84b5025 100644 --- a/src/Traits/OpenApiTrait.php +++ b/src/Traits/OpenApiTrait.php @@ -4,6 +4,7 @@ use Dskripchenko\LaravelApi\Facades\ApiModule; use Dskripchenko\LaravelApi\Services\OpenApi\DocPatterns; +use Dskripchenko\LaravelApi\Services\OpenApi\OperationContext; use Illuminate\Routing\Router; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Route; @@ -200,14 +201,28 @@ private static function getOpenApiPaths(string $version) $defaultsAndExamples = static::parseDefaultAndExampleTags($defaultTags, $exampleTags); foreach ($httpMethods as $httpMethod) { - $parameters = static::getParametersByTags($inputTagList, $class, $httpMethod, $defaultsAndExamples); + // Who is being described: a dynamic `[method]` serving many + // routes cannot answer for one of them without being told. + $context = new OperationContext( + (string) $version, + static::class, + (string) $controller, + $action, + $class, + $methodKey, + strtolower((string) $httpMethod), + 'input', + is_array($value) ? $value : [], + ); + + $parameters = static::getParametersByTags($inputTagList, $class, $httpMethod, $defaultsAndExamples, $context); $headerParameters = static::getHeaderParametersByTags($headerTagList); $parameters = array_merge($headerParameters, $parameters); $hasExplicitResponses = !empty($responseTags); $responses = $hasExplicitResponses ? static::getResponsesByTags($responseTags, $outputTagList) - : static::getResponseByTags($outputTagList); + : static::getResponseByTags($outputTagList, $class, $context->forTag('output')); $methodData = static::getMethodData($summary, $description, [ 'tags' => $tags, @@ -304,10 +319,17 @@ private static function getDocBlockByComment($comment) * @param $class * @param string $httpMethod * @param array $defaultsAndExamples + * @param OperationContext|null $context The operation being described, + * handed to `[method]` callables. * @return array */ - private static function getParametersByTags(array $tags, $class, $httpMethod = 'post', array $defaultsAndExamples = []) - { + private static function getParametersByTags( + array $tags, + $class, + $httpMethod = 'post', + array $defaultsAndExamples = [], + ?OperationContext $context = null + ) { $parameters = []; $pattern = static::getDocInputOutputPattern(); $callableInputsPattern = static::getDocInputsCallablePattern(); @@ -317,6 +339,7 @@ private static function getParametersByTags(array $tags, $class, $httpMethod = ' $parameterType = $httpMethod === 'get' ? 'query' : 'formData'; $parsedParams = []; + $explicitSchemas = []; /** * @var Tag $tag @@ -326,11 +349,21 @@ private static function getParametersByTags(array $tags, $class, $httpMethod = ' $descriptions = [$description]; if (preg_match($callableInputsPattern, $description, $matches)) { - $callable = "{$class}@{$matches['callable']}"; - $descriptions = app()->call($callable); + $result = static::callSchemaMethod($class, $matches['callable'], $context); + + if (static::isObjectSchema($result)) { + $explicitSchemas[] = $result; + continue; + } + + $descriptions = $result; } foreach ($descriptions as $description) { + if (!is_string($description)) { + continue; + } + if (preg_match($modelRefPattern, $description, $matches)) { $modelName = $matches['model']; if (static::$useResponseTemplates && static::isHasTemplate($modelName)) { @@ -353,60 +386,296 @@ private static function getParametersByTags(array $tags, $class, $httpMethod = ' } } + if (!empty($explicitSchemas)) { + $schema = static::mergeObjectSchemas( + static::objectSchemaFromParsedParams($parsedParams, $defaultsAndExamples), + ...$explicitSchemas + ); + + return array_merge($parameters, static::parametersFromObjectSchema($schema, $parameterType)); + } + if (static::hasNestedParameters($parsedParams)) { $nestedSchema = static::buildNestedSchema($parsedParams); - $parameters[] = static::getBodyParameterFromNested($nestedSchema); + $parameters[] = static::getBodyParameterFromNested($nestedSchema, static::requiredRootsOf($parsedParams)); } else { foreach ($parsedParams as $matches) { - $descText = Arr::get($matches, 'description', ''); - $enum = static::extractEnumFromDescription($descText); - $type = static::getSafeDataType(Arr::get($matches, 'type', 'string')); - $varName = Arr::get($matches, 'variable', ''); + $parameters[] = static::flatParameterFromMatches($matches, $parameterType, $defaultsAndExamples); + } + } - $param = [ - 'in' => $parameterType, - 'name' => $varName, - 'description' => $descText, - 'required' => Arr::get($matches, 'optional', '') !== '?', - 'type' => $type, - ]; + return $parameters; + } - $format = Arr::get($matches, 'format'); - if ($format) { - $param['format'] = $format; - } - if ($enum !== null) { - $param['enum'] = $enum; + /** + * One `type ?$name Description` line as a flat parameter. + * + * @param array $matches + * @param string $parameterType + * @param array $defaultsAndExamples + * @return array + */ + private static function flatParameterFromMatches(array $matches, string $parameterType, array $defaultsAndExamples): array + { + $descText = Arr::get($matches, 'description', ''); + $enum = static::extractEnumFromDescription($descText); + $type = static::getSafeDataType(Arr::get($matches, 'type', 'string')); + $varName = Arr::get($matches, 'variable', ''); + + $param = [ + 'in' => $parameterType, + 'name' => $varName, + 'description' => $descText, + 'required' => Arr::get($matches, 'optional', '') !== '?', + 'type' => $type, + ]; + + $format = Arr::get($matches, 'format'); + if ($format) { + $param['format'] = $format; + } + if ($enum !== null) { + $param['enum'] = $enum; + } + if (isset($defaultsAndExamples[$varName]['default'])) { + $param['default'] = $defaultsAndExamples[$varName]['default']; + } + if (isset($defaultsAndExamples[$varName]['example'])) { + $param['example'] = $defaultsAndExamples[$varName]['example']; + } + + return $param; + } + + /** + * Calls a `[method]` on the controller, telling it which operation is + * being described. + * + * The context goes through the container: a parameter typed + * OperationContext receives it whatever its name, and `$version`, + * `$controllerKey`, `$actionKey` and `$httpMethod` receive those scalars. + * A method declaring none of them is called exactly as it always was. + * + * @param string $class + * @param string $method + * @param OperationContext|null $context + * @return array A list of `type ?$name Description` lines, or an object schema. + */ + private static function callSchemaMethod(string $class, string $method, ?OperationContext $context): array + { + $result = app()->call("{$class}@{$method}", $context ? $context->callParameters() : []); + + return is_array($result) ? $result : []; + } + + /** + * Whether a `[method]` returned a JSON Schema object rather than a list of + * docblock lines. + * + * @param mixed $value + * @return bool + */ + private static function isObjectSchema($value): bool + { + if (!is_array($value) || $value === [] || array_is_list($value)) { + return false; + } + + if (isset($value['properties']) && is_array($value['properties'])) { + return true; + } + + return ($value['type'] ?? null) === 'object'; + } + + /** + * The docblock lines of an operation as one object schema, so they can be + * merged with a schema a `[method]` returned. + * + * @param array $parsedParams + * @param array $defaultsAndExamples + * @return array + */ + private static function objectSchemaFromParsedParams(array $parsedParams, array $defaultsAndExamples = []): array + { + $schema = ['type' => 'object', 'properties' => []]; + + if (empty($parsedParams)) { + return $schema; + } + + if (static::hasNestedParameters($parsedParams)) { + $schema['properties'] = static::buildNestedSchema($parsedParams); + $required = static::requiredRootsOf($parsedParams); + } else { + $required = []; + foreach ($parsedParams as $matches) { + $param = static::flatParameterFromMatches($matches, 'formData', $defaultsAndExamples); + $prop = ['type' => $param['type']]; + foreach (['format', 'enum', 'default', 'example'] as $key) { + if (array_key_exists($key, $param)) { + $prop[$key] = $param[$key]; + } } - if (isset($defaultsAndExamples[$varName]['default'])) { - $param['default'] = $defaultsAndExamples[$varName]['default']; + $prop['description'] = $param['description']; + if ($prop['type'] === 'file') { + $prop = ['type' => 'string', 'format' => 'binary', 'description' => $param['description']]; } - if (isset($defaultsAndExamples[$varName]['example'])) { - $param['example'] = $defaultsAndExamples[$varName]['example']; + $schema['properties'][$param['name']] = $prop; + if ($param['required']) { + $required[] = $param['name']; } + } + } + + if (!empty($required)) { + $schema['required'] = array_values(array_unique($required)); + } + + return $schema; + } - $parameters[] = $param; + /** + * Merges object schemas left to right: a property declared later replaces + * the earlier one of the same name, and the `required` lists are united. + * + * @param array ...$schemas + * @return array + */ + private static function mergeObjectSchemas(array ...$schemas): array + { + $merged = ['type' => 'object', 'properties' => []]; + $required = []; + + foreach ($schemas as $schema) { + foreach ($schema as $key => $value) { + if ($key === 'properties') { + foreach ((array) $value as $name => $property) { + $merged['properties'][$name] = $property; + } + } elseif ($key === 'required') { + $required = array_merge($required, (array) $value); + } elseif ($key !== 'type') { + $merged[$key] = $value; + } } } + $required = array_values(array_unique(array_filter( + $required, + static fn ($name): bool => is_string($name) && $name !== '' + ))); + if (!empty($required)) { + $merged['required'] = $required; + } + + return $merged; + } + + /** + * An object schema as parameters: the request body for anything but GET, + * and one query parameter per top-level property for GET. + * + * @param array $schema + * @param string $parameterType `query` or `formData` + * @return array + */ + private static function parametersFromObjectSchema(array $schema, string $parameterType): array + { + if (empty($schema['properties'])) { + return []; + } + + if ($parameterType !== 'query') { + return [[ + 'in' => 'body', + 'name' => 'body', + 'description' => 'Request body', + 'required' => true, + 'schema' => $schema, + ]]; + } + + $required = (array) ($schema['required'] ?? []); + $parameters = []; + foreach ($schema['properties'] as $name => $property) { + $property = (array) $property; + $description = (string) ($property['description'] ?? ''); + unset($property['description']); + + $parameters[] = [ + 'in' => 'query', + 'name' => (string) $name, + 'description' => $description, + 'required' => in_array($name, $required, true), + 'schema' => $property, + ]; + } + return $parameters; } /** * @param array $tags + * @param string|null $class The controller, for `@output [method]`. + * @param OperationContext|null $context The operation, for `@output [method]`. * @return array */ - private static function getResponseByTags(array $tags) + private static function getResponseByTags(array $tags, ?string $class = null, ?OperationContext $context = null) { - $properties = []; - $pattern = static::getDocInputOutputPattern(); - $templatePattern = static::getDocInputOutputTemplatePattern(); - $modelRefPattern = static::getDocModelRefPattern(); + + // `@output [method]` unfolds into the lines it returns, or into an + // object schema merged over whatever the other lines describe. + $lines = []; + $explicitSchemas = []; /** * @var Tag $tag */ foreach ($tags as $tag) { - $desctiption = $tag->getDescription()->render(); + $line = $tag->getDescription()->render(); + + if ($class !== null && preg_match(static::getDocInputsCallablePattern(), $line, $matches)) { + $result = static::callSchemaMethod($class, $matches['callable'], $context); + + if (static::isObjectSchema($result)) { + $explicitSchemas[] = $result; + } else { + $lines = array_merge($lines, array_filter($result, 'is_string')); + } + + continue; + } + + $lines[] = $line; + } + + $base = static::getResponseByLines($lines); + + // A `{Template}` line wins over everything, as it does among plain lines. + if (empty($explicitSchemas) || isset($base['schema'])) { + return $base; + } + + $schema = static::mergeObjectSchemas( + array_intersect_key($base, array_flip(['properties', 'required'])), + ...$explicitSchemas + ); + + return ['description' => 'Response payload', 'schema' => $schema]; + } + + /** + * @param string[] $lines The bodies of @output tags. + * @return array + */ + private static function getResponseByLines(array $lines) + { + $properties = []; + $pattern = static::getDocInputOutputPattern(); + $templatePattern = static::getDocInputOutputTemplatePattern(); + $modelRefPattern = static::getDocModelRefPattern(); + + foreach ($lines as $desctiption) { if (static::$useResponseTemplates && preg_match($templatePattern, $desctiption, $matches)) { if (static::isHasTemplate($matches['template'])) { @@ -465,25 +734,40 @@ private static function getResponseByTags(array $tags) } } - $parsedParams = []; - foreach ($properties as $variable => $prop) { - if (str_contains($variable, '.') || str_contains($variable, '[]')) { + if (static::hasNestedParameters(array_map( + static fn ($variable): array => ['variable' => (string) $variable], + array_keys($properties) + ))) { + // Every line takes part — the flat siblings and the roots' own + // declarations too, not only the dotted ones; references to + // templates are already schemas and are carried over as they are. + $parsedParams = []; + $references = []; + foreach ($properties as $variable => $prop) { + if (!isset($prop['type'])) { + $references[$variable] = $prop; + continue; + } $parsedParams[] = [ - 'variable' => $variable, + 'variable' => (string) $variable, 'type' => $prop['type'], + 'format' => $prop['format'] ?? null, 'description' => $prop['description'] ?? '', 'required' => $prop['required'] ?? true, ]; } - } - if (!empty($parsedParams) && static::hasNestedParameters($parsedParams)) { - $nestedSchema = static::buildNestedSchema($parsedParams); - return [ + $nested = [ 'description' => 'Response payload', 'type' => 'object', - 'properties' => $nestedSchema, + 'properties' => array_merge(static::buildNestedSchema($parsedParams), $references), ]; + $required = static::requiredRootsOf($parsedParams); + if (!empty($required)) { + $nested['required'] = $required; + } + + return $nested; } $requiredFields = []; @@ -884,6 +1168,50 @@ private static function hasNestedParameters(array $parsedParams): bool return false; } + /** + * Whether a parsed line is required: input lines carry the `?` mark, + * output lines arrive with a `required` flag already worked out. + * + * @param array $param + * @return bool + */ + private static function isRequiredParam(array $param): bool + { + if (array_key_exists('required', $param) && is_bool($param['required'])) { + return $param['required']; + } + + return ($param['optional'] ?? '') !== '?'; + } + + /** + * The required top-level names of a nested set of lines: a root counts + * when its own line (`$address`, or `$tags[]` for a list of scalars) is + * not marked optional. A root known only through its children says + * nothing about itself. + * + * @param array $parsedParams + * @return string[] + */ + private static function requiredRootsOf(array $parsedParams): array + { + $declared = []; + foreach ($parsedParams as $param) { + $variable = $param['variable'] ?? ''; + $parts = explode('.', str_replace('[]', '', $variable)); + if (count($parts) !== 1) { + continue; + } + // `$tags` decides over `$tags[]` whatever the order. + if (str_contains($variable, '[]') && array_key_exists($parts[0], $declared)) { + continue; + } + $declared[$parts[0]] = static::isRequiredParam($param); + } + + return array_keys(array_filter($declared)); + } + /** * @param array $parsedParams * @return array @@ -895,23 +1223,41 @@ private static function buildNestedSchema(array $parsedParams): array $variable = $param['variable'] ?? ''; $type = static::getSafeDataType($param['type'] ?? 'string'); $description = $param['description'] ?? ''; + $isArray = str_contains($variable, '[]'); - $parts = preg_split('/\./', str_replace('[]', '', $variable)); + $parts = explode('.', str_replace('[]', '', $variable)); $root = $parts[0]; + $groups[$root] = $groups[$root] ?? []; - if (count($parts) === 1 && !str_contains($variable, '[]')) { - $groups[$root] = ['type' => $type, 'description' => $description]; - } else { - if (!isset($groups[$root])) { - $groups[$root] = ['type' => 'object', 'properties' => []]; - } - if (count($parts) > 1) { - $child = $parts[1]; - $groups[$root]['properties'][$child] = ['type' => $type, 'description' => $description]; - } - if (str_contains($variable, '[]')) { - $groups[$root]['type'] = 'array'; + if (count($parts) === 1 && !$isArray) { + // The root's own line. It may come after its children, so it + // must not wipe what they already put there. + $groups[$root]['declaredType'] = $type; + $groups[$root]['description'] = $description; + continue; + } + + if ($isArray) { + $groups[$root]['isArray'] = true; + } + + if (count($parts) === 1) { + // `$tags[]` — the element of a list of scalars. + $groups[$root]['itemType'] = $type; + if (!isset($groups[$root]['description'])) { + $groups[$root]['description'] = $description; } + continue; + } + + $child = $parts[1]; + $property = ['type' => $type, 'description' => $description]; + if (!empty($param['format'])) { + $property['format'] = $param['format']; + } + $groups[$root]['properties'][$child] = $property; + if (static::isRequiredParam($param)) { + $groups[$root]['required'][] = $child; } } @@ -926,24 +1272,33 @@ private static function buildNestedSchemaRecursive(array $groups): array { $result = []; foreach ($groups as $key => $spec) { - $type = $spec['type'] ?? 'object'; $description = $spec['description'] ?? ''; - - if ($type === 'array' && isset($spec['properties'])) { + $properties = $spec['properties'] ?? null; + $required = array_values(array_unique($spec['required'] ?? [])); + $declaredType = $spec['declaredType'] ?? null; + + $isArray = !empty($spec['isArray']) || $declaredType === 'array'; + $type = $isArray + ? 'array' + : ($declaredType ?? ($properties !== null ? 'object' : 'string')); + + if ($type === 'array' && $properties !== null) { + $items = ['type' => 'object', 'properties' => $properties]; + if (!empty($required)) { + $items['required'] = $required; + } + $result[$key] = ['type' => 'array', 'description' => $description, 'items' => $items]; + } elseif ($type === 'array' && isset($spec['itemType'])) { $result[$key] = [ 'type' => 'array', 'description' => $description, - 'items' => [ - 'type' => 'object', - 'properties' => $spec['properties'], - ], - ]; - } elseif ($type === 'object' && isset($spec['properties'])) { - $result[$key] = [ - 'type' => 'object', - 'description' => $description, - 'properties' => $spec['properties'], + 'items' => ['type' => $spec['itemType']], ]; + } elseif ($type === 'object' && $properties !== null) { + $result[$key] = ['type' => 'object', 'description' => $description, 'properties' => $properties]; + if (!empty($required)) { + $result[$key]['required'] = $required; + } } else { $result[$key] = ['type' => $type, 'description' => $description]; } @@ -953,19 +1308,25 @@ private static function buildNestedSchemaRecursive(array $groups): array /** * @param array $schema + * @param string[] $required * @return array */ - private static function getBodyParameterFromNested(array $schema): array + private static function getBodyParameterFromNested(array $schema, array $required = []): array { + $bodySchema = [ + 'type' => 'object', + 'properties' => $schema, + ]; + if (!empty($required)) { + $bodySchema['required'] = array_values($required); + } + return [ 'in' => 'body', 'name' => 'body', 'description' => 'Request body', 'required' => true, - 'schema' => [ - 'type' => 'object', - 'properties' => $schema, - ], + 'schema' => $bodySchema, ]; } @@ -984,7 +1345,18 @@ private static function buildOasParametersAndBody(array $rawParameters): array foreach ($rawParameters as $param) { $in = $param['in'] ?? 'query'; - if ($in === 'query' || $in === 'header') { + if (($in === 'query' || $in === 'header') && isset($param['schema'])) { + // Already a schema: it came from an object schema a `[method]` + // returned, constraints and all. + $oasParam = [ + 'name' => $param['name'], + 'in' => $in, + 'description' => $param['description'] ?? '', + 'required' => $param['required'] ?? false, + 'schema' => $param['schema'], + ]; + $oasParams[] = $oasParam; + } elseif ($in === 'query' || $in === 'header') { $schema = ['type' => $param['type'] ?? 'string']; if (isset($param['format'])) { $schema['format'] = $param['format']; diff --git a/tests/Feature/Linter/ApiLintTest.php b/tests/Feature/Linter/ApiLintTest.php index 1c52c56..9cc2861 100644 --- a/tests/Feature/Linter/ApiLintTest.php +++ b/tests/Feature/Linter/ApiLintTest.php @@ -244,3 +244,50 @@ function useModule(string $moduleClass): void // `@OrderError!` — the required mark on a reference is not part of the name. expect(issuesOfRule($issues, 'template.unknown-ref'))->toBe([]); }); + +/** + * @return LintIssue[] + */ +function lintUndeclared(): array +{ + return issuesOfRule( + (new OpenApiLinter())->lintVersionList(['v1' => \Tests\Fixtures\Linter\UndeclaredInputApi::class]), + 'input.undeclared' + ); +} + +it('reports an action that validates input and declares none', function () { + $where = array_map(static fn (LintIssue $i): string => $i->where, lintUndeclared()); + + expect($where)->toBe([ + 'v1 · things.validatesLiteral', + 'v1 · things.validatesRuntime', + 'v1 · things.validatorFacade', + 'v1 · things.validatorHelper', + 'v1 · things.formRequest', + ]); +}); + +it('reports undeclared input as a warning that names the evidence', function () { + $issues = lintUndeclared(); + + expect($issues[0]->isError())->toBeFalse(); + expect($issues[0]->message)->toContain('validate()'); + expect($issues[2]->message)->toContain('Validator::make()'); + expect($issues[4]->message)->toContain('FormRequest'); +}); + +it('leaves alone actions that declare input, take none, or only mention validation in a comment', function () { + $where = array_map(static fn (LintIssue $i): string => $i->where, lintUndeclared()); + + expect($where)->not->toContain('v1 · things.declared'); + expect($where)->not->toContain('v1 · things.declaredDynamically'); + expect($where)->not->toContain('v1 · things.takesNothing'); + expect($where)->not->toContain('v1 · things.onlyMentionsInComment'); +}); + +it('accepts the [method] form on @output', function () { + $issues = (new OpenApiLinter())->lintVersionList(['v1' => \Tests\Fixtures\OpenApi\ContextApi::class]); + + expect($issues)->toBe([]); +}); diff --git a/tests/Fixtures/Linter/UndeclaredInputApi.php b/tests/Fixtures/Linter/UndeclaredInputApi.php new file mode 100644 index 0000000..173bf0e --- /dev/null +++ b/tests/Fixtures/Linter/UndeclaredInputApi.php @@ -0,0 +1,38 @@ + [ + 'things' => [ + 'controller' => UndeclaredInputController::class, + 'actions' => [ + 'validatesLiteral' => ['method' => ['post']], + 'validatesRuntime' => ['method' => ['post']], + 'validatorFacade' => ['method' => ['post']], + 'validatorHelper' => ['method' => ['post']], + 'formRequest' => ['method' => ['post']], + 'declared' => ['method' => ['post']], + 'declaredDynamically' => ['method' => ['post']], + 'takesNothing' => ['method' => ['get']], + 'onlyMentionsInComment' => ['method' => ['get']], + ], + ], + ], + ]; + } +} diff --git a/tests/Fixtures/Linter/UndeclaredInputController.php b/tests/Fixtures/Linter/UndeclaredInputController.php new file mode 100644 index 0000000..0112a8f --- /dev/null +++ b/tests/Fixtures/Linter/UndeclaredInputController.php @@ -0,0 +1,128 @@ + */ + public function rules(): array + { + return ['name' => 'required']; + } +} + +class UndeclaredInputController extends ApiController +{ + /** + * Validates a literal, declares nothing + */ + public function validatesLiteral(Request $request): JsonResponse + { + $request->validate(['name' => 'required']); + + return $this->success(); + } + + /** + * Validates rules assembled at runtime, declares nothing + */ + public function validatesRuntime(Request $request): JsonResponse + { + $request->validate($this->rules()); + + return $this->success(); + } + + /** + * Builds a validator through the facade + */ + public function validatorFacade(Request $request): JsonResponse + { + Validator::make($request->all(), ['name' => 'required'])->validate(); + + return $this->success(); + } + + /** + * Builds a validator through the helper + */ + public function validatorHelper(Request $request): JsonResponse + { + \validator($request->all(), ['name' => 'required'])->validate(); + + return $this->success(); + } + + /** + * Validates through a form request + */ + public function formRequest(UndeclaredFormRequest $request): JsonResponse + { + return $this->success(); + } + + /** + * Validates and says so + * + * @input string $name Name + */ + public function declared(Request $request): JsonResponse + { + $request->validate(['name' => 'required']); + + return $this->success(); + } + + /** + * Validates and describes the fields at runtime + * + * @input [dynamicFields] + */ + public function declaredDynamically(Request $request): JsonResponse + { + $request->validate($this->rules()); + + return $this->success(); + } + + /** + * Takes nothing + */ + public function takesNothing(): JsonResponse + { + return $this->success(); + } + + /** + * Takes nothing either + */ + public function onlyMentionsInComment(): JsonResponse + { + // Nothing here calls $request->validate([...]) — it only says so. + return $this->success(); + } + + /** + * @return string[] + */ + public function dynamicFields(): array + { + return ['string $name Name']; + } + + /** + * @return array + */ + private function rules(): array + { + return ['name' => 'required']; + } +} diff --git a/tests/Fixtures/OpenApi/ContextApi.php b/tests/Fixtures/OpenApi/ContextApi.php new file mode 100644 index 0000000..cc4fd99 --- /dev/null +++ b/tests/Fixtures/OpenApi/ContextApi.php @@ -0,0 +1,38 @@ + [ + 'controller' => ContextController::class, + 'actions' => [ + 'create' => ['method' => ['post']], + 'update' => ['method' => ['post']], + 'search' => ['method' => ['get']], + 'show' => ['method' => ['get']], + 'legacy' => ['method' => ['post']], + 'scalars' => ['method' => ['post'], 'flavour' => 'spicy'], + ], + ]; + + return [ + 'controllers' => [ + 'users' => $entity(), + 'posts' => $entity(), + ], + ]; + } +} diff --git a/tests/Fixtures/OpenApi/ContextController.php b/tests/Fixtures/OpenApi/ContextController.php new file mode 100644 index 0000000..9fdeee4 --- /dev/null +++ b/tests/Fixtures/OpenApi/ContextController.php @@ -0,0 +1,161 @@ +>> */ + public const FIELDS = [ + 'users' => [ + 'email' => ['type' => 'string', 'format' => 'email', 'maxLength' => 255], + 'age' => ['type' => 'integer', 'minimum' => 0, 'nullable' => true], + ], + 'posts' => [ + 'title' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'published']], + ], + ]; + + /** + * Create an entity + * + * @input [entityFields] + * + * @output [entityOutput] + */ + public function create(): JsonResponse + { + return $this->success(); + } + + /** + * Update an entity + * + * @input integer $id Identifier + * @input [entityFields] + */ + public function update(): JsonResponse + { + return $this->success(); + } + + /** + * Search entities + * + * @input integer ?$page Page + * @input [entityFields] + */ + public function search(): JsonResponse + { + return $this->success(); + } + + /** + * Show an entity + * + * @input integer $id Identifier + * + * @output integer $id Identifier + * @output [entityOutputLines] + */ + public function show(): JsonResponse + { + return $this->success(); + } + + /** + * The historical form: no context, a list of lines + * + * @input [legacyLines] + */ + public function legacy(): JsonResponse + { + return $this->success(); + } + + /** + * Scalars by name rather than the object + * + * @input [scalarLines] + */ + public function scalars(): JsonResponse + { + return $this->success(); + } + + /** + * @return array + */ + public function entityFields(OperationContext $context): array + { + $required = $context->actionKey === 'create' + ? array_keys(array_filter(self::FIELDS[$context->controllerKey], static fn (array $f): bool => empty($f['nullable']))) + : []; + + return array_filter([ + 'type' => 'object', + 'properties' => self::FIELDS[$context->controllerKey], + 'required' => $required, + ]); + } + + /** + * @return array + */ + public function entityOutput(OperationContext $context): array + { + return [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer']] + self::FIELDS[$context->controllerKey], + 'required' => ['id'], + 'x-tag' => $context->tag, + ]; + } + + /** + * @return string[] + */ + public function entityOutputLines(string $controllerKey): array + { + return array_map( + static fn (string $name): string => "string \${$name} From {$controllerKey}", + array_keys(self::FIELDS[$controllerKey]) + ); + } + + /** + * @return string[] + */ + public function legacyLines(): array + { + return ['string $name Name', 'integer ?$count Count']; + } + + /** + * @return string[] + */ + public function scalarLines( + string $version, + string $controllerKey, + string $actionKey, + string $httpMethod, + OperationContext $operation, + string $untouched = 'default' + ): array { + $flavour = (string) ($operation->actionOptions['flavour'] ?? 'none'); + + return [ + "string \$marker {$version}|{$controllerKey}|{$actionKey}|{$httpMethod}|{$flavour}|{$untouched}|{$operation->controllerMethod}", + ]; + } +} diff --git a/tests/Fixtures/OpenApi/NestedApi.php b/tests/Fixtures/OpenApi/NestedApi.php new file mode 100644 index 0000000..903e52d --- /dev/null +++ b/tests/Fixtures/OpenApi/NestedApi.php @@ -0,0 +1,29 @@ + [ + 'nested' => [ + 'controller' => NestedController::class, + 'actions' => [ + 'save' => ['method' => ['post']], + 'show' => ['method' => ['get']], + ], + ], + ], + ]; + } +} diff --git a/tests/Fixtures/OpenApi/NestedController.php b/tests/Fixtures/OpenApi/NestedController.php new file mode 100644 index 0000000..395ad80 --- /dev/null +++ b/tests/Fixtures/OpenApi/NestedController.php @@ -0,0 +1,40 @@ +success(); + } + + /** + * Show a record + * + * @output integer $id Identifier + * @output string ?$note Note + * @output object $owner Owner + * @output string $owner.name Owner name + * @output string ?$owner.email Owner email + */ + public function show(): JsonResponse + { + return $this->success(); + } +} diff --git a/tests/Unit/Traits/NestedNotationTest.php b/tests/Unit/Traits/NestedNotationTest.php new file mode 100644 index 0000000..e74c01a --- /dev/null +++ b/tests/Unit/Traits/NestedNotationTest.php @@ -0,0 +1,44 @@ +toBe('array'); + expect($widgets['description'])->toBe('Widgets, declared after their fields'); + expect(array_keys($widgets['items']['properties']))->toBe(['slug', 'size']); +}); + +it('marks required fields of nested objects', function () { + $schema = nestedSaveSchema(); + + expect($schema['properties']['widgets']['items']['required'])->toBe(['slug']); + expect($schema['required'])->toBe(['key', 'widgets']); +}); + +it('describes the element of a list of scalars', function () { + $abilities = nestedSaveSchema()['properties']['abilities']; + + expect($abilities['type'])->toBe('array'); + expect($abilities['items'])->toBe(['type' => 'string']); + expect($abilities['description'])->toBe('Abilities'); +}); + +it('keeps the flat siblings of nested output fields', function () { + $schema = NestedApi::getOpenApiConfig('v1')['paths']['/v1/nested/show']['get'] + ['responses']['200']['content']['application/json']['schema']; + + expect(array_keys($schema['properties']))->toBe(['id', 'note', 'owner']); + expect($schema['properties']['id']['type'])->toBe('integer'); + expect($schema['properties']['owner']['required'])->toBe(['name']); + expect($schema['required'])->toBe(['id', 'owner']); +}); diff --git a/tests/Unit/Traits/OperationContextSchemaTest.php b/tests/Unit/Traits/OperationContextSchemaTest.php new file mode 100644 index 0000000..15440d2 --- /dev/null +++ b/tests/Unit/Traits/OperationContextSchemaTest.php @@ -0,0 +1,97 @@ +toBe(['email', 'age']); + expect(array_keys($posts['properties']))->toBe(['title', 'status']); +}); + +it('passes a returned schema through, constraints included', function () { + $paths = contextSpec()['paths']; + + $users = jsonBody($paths['/v1/users/create']['post']); + expect($users['type'])->toBe('object'); + expect($users['properties']['email'])->toBe(['type' => 'string', 'format' => 'email', 'maxLength' => 255]); + expect($users['properties']['age']['minimum'])->toBe(0); + expect($users['required'])->toBe(['email']); + + $posts = jsonBody($paths['/v1/posts/create']['post']); + expect($posts['properties']['status']['enum'])->toBe(['draft', 'published']); +}); + +it('tells the method which action it describes', function () { + $paths = contextSpec()['paths']; + + // The fixture marks fields required on create only. + expect(jsonBody($paths['/v1/users/create']['post']))->toHaveKey('required'); + expect(jsonBody($paths['/v1/users/update']['post'])['required'] ?? [])->toBe(['id']); +}); + +it('merges docblock lines with the returned schema', function () { + $schema = jsonBody(contextSpec()['paths']['/v1/posts/update']['post']); + + expect(array_keys($schema['properties']))->toBe(['id', 'title', 'status']); + expect($schema['properties']['id']['type'])->toBe('integer'); + expect($schema['properties']['id']['description'])->toBe('Identifier'); + expect($schema['required'])->toBe(['id']); +}); + +it('turns a returned schema into query parameters for GET', function () { + $operation = contextSpec()['paths']['/v1/posts/search']['get']; + + expect($operation)->not->toHaveKey('requestBody'); + $parameters = collect($operation['parameters'])->keyBy('name'); + + expect($parameters->keys()->all())->toBe(['page', 'title', 'status']); + expect($parameters['page']['in'])->toBe('query'); + expect($parameters['page']['required'])->toBeFalse(); + expect($parameters['status']['schema'])->toBe(['type' => 'string', 'enum' => ['draft', 'published']]); +}); + +it('hands the context to @output [method] as well', function () { + $schema = contextSpec()['paths']['/v1/users/create']['post']['responses']['200']['content']['application/json']['schema']; + + expect(array_keys($schema['properties']))->toBe(['id', 'email', 'age']); + expect($schema['required'])->toBe(['id']); + expect($schema['x-tag'])->toBe('output'); +}); + +it('mixes @output lines with lines a method returned for the key', function () { + $schema = contextSpec()['paths']['/v1/posts/show']['get']['responses']['200']['content']['application/json']['schema']; + + expect(array_keys($schema['properties']))->toBe(['id', 'title', 'status']); + expect($schema['properties']['title']['description'])->toBe('From posts'); +}); + +it('keeps calling a method that asks for no context exactly as before', function () { + $body = contextSpec()['paths']['/v1/users/legacy']['post']['requestBody']; + $schema = $body['content']['application/x-www-form-urlencoded']['schema']; + + expect(array_keys($schema['properties']))->toBe(['name', 'count']); + expect($schema['required'])->toBe(['name']); +}); + +it('binds the scalars by name and the object by type, leaving other parameters alone', function () { + $body = contextSpec()['paths']['/v1/posts/scalars']['post']['requestBody']; + $marker = $body['content']['application/x-www-form-urlencoded']['schema']['properties']['marker']; + + expect($marker['description'])->toBe('v1|posts|scalars|post|spicy|default|scalars'); +}); From a995a8b7161af1a7698333d2c94ab65a554f8417 Mon Sep 17 00:00:00 2001 From: Skripchenko Denis Date: Fri, 2 Oct 2026 00:56:54 +0300 Subject: [PATCH 2/2] release: 5.11.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 326316c..096f359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [5.11.0] — 2026-10-02 ### Added