From 7aa1eb50b3a3e52a15a9d60ac08a0e0591feebb3 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 13:45:36 -0400 Subject: [PATCH 01/59] fix(differ): compare a webhook's required flag only while both sides carry a payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A webhook payload appearing or disappearing was reported as `payload added; body optional → required` (or `payload removed; body required → optional`), though docs/cli.md and the diffWebhookBody JSDoc have said `payload added` / `payload removed` alone since #340. diffWebhookBody compared requestBodyRequired whether or not both sides carried a payload, while diffOperations gates the same comparison on bothCarryBody. The flag is now compared only while both sides carry a payload. Only `details` changes; the change stays breaking. Closes #404 --- docs/architecture/ir-types.md | 2 +- src/differ/types.ts | 6 +-- src/differ/webhooks.ts | 5 +- test/differ/request-body-encoding.test.ts | 4 +- test/differ/webhooks.test.ts | 58 +++++++++++++++++++++++ 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index e80ca498..d2594aed 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -670,7 +670,7 @@ interface WebhookResponse { `summary`, `description` and `externalDocs` are read as an operation's are: `summary` and `description` from the operation or, for each the operation declares none with content for, from its path item — the entry itself, or the `components/pathItems` item its `$ref` leads to (text written beside the `$ref` is not read — a Path Item's `$ref` is not a Reference Object, see Reference Objects — and is reported with the other keys beside it) — combined into `description` by the operation's rule; `externalDocs` from the operation alone, since a Path Item Object has none. Everything else is read from the receiver's side of the wire. `headerParams`, `queryParams` and `cookieParams` are the values the request carries, typed exactly as an operation's parameters are (`components/parameters` refs resolved, a path-item-level parameter overridden by an operation-level one of the same identity — location and name, a header's name compared case-insensitively, as `parameterKey` decides); a parameter with an inline object schema, or an array of them, is materialized as a model under the name the parameter references (`XMeta`, `Filter`; a header's name lower-cased first, so `X-RateLimit-Meta` gives `XRatelimitMeta` whatever its case), so no parameter type dangles — and when a name it would mint is already taken by the payload or an earlier parameter of the same webhook (a query parameter named `payload` would mint the body's `Payload`, one named `payload_pet` the body's nested `PayloadPet`, a header `X-Meta` and a query `x_meta` would both mint `XMeta`), the parameter is typed and minted under its location-qualified name instead (`QueryPayload`, then `QueryPayload2`, …), the first that takes nothing already minted — and the parameter always references what its mint produced, whether or not the property moved, so a variant renamed past a decided enum under the parameter's own name (`PetAdoptedFilterCircle`) is what its union and discriminator mapping point at; when only context-free names clash (a titled variant another parameter also mints), the earliest trial is kept under the parameter's own name and the clash is left to the model merge; a `path` parameter has nowhere to go and is dropped with a warning. `requestBody` is the payload, read like an operation's body (`components/requestBodies` refs resolved, the same media-type pick by essence, a schemaless binary or text body typed as a primitive), with `requestBodyEncoding` and `requestBodyContentType` beside it as on an operation, and `requestBodyDescription` read as `Operation.requestBodyDescription` is — the Request Body Object's own `description`, else the `description` written on the picked entry's schema itself, a `$ref` not followed (see Request Body Description) — while the model an inline object payload is materialized as carries that schema's `description` itself; `requestBodyContentTypes` and `requestBodyContents` carry every media type the payload declares, read as `Operation.requestBodyContents` is (see Request Body Content Types), with an inline object under a secondary media type materialized as `Payload` (`PetAdoptedTextCsvPayload`, the fragment from the media type's essence, plus its parameters when another spelling of the same essence sits beside it) and named by the stem rules below, so it takes part in the stem's collision check like `Payload` itself; within one payload, a secondary entry whose names are already minted by another entry shares that model when the shapes are identical and otherwise mints under the next free property (`Payload2`, …), so two case-only spellings with different fields never point at one model. `responses` lists the status codes the sender expects the receiver to return, one entry per status code key (three digits, 100–599, as the shared `classifyResponseKey` decides) in document order with the document's description, following a `components/responses` `$ref` for it (chains included; a description written beside the `$ref` wins in an OAS 3.1 document and is ignored in a 3.0 one, as beside every Reference Object; a ref that leads nowhere keeps the code without a description, with a warning); `default`, range keys such as `2XX` and `x-` keys are not carried, a key that is none of those (`error`, `6XX`, `600`) is warned about and ignored exactly as on an operation, and body schemas on these responses are not carried either, since a webhook response is an acknowledgement. `security` mirrors the document the way `Operation.security` does: `undefined` inherits `ApiSpec.security`, `[]` means none. -One `webhooks` key may declare several methods, so `name` alone does not identify a webhook; `name` + `httpMethod` does, and that pair is what `oagen diff` keys its `webhook-added` / `webhook-removed` / `webhook-modified` changes on. A `webhook-modified` change reads from the receiver's side: a payload type, encoding or `requestBodyRequired` change sets `requestBodyChanged` with `details` saying which (type and required → optional are breaking; encoding is breaking unless the representation the sender now uses is one the old document declared with the same shape; optional → required is additive) and, for an encoding change — compared only while both documents declare a payload, as on an operation, so a payload appearing or disappearing carries neither field — `oldRequestBodyEncoding`/`newRequestBodyEncoding` beside the flag as an `operation-modified` change carries them, and so does a change to the media types the sender may use (`requestBodyContentTypes`, compared by essence, both sides declaring a payload), with `requestBodyContentTypeChange` beside it listing them (a media type the sender can newly use, or whose body changed type, is breaking — the handler must decode it; one it can no longer use is additive), a change to the status codes the sender accepts sets `responsesChanged` (a code no longer accepted is breaking, since a handler that keeps returning it now fails the delivery; a newly accepted one is additive; descriptions are not compared), and parameters are matched by location and name — a header and a query parameter may share one, and a header's name is compared case-insensitively, so one that only changed case is a `param-name-changed` entry (additive) rather than a removal beside an addition — with every `ParamChange` carrying its `paramLocation` (added is additive, removed breaking, optional → required additive, required → optional breaking). The webhook's own `deprecated` flipping sets `deprecatedChanged`, with `oldDeprecated`/`newDeprecated` beside it, and a parameter's is a `param-deprecated-changed` entry in `paramChanges`; both are additive in either direction and mirrored into `behaviorChanges` as `webhook` and `webhook-parameter` nodes, exactly as an operation's are (see [Deprecation](#deprecation)). The security the request carries — `webhook.security ?? spec.security` on each side, a webhook that inherits on both sides being left to the document-level `security-changed` change — sets `securityChanged` with `oldSecurity`/`newSecurity` beside it, read from the receiver's side: a credential the sender stops presenting, or an alternative it may now present that the handler never checked for, is breaking; a credential it starts carrying beside the old ones, or an alternative withdrawn, is additive; the change is mirrored into `behaviorChanges` as a `webhook` node (see the security paragraph under [Service & Operation](#service--operation)). The webhook's `tags` differing, in document order, sets `tagsChanged` with `oldTags`/`newTags`, additive always. An entry that is a `$ref` into `components/pathItems` is resolved, chains included, by `resolvePathItemRef` in `src/parser/path-item-refs.ts`, the function a `paths` entry is resolved by too (see [Path Item references](#path-item-references)); fields written beside the `$ref` are not merged in, since OpenAPI leaves a field present on both sides undefined, and are reported once per entry, naming the key and the fields (`Webhook "petAdopted" has fields beside its $ref ("summary") that are not read: the referenced path item is taken whole.`); a ref anywhere else, a cycle, or an entry that is not an object is warned about and skipped (a ref the document cannot resolve at all fails the bundle before parsing, as any dangling ref does). A value under `webhooks` that is not an object is ignored with a warning; a key starting with `x-` is a specification extension, not a webhook, and is stepped over without a warning whatever its value, as under `paths` (see [Path Item references](#path-item-references)). +One `webhooks` key may declare several methods, so `name` alone does not identify a webhook; `name` + `httpMethod` does, and that pair is what `oagen diff` keys its `webhook-added` / `webhook-removed` / `webhook-modified` changes on. A `webhook-modified` change reads from the receiver's side: a payload type, encoding or `requestBodyRequired` change sets `requestBodyChanged` with `details` saying which (type and required → optional are breaking; encoding is breaking unless the representation the sender now uses is one the old document declared with the same shape; optional → required is additive; the flag is compared, as the encoding is, only while both documents declare a payload, so a payload appearing or disappearing is `payload added` / `payload removed` alone) and, for an encoding change — compared only while both documents declare a payload, as on an operation, so a payload appearing or disappearing carries neither field — `oldRequestBodyEncoding`/`newRequestBodyEncoding` beside the flag as an `operation-modified` change carries them, and so does a change to the media types the sender may use (`requestBodyContentTypes`, compared by essence, both sides declaring a payload), with `requestBodyContentTypeChange` beside it listing them (a media type the sender can newly use, or whose body changed type, is breaking — the handler must decode it; one it can no longer use is additive), a change to the status codes the sender accepts sets `responsesChanged` (a code no longer accepted is breaking, since a handler that keeps returning it now fails the delivery; a newly accepted one is additive; descriptions are not compared), and parameters are matched by location and name — a header and a query parameter may share one, and a header's name is compared case-insensitively, so one that only changed case is a `param-name-changed` entry (additive) rather than a removal beside an addition — with every `ParamChange` carrying its `paramLocation` (added is additive, removed breaking, optional → required additive, required → optional breaking). The webhook's own `deprecated` flipping sets `deprecatedChanged`, with `oldDeprecated`/`newDeprecated` beside it, and a parameter's is a `param-deprecated-changed` entry in `paramChanges`; both are additive in either direction and mirrored into `behaviorChanges` as `webhook` and `webhook-parameter` nodes, exactly as an operation's are (see [Deprecation](#deprecation)). The security the request carries — `webhook.security ?? spec.security` on each side, a webhook that inherits on both sides being left to the document-level `security-changed` change — sets `securityChanged` with `oldSecurity`/`newSecurity` beside it, read from the receiver's side: a credential the sender stops presenting, or an alternative it may now present that the handler never checked for, is breaking; a credential it starts carrying beside the old ones, or an alternative withdrawn, is additive; the change is mirrored into `behaviorChanges` as a `webhook` node (see the security paragraph under [Service & Operation](#service--operation)). The webhook's `tags` differing, in document order, sets `tagsChanged` with `oldTags`/`newTags`, additive always. An entry that is a `$ref` into `components/pathItems` is resolved, chains included, by `resolvePathItemRef` in `src/parser/path-item-refs.ts`, the function a `paths` entry is resolved by too (see [Path Item references](#path-item-references)); fields written beside the `$ref` are not merged in, since OpenAPI leaves a field present on both sides undefined, and are reported once per entry, naming the key and the fields (`Webhook "petAdopted" has fields beside its $ref ("summary") that are not read: the referenced path item is taken whole.`); a ref anywhere else, a cycle, or an entry that is not an object is warned about and skipped (a ref the document cannot resolve at all fails the bundle before parsing, as any dangling ref does). A value under `webhooks` that is not an object is ignored with a warning; a key starting with `x-` is a specification extension, not a webhook, and is stepped over without a warning whatever its value, as under `paths` (see [Path Item references](#path-item-references)). **Payload models.** An inline payload schema is materialized the way a component field's would be: the parser types it as the `payload` property of a synthetic object named by the webhook's _stem_, so the reference and the model are named by the same code and always agree. An inline object becomes `Payload`, with nested objects qualified under it (`PayloadPet`) and inline enums promoted as `Payload`; a `oneOf`/`anyOf` of inline objects stays a union — discriminator intact, because an event handler switches on it — with its variants named as any field-level union's are (`Payload`, `Payload`, …); a `$ref` stays a reference; a payload that is itself an inline enum is promoted as `Payload`. Nothing is flattened. Inline parameter enums are qualified with the stem the way a service name qualifies an operation's (`PetAdoptedXEventType`). diff --git a/src/differ/types.ts b/src/differ/types.ts index dac4b5c4..91f8087e 100644 --- a/src/differ/types.ts +++ b/src/differ/types.ts @@ -804,9 +804,9 @@ export interface WebhookModified { httpMethod: string; paramChanges: ParamChange[]; /** - * True when the payload's type, encoding or `requestBodyRequired` changed, - * or — both specs declaring a payload — the media types the sender may use - * did; `details` says which. + * True when the payload's type changed, or — both specs declaring a + * payload — its encoding, its `requestBodyRequired` or the media types the + * sender may use did; `details` says which. */ requestBodyChanged: boolean; /** diff --git a/src/differ/webhooks.ts b/src/differ/webhooks.ts index c254bd5d..65d72baa 100644 --- a/src/differ/webhooks.ts +++ b/src/differ/webhooks.ts @@ -137,6 +137,9 @@ export function diffWebhooks( * `details` string so the change can report both sides. * - required → optional: breaking — a handler that assumed a body may get none. * - optional → required: additive — the body is now always there. + * The flag is compared only while both sides carry a payload, as on an + * operation: the parser sets it beside the payload, so one appearing or + * disappearing takes its flag with it too. * - media types changed, both sides declaring a payload: one the sender can * newly use, or whose body changed type, is breaking — the handler must * decode it; one it can no longer use, or one that only changed a @@ -177,7 +180,7 @@ function diffWebhookBody( const wasRequired = oldWebhook.requestBodyRequired === true; const isRequired = newWebhook.requestBodyRequired === true; - if (wasRequired !== isRequired) { + if (oldBody && newBody && wasRequired !== isRequired) { changes.push(isRequired ? 'body optional → required' : 'body required → optional'); if (!isRequired) breaking = true; } diff --git a/test/differ/request-body-encoding.test.ts b/test/differ/request-body-encoding.test.ts index 14f6b6d0..eb663925 100644 --- a/test/differ/request-body-encoding.test.ts +++ b/test/differ/request-body-encoding.test.ts @@ -294,13 +294,13 @@ describe('diffWebhooks — request body encoding (issue #290)', () => { const [removed] = diffWebhooks([petAdopted()], [none]); expect(removed).toMatchObject({ requestBodyChanged: true, - details: 'payload removed; body required → optional', + details: 'payload removed', classification: 'breaking', }); expect(removed).not.toHaveProperty('oldRequestBodyEncoding'); expect(removed).not.toHaveProperty('newRequestBodyEncoding'); const [added] = diffWebhooks([none], [petAdopted()]); - expect(added).toMatchObject({ details: 'payload added; body optional → required', classification: 'breaking' }); + expect(added).toMatchObject({ details: 'payload added', classification: 'breaking' }); expect(added).not.toHaveProperty('oldRequestBodyEncoding'); expect(added).not.toHaveProperty('newRequestBodyEncoding'); }); diff --git a/test/differ/webhooks.test.ts b/test/differ/webhooks.test.ts index 986c1c03..6eb1e0df 100644 --- a/test/differ/webhooks.test.ts +++ b/test/differ/webhooks.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { diffSpecs } from '../../src/differ/diff.js'; import { buildDirectionIndex } from '../../src/differ/direction.js'; +import { parseSpec } from '../../src/parser/parse.js'; import type { ApiSpec, Model, Parameter, Webhook } from '../../src/ir/types.js'; import { defaultSdkBehavior } from '../../src/ir/sdk-behavior.js'; @@ -263,6 +267,60 @@ describe('diffSpecs — webhooks', () => { }); }); +describe('parseSpec to diffSpecs — a required payload appearing or disappearing (issue #404 repro)', () => { + /** The issue's `old.json` without `requestBody`, or `new.json` with it: `petAdopted` responding `200`. */ + const document = (requestBody?: unknown) => ({ + openapi: '3.1.0', + info: { title: 'Pets', version: '1' }, + webhooks: { + petAdopted: { post: { ...(requestBody ? { requestBody } : {}), responses: { '200': { description: 'ok' } } } }, + }, + components: { + schemas: { Pet: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } } }, + }, + }); + const requiredPet = { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } }, + }; + + async function parseDocuments(oldDoc: unknown, newDoc: unknown): Promise<[ApiSpec, ApiSpec]> { + const dir = mkdtempSync(join(tmpdir(), 'oagen-404-')); + try { + writeFileSync(join(dir, 'old.json'), JSON.stringify(oldDoc)); + writeFileSync(join(dir, 'new.json'), JSON.stringify(newDoc)); + return [await parseSpec(join(dir, 'old.json')), await parseSpec(join(dir, 'new.json'))]; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it('says `payload added` / `payload removed` alone, still breaking, as an operation reports no required flip beside a body', async () => { + const [before, after] = await parseDocuments(document(), document(requiredPet)); + expect(after.webhooks?.[0]).toMatchObject({ requestBodyRequired: true, requestBodyEncoding: 'json' }); + for (const [details, oldSpec, newSpec] of [ + ['payload added', before, after], + ['payload removed', after, before], + ] as const) { + expect(diffSpecs(oldSpec, newSpec).changes.filter((c) => c.kind === 'webhook-modified')).toEqual([ + { + kind: 'webhook-modified', + name: 'petAdopted', + httpMethod: 'post', + paramChanges: [], + requestBodyChanged: true, + responsesChanged: false, + deprecatedChanged: false, + securityChanged: false, + tagsChanged: false, + details, + classification: 'breaking', + }, + ]); + } + }); +}); + describe('buildDirectionIndex — webhooks', () => { it('treats a model reached only through a webhook payload or header as response-facing', () => { const meta: Model = { name: 'DeliveryMeta', fields: [] }; From b5c402732c3d1de9c385648de317b4647e2073cb Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 16:58:51 -0400 Subject: [PATCH 02/59] docs(differ): say a webhook's required flag is not compared without a payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diffWebhookBody JSDoc said the parser sets requestBodyRequired beside the payload, so a payload appearing or disappearing takes its flag with it. buildWebhook reads the flag off the Request Body Object whether or not it reads a payload from it, so a required body under application/xml alone, application/json without a schema, or `content: {}` carries `requestBodyRequired: true` and no requestBody. Since the parent commit that flag is not compared either. The JSDoc now says so, and a test pins the review's documents through parseSpec and diffSpecs: such a body appearing, disappearing or flipping its flag reports nothing, as an operation declaring the same body does, and the webhook keeps the flag beside requestBodyContentTypes. Review finding 1 on #453 (and its inline thread on src/differ/webhooks.ts) offered this or setting the flag only beside a typed payload, as buildOperation does on this base. The parser is left as it is: that rule would drop the flag from a payload carried in requestBodyContents alone, which #474 (#388) keeps on an operation "as petAdopted does", and #474's review asks instead for the webhook flag to be read only when the payload declares a media type, a change left to #474. The exit code moves for those documents, not only details, so the PR title now carries `!`, as #451, #452 and #454 do when an oagen diff classification flips. BREAKING CHANGE: `oagen diff` no longer compares a webhook's `requestBodyRequired` unless both documents carry a `requestBody`. A required body the parser reads no payload from (one under `application/xml` alone, `application/json` without a `schema`, or `content: {}`) appearing, disappearing or flipping its flag now reports nothing and exits 0, where it reported `body required → optional` (breaking, exit 2) or `body optional → required` (additive, exit 1). --- src/differ/webhooks.ts | 8 +++-- test/differ/webhooks.test.ts | 67 +++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/differ/webhooks.ts b/src/differ/webhooks.ts index 65d72baa..cc8d09f8 100644 --- a/src/differ/webhooks.ts +++ b/src/differ/webhooks.ts @@ -137,9 +137,11 @@ export function diffWebhooks( * `details` string so the change can report both sides. * - required → optional: breaking — a handler that assumed a body may get none. * - optional → required: additive — the body is now always there. - * The flag is compared only while both sides carry a payload, as on an - * operation: the parser sets it beside the payload, so one appearing or - * disappearing takes its flag with it too. + * The flag is compared only while both sides carry a payload + * (`requestBody`), as on an operation, so one appearing or disappearing + * takes its flag with it. The parser also sets the flag on a body it reads + * no payload from (one under `application/xml` alone, say); that flag is + * not compared either. * - media types changed, both sides declaring a payload: one the sender can * newly use, or whose body changed type, is breaking — the handler must * decode it; one it can no longer use, or one that only changed a diff --git a/test/differ/webhooks.test.ts b/test/differ/webhooks.test.ts index 6eb1e0df..fe9b90ae 100644 --- a/test/differ/webhooks.test.ts +++ b/test/differ/webhooks.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -321,6 +321,71 @@ describe('parseSpec to diffSpecs — a required payload appearing or disappearin }); }); +describe('parseSpec to diffSpecs — a required flag on a body the parser reads no payload from (#404 review)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** `createPet` and `petAdopted` declaring the same `requestBody`, or none, both responding `200`. */ + const document = (requestBody?: unknown) => { + const body = requestBody ? { requestBody } : {}; + return { + openapi: '3.1.0', + info: { title: 'Pets', version: '1' }, + paths: { '/pets': { post: { operationId: 'createPet', ...body, responses: { '200': { description: 'ok' } } } } }, + webhooks: { petAdopted: { post: { ...body, responses: { '200': { description: 'ok' } } } } }, + components: { + schemas: { Pet: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } } }, + }, + }; + }; + /** The review's bodies, none of which the parser reads a payload from. */ + const bodies: Record unknown> = { + 'application/xml': (required) => ({ + required, + content: { 'application/xml': { schema: { $ref: '#/components/schemas/Pet' } } }, + }), + 'content: {}': (required) => ({ required, content: {} }), + 'application/json without a schema': (required) => ({ required, content: { 'application/json': {} } }), + }; + + async function parseDocument(doc: unknown): Promise { + const dir = mkdtempSync(join(tmpdir(), 'oagen-404-')); + try { + writeFileSync(join(dir, 'spec.json'), JSON.stringify(doc)); + return await parseSpec(join(dir, 'spec.json')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it('keeps the flag on a payload carried in requestBodyContents alone, without a requestBody', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { webhooks } = await parseDocument(document(bodies['application/xml'](true))); + expect(webhooks?.[0]).not.toHaveProperty('requestBody'); + expect(webhooks?.[0]).toMatchObject({ requestBodyRequired: true, requestBodyContentTypes: ['application/xml'] }); + }); + + it('reports nothing when such a body appears, disappears or flips its flag, as on an operation', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const none = await parseDocument(document()); + for (const [label, body] of Object.entries(bodies)) { + const required = await parseDocument(document(body(true))); + const optional = await parseDocument(document(body(false))); + expect(required.webhooks?.[0], label).not.toHaveProperty('requestBody'); + for (const [oldSpec, newSpec] of [ + [required, none], + [none, required], + [required, optional], + [optional, required], + ]) { + // Neither the webhook nor the operation declaring the same body is modified: `oagen diff` exits 0. + expect(diffSpecs(oldSpec, newSpec).changes, label).toEqual([]); + } + } + }); +}); + describe('buildDirectionIndex — webhooks', () => { it('treats a model reached only through a webhook payload or header as response-facing', () => { const meta: Model = { name: 'DeliveryMeta', fields: [] }; From 7b241f606775ebfd653e3dfda09573b7c37c8cfb Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 14:01:54 -0400 Subject: [PATCH 03/59] fix(differ)!: read a moved security default only from a node that inherits it in both documents `diffDocumentSecurity` read the caller's side when an operation declared no `security` in either document, and the receiver's side when a webhook did. An operation or webhook that declares its own list in one document and inherits in the other is already compared on its own node by `securityShift`, on what it requires in each document. Counting it again for the document made the document change breaking even when no request's security changed: with `listPets` requiring `apiKey` throughout while the default moved from `apiKey` to `bearer` (or back), `oagen diff` exited 2. A side is now read only when an operation or webhook inherits the default in both documents, paired across them as its own change is: an operation by its service and name, a webhook by its method and name. A node that only one document has is reported as added or removed, so it no longer reads the default either. BREAKING CHANGE: `oagen diff` classifies a document-level `security-changed` as additive (was breaking) when no operation or webhook inherits the default in both documents, so the issue's documents now exit 1 (was 2). A node that inherits it in only one document is still reported on its own `operation-modified` or `webhook-modified` change, or as added or removed. --- docs/architecture/ir-types.md | 2 +- docs/cli.md | 2 +- src/differ/diff.ts | 37 ++++-- src/differ/types.ts | 14 ++- test/differ/security-tags.test.ts | 105 +++++++++++++++--- .../security-moved-default-after.json | 16 +++ .../security-moved-default-before.json | 16 +++ 7 files changed, 160 insertions(+), 32 deletions(-) create mode 100644 test/fixtures/security-moved-default-after.json create mode 100644 test/fixtures/security-moved-default-before.json diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index d2594aed..6269b75a 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -426,7 +426,7 @@ type SecurityRequirementSet = SecurityRequirement[]; To resolve what an operation accepts, take `op.security ?? spec.security`; a result of `undefined` means the document declares no requirements at all. The same list shape applies to `ApiSpec.security`. -The differ compares security on what a request actually carries, resolved the same way on each side (`[]` when neither the node nor its document declares a list), and reports a difference as `securityChanged: true` on the `operation-modified` or `webhook-modified` change with `oldSecurity`/`newSecurity` beside it, present exactly when the flag is. A node that declares no list on either side inherits twice and is not reported: a moved document default is the document-level `security-changed` change's to report, so a document that moves to what an operation already declared (its override vanishing with it) is no change to that operation. Lists are compared as sets of alternatives, each a set of schemes with scopes as sets, so order and repetition do not count, and `security: []`, a lone anonymous alternative (`[{}]`) and nothing declared anywhere are the same thing: no authentication. The classification is read from the node's side of the wire, by one rule applied in each direction (`classifySecurityChange` in `src/differ/security.ts`): for an operation, a caller built for the old list must still satisfy the new one — breaking when some old alternative satisfies no new one (a scheme or scope now required, an alternative removed, an anonymous alternative withdrawn), additive when every old alternative still passes (an alternative added, a scheme or scope no longer needed, authentication made optional); for a webhook, the handler built for the old list must still find what it checked for in whatever the sender now presents — breaking when some new alternative carries no old one (a credential the sender stops presenting, an alternative it may now use that the old list never offered), additive when every new alternative still carries some old one (a credential the sender starts carrying beside the old ones, an alternative withdrawn). The document-level change is read from each side that inherits the default — the caller's when an operation in either document declares no `security` of its own, the receiver's when a webhook does — and is breaking if any such side breaks; when nothing inherits it, no request reads the default, so the change is additive. Every security change is mirrored into `behaviorChanges` as a `security-changed` entry naming the node (`document`, `operation` or `webhook`) with the two lists and the security change's own classification, beside the deprecation and parameter-default entries collected there. `tags` differing — compared in document order, an absent key as an empty list — is `tagsChanged: true` on the same changes with `oldTags`/`newTags` beside it, additive always and not mirrored into `behaviorChanges`. That flag can only ever carry an edit that leaves the operation in a service of the same name, because operations are matched by name within a service: the parser groups an operation by `tags[0]` (the first usable entry) and then the path-prefix split (see Service above), so a first-tag change that the split files back under the same path-named service is a `tagsChanged`, while one that moves the operation to a service of another name leaves nothing to match, and the diff reports the move as `operation-removed` plus `operation-added` across the two services, or `service-removed` plus `service-added` when a service goes or comes with it — breaking, as a moved method is. A webhook belongs to no service, so every edit to its `tags` is a `tagsChanged`. +The differ compares security on what a request actually carries, resolved the same way on each side (`[]` when neither the node nor its document declares a list), and reports a difference as `securityChanged: true` on the `operation-modified` or `webhook-modified` change with `oldSecurity`/`newSecurity` beside it, present exactly when the flag is. A node that declares no list on either side inherits twice and is not reported: a moved document default is the document-level `security-changed` change's to report, so a document that moves to what an operation already declared (its override vanishing with it) is no change to that operation. Lists are compared as sets of alternatives, each a set of schemes with scopes as sets, so order and repetition do not count, and `security: []`, a lone anonymous alternative (`[{}]`) and nothing declared anywhere are the same thing: no authentication. The classification is read from the node's side of the wire, by one rule applied in each direction (`classifySecurityChange` in `src/differ/security.ts`): for an operation, a caller built for the old list must still satisfy the new one — breaking when some old alternative satisfies no new one (a scheme or scope now required, an alternative removed, an anonymous alternative withdrawn), additive when every old alternative still passes (an alternative added, a scheme or scope no longer needed, authentication made optional); for a webhook, the handler built for the old list must still find what it checked for in whatever the sender now presents — breaking when some new alternative carries no old one (a credential the sender stops presenting, an alternative it may now use that the old list never offered), additive when every new alternative still carries some old one (a credential the sender starts carrying beside the old ones, an alternative withdrawn). The document-level change is read from each side that inherits the default in both documents — the caller's when an operation does, the receiver's when a webhook does, each matched across the two documents as its own change is (an operation by name within its service, a webhook by name and method) — and is breaking if any such side breaks. No other node reads both defaults: one that declares its own list on either side is compared on its own, as above, and one added or removed is reported as such. When nothing inherits the default in both documents, no request reads both defaults, so the change is additive. Every security change is mirrored into `behaviorChanges` as a `security-changed` entry naming the node (`document`, `operation` or `webhook`) with the two lists and the security change's own classification, beside the deprecation and parameter-default entries collected there. `tags` differing — compared in document order, an absent key as an empty list — is `tagsChanged: true` on the same changes with `oldTags`/`newTags` beside it, additive always and not mirrored into `behaviorChanges`. That flag can only ever carry an edit that leaves the operation in a service of the same name, because operations are matched by name within a service: the parser groups an operation by `tags[0]` (the first usable entry) and then the path-prefix split (see Service above), so a first-tag change that the split files back under the same path-named service is a `tagsChanged`, while one that moves the operation to a service of another name leaves nothing to match, and the diff reports the move as `operation-removed` plus `operation-added` across the two services, or `service-removed` plus `service-added` when a service goes or comes with it — breaking, as a moved method is. A webhook belongs to no service, so every edit to its `tags` is a `tagsChanged`. ```typescript interface SuccessResponse { diff --git a/docs/cli.md b/docs/cli.md index bc165eb1..2fd31a8c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -82,7 +82,7 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Documentation.** A `description` is not compared on any node — a model's, a field's, an enum's, an enum value's, an operation's, a parameter's, a response header's, a response's, a request body's — so a change to one, including a description appearing or disappearing, is not reported and the command exits 0 when nothing else changed: documentation is not shape, and no change carries a flag for it. A body comparison that reads a model or enum ref through to its shape (see Response media types) ignores documentation the same way. -**Security.** What a request must carry is compared on the document and on every operation and webhook. `ApiSpec.security` — the default every operation and webhook that declares none of its own inherits — differing between the two documents is one `security-changed` change, with `oldSecurity`/`newSecurity` beside it (`[]` when a document declares none; `security: []` and an undeclared list both mean no authentication). An operation or webhook that declares its own list on either side is compared on what it actually requires — its own list, else that document's — and reported with `securityChanged: true` on its `operation-modified` or `webhook-modified` change, with `oldSecurity`/`newSecurity` beside it; one that inherits on both sides is not reported again for a moved default. Lists are compared as sets of alternatives, each a set of schemes with scopes as sets, so order and repetition are not changes. The classification is read from the node's side of the wire. For an operation, a caller built for the old list must still satisfy the new one: breaking when some old alternative satisfies no new one (a scheme or scope now required, an alternative removed, an anonymous alternative withdrawn), additive otherwise (an alternative added, a scheme or scope no longer needed, authentication made optional). For a webhook, a handler built for the old list must still find what it checked for in whatever the sender now presents: breaking when some new alternative carries no old one (a credential the sender stops presenting, an alternative it may now use that the old list never offered), additive otherwise (a credential the sender starts carrying beside the old ones, an alternative withdrawn). The document-level change is read from each side that inherits the default — the caller's when an operation in either document declares no `security` of its own, the receiver's when a webhook does — and is breaking if any such side breaks; when nothing inherits it, no request reads the default, so the change is additive. A change whose only content is security is still reported, and a breaking change on the same node keeps the change breaking. Every security change is mirrored into `behaviorChanges` as a `security-changed` entry naming the node (`document`, `operation` or `webhook`), copying the two lists and carrying the security change's own classification, which stays additive when the owning change is breaking for another reason. +**Security.** What a request must carry is compared on the document and on every operation and webhook. `ApiSpec.security` — the default every operation and webhook that declares none of its own inherits — differing between the two documents is one `security-changed` change, with `oldSecurity`/`newSecurity` beside it (`[]` when a document declares none; `security: []` and an undeclared list both mean no authentication). An operation or webhook that declares its own list on either side is compared on what it actually requires — its own list, else that document's — and reported with `securityChanged: true` on its `operation-modified` or `webhook-modified` change, with `oldSecurity`/`newSecurity` beside it; one that inherits on both sides is not reported again for a moved default. Lists are compared as sets of alternatives, each a set of schemes with scopes as sets, so order and repetition are not changes. The classification is read from the node's side of the wire. For an operation, a caller built for the old list must still satisfy the new one: breaking when some old alternative satisfies no new one (a scheme or scope now required, an alternative removed, an anonymous alternative withdrawn), additive otherwise (an alternative added, a scheme or scope no longer needed, authentication made optional). For a webhook, a handler built for the old list must still find what it checked for in whatever the sender now presents: breaking when some new alternative carries no old one (a credential the sender stops presenting, an alternative it may now use that the old list never offered), additive otherwise (a credential the sender starts carrying beside the old ones, an alternative withdrawn). The document-level change is read from each side that inherits the default in both documents — the caller's when an operation does, the receiver's when a webhook does, each matched across the two documents as its own change is (an operation by name within its service, a webhook by name and method) — and is breaking if any such side breaks. No other node reads both defaults: one that declares its own list on either side is compared on its own, as above, and one added or removed is reported as such. When nothing inherits the default in both documents (say the only operation that did now declares the old default as its own list), no request reads both defaults, so the change is additive. A change whose only content is security is still reported, and a breaking change on the same node keeps the change breaking. Every security change is mirrored into `behaviorChanges` as a `security-changed` entry naming the node (`document`, `operation` or `webhook`), copying the two lists and carrying the security change's own classification, which stays additive when the owning change is breaking for another reason. **Tags.** Operations are matched by name within a service, and the parser groups an operation into a service by `tags[0]` — the first usable entry, non-empty strings only, repeats folded (then the path-prefix split, see [Service naming](architecture/ir-types.md#service--operation)), so a `tags` edit is reported by where the operation ends up. When it stays in a service of the same name — any edit that leaves the first entry alone, a first tag respelled to the same service name (`pets` → `Pets`), or a first tag changed to one the split files back under the same path-named service — it is `tagsChanged: true` on the `operation-modified` change, with `oldTags`/`newTags` beside it (`[]` for none; the lists are compared in document order, so a reorder after the first entry counts). When the first entry moves it to a service of another name, there is no `operation-modified` to carry a flag: the diff reports the move, `operation-removed` from the old service (breaking) and `operation-added` to the new one (additive), or `service-removed` (breaking) and `service-added` (additive) when a service goes or comes with it — so swapping `['Pets', 'Store']` to `['Store', 'Pets']` on a document's only operation exits 2. A webhook belongs to no service, so every edit to its `tags` is `tagsChanged` on its `webhook-modified` change. `tagsChanged` is always additive: tags group and document a method and change nothing a caller compiles against. A change whose only content is such a tags edit is still reported (so the command exits 1), and tags are not mirrored into `behaviorChanges`. diff --git a/src/differ/diff.ts b/src/differ/diff.ts index 1d6ea4a2..8d02467b 100644 --- a/src/differ/diff.ts +++ b/src/differ/diff.ts @@ -67,22 +67,39 @@ export function diffSpecs(oldSpec: ApiSpec, newSpec: ApiSpec): DiffReport { * The document-level `security` compared between the two documents: one * `security-changed` change when the lists differ as sets of alternatives, * nothing otherwise. `[]` and an undeclared list both mean no authentication - * and compare equal. Classified from each side that actually reads the - * default: the caller's when an operation in either document declares no - * `security` of its own, the receiver's when a webhook does — breaking if - * any such side breaks. When nothing inherits it, no request reads the - * default, so the change is additive. + * and compare equal. Classified from each side that inherits the default in + * both documents: the caller's when an operation does, the receiver's when a + * webhook does, each paired across the documents as its own change is — + * breaking if any such side breaks. No other node reads both defaults: + * `securityShift` compares one that declares its own list on either side, + * and one only one document has is reported as added or removed. When + * nothing inherits it in both documents, no request reads both defaults, so + * the change is additive. */ function diffDocumentSecurity(oldSpec: ApiSpec, newSpec: ApiSpec): Change[] { const oldSecurity = oldSpec.security ?? []; const newSecurity = newSpec.security ?? []; if (securityEqual(oldSecurity, newSecurity)) return []; - const inherits = (nodes: Array<{ security?: SecurityRequirementSet[] }>): boolean => - nodes.some((node) => node.security === undefined); - const operations = (spec: ApiSpec) => spec.services.flatMap((service) => service.operations); + // The nodes that declare no `security`, keyed as the diff pairs them across + // the two documents: an operation by its service and name, a webhook by its + // method and name. + const operations = (spec: ApiSpec): Set => + new Set( + spec.services.flatMap((service) => + service.operations.filter((op) => op.security === undefined).map((op) => `${service.name}.${op.name}`), + ), + ); + const webhooks = (spec: ApiSpec): Set => + new Set( + (spec.webhooks ?? []) + .filter((webhook) => webhook.security === undefined) + .map((webhook) => `${webhook.httpMethod} ${webhook.name}`), + ); + const inheritsTwice = (oldKeys: Set, newKeys: Set): boolean => + [...newKeys].some((key) => oldKeys.has(key)); const sides: SecuritySide[] = []; - if (inherits(operations(oldSpec)) || inherits(operations(newSpec))) sides.push('caller'); - if (inherits(oldSpec.webhooks ?? []) || inherits(newSpec.webhooks ?? [])) sides.push('receiver'); + if (inheritsTwice(operations(oldSpec), operations(newSpec))) sides.push('caller'); + if (inheritsTwice(webhooks(oldSpec), webhooks(newSpec))) sides.push('receiver'); const breaking = sides.some((side) => classifySecurityChange(oldSecurity, newSecurity, side) === 'breaking'); return [{ kind: 'security-changed', oldSecurity, newSecurity, classification: breaking ? 'breaking' : 'additive' }]; } diff --git a/src/differ/types.ts b/src/differ/types.ts index 91f8087e..53614e88 100644 --- a/src/differ/types.ts +++ b/src/differ/types.ts @@ -1084,12 +1084,14 @@ export interface DocumentSecurityChanged { /** The new document's list; `[]` when it declares none. */ newSecurity: SecurityRequirementSet[]; /** - * Read from each side that inherits the default, by the rule the - * inheriting node would be read by (`classifySecurityChange`): the - * caller's when an operation in either document declares no `security` - * of its own, the receiver's when a webhook does; breaking if any such - * side breaks. When nothing inherits it, no request reads the default, - * so the change is additive. + * Read from each side that inherits the default in both documents, by the + * rule the inheriting node would be read by (`classifySecurityChange`): + * the caller's when an operation does, the receiver's when a webhook + * does, each matched across the documents as its own change is; breaking + * if any such side breaks. A node that declares its own list on either + * side is compared on its own (`securityChanged`), and one added or + * removed is reported as such. When nothing inherits it in both + * documents, no request reads both defaults, so the change is additive. */ classification: 'additive' | 'breaking'; } diff --git a/test/differ/security-tags.test.ts b/test/differ/security-tags.test.ts index 2011eca8..cc58b8b2 100644 --- a/test/differ/security-tags.test.ts +++ b/test/differ/security-tags.test.ts @@ -480,10 +480,12 @@ describe('diffSpecs – the document’s default security', () => { expect(diff.behaviorChanges).toEqual([ { kind: 'security-changed', node: 'document', oldSecurity: apiKey, newSecurity: [], classification: 'breaking' }, ]); - // A webhook in either document is enough (the removed webhook is reported on its own). + // A webhook in only one document does not read both defaults (issue + // #383): the removed webhook is reported on its own, and the caller's + // side, where the default withdrawn is additive, decides alone. expect(diffSpecs(withHook(apiKey), spec({ security: undefined, webhooks: [] })).changes).toMatchObject([ - { kind: 'security-changed', classification: 'breaking' }, - { kind: 'webhook-removed' }, + { kind: 'security-changed', classification: 'additive' }, + { kind: 'webhook-removed', classification: 'breaking' }, ]); // A webhook that declares its own list does not inherit, so the caller's side alone decides. const pinned = (security?: SecurityRequirementSet[]) => spec({ security, webhooks: [hook({ security: bearer })] }); @@ -496,10 +498,20 @@ describe('diffSpecs – the document’s default security', () => { const before = spec({ security: apiKey, services: [{ name: 'Pets', operations: [op({ security: apiKey })] }] }); const after = spec({ security: bearer, services: [{ name: 'Pets', operations: [op()] }] }); const diff = diffSpecs(before, after); + // listPets reads the default in the new document only, so the document + // change, which nothing reads in both, is additive (issue #383), and the + // break (apiKey → bearer) is reported on the operation alone. expect(diff.changes).toMatchObject([ - { kind: 'security-changed', classification: 'breaking' }, - { kind: 'operation-modified', securityChanged: true, oldSecurity: apiKey, newSecurity: bearer }, + { kind: 'security-changed', classification: 'additive' }, + { + kind: 'operation-modified', + securityChanged: true, + oldSecurity: apiKey, + newSecurity: bearer, + classification: 'breaking', + }, ]); + expect(diff.summary.breaking).toBe(1); expect(diff.behaviorChanges.map((b) => (b.kind === 'security-changed' ? b.node : b.kind))).toEqual([ 'document', 'operation', @@ -541,15 +553,80 @@ describe('diffSpecs – the document’s default security', () => { { kind: 'security-changed', oldSecurity: [], newSecurity: apiKey, classification: 'additive' }, ]); - // An operation inheriting in either document brings the caller's side back. - expect(diffSpecs(pinnedOps(undefined), spec({ security: apiKey })).changes[0]).toMatchObject({ - kind: 'security-changed', - classification: 'breaking', - }); - expect(diffSpecs(spec({ security: undefined }), pinnedOps(apiKey)).changes[0]).toMatchObject({ - kind: 'security-changed', - classification: 'breaking', - }); + // An operation inheriting in only one document does not bring the + // caller's side back (issue #383): it is compared on its own, and one + // that needs nothing in both documents is no change at all. + expect(diffSpecs(pinnedOps(undefined), spec({ security: apiKey })).changes).toMatchObject([ + { kind: 'security-changed', classification: 'additive' }, + { + kind: 'operation-modified', + securityChanged: true, + oldSecurity: [], + newSecurity: apiKey, + classification: 'breaking', + }, + ]); + expect(diffSpecs(spec({ security: undefined }), pinnedOps(apiKey)).changes).toEqual([ + { kind: 'security-changed', oldSecurity: [], newSecurity: apiKey, classification: 'additive' }, + ]); + }); + + it('reports a default that no operation inherits in both documents as additive (issue #383)', async () => { + // `pin`: listPets inherits apiKey, then declares apiKey under a bearer + // default; `unpin` is the same pair swapped. listPets requires apiKey in + // every document, so no request reads the moved default and `oagen diff` + // exits 1 either way. + const inherited = await parseSpec(path.join(fixtures, 'security-moved-default-before.json')); + const pinned = await parseSpec(path.join(fixtures, 'security-moved-default-after.json')); + for (const [before, after, oldSecurity, newSecurity] of [ + [inherited, pinned, apiKey, bearer], + [pinned, inherited, bearer, apiKey], + ] as const) { + const diff = diffSpecs(before, after); + expect(diff.changes).toEqual([ + { kind: 'security-changed', oldSecurity, newSecurity, classification: 'additive' }, + ]); + expect(diff.behaviorChanges).toEqual([ + { kind: 'security-changed', node: 'document', oldSecurity, newSecurity, classification: 'additive' }, + ]); + expect(diff.summary).toEqual({ added: 0, removed: 0, modified: 1, breaking: 0, additive: 1, behaviorChanges: 1 }); + } + }); + + it('pairs an inheriting node across the documents as its own change does', () => { + // listPets added or removed is reported on its own, and so is a move to + // another service, which the diff reports as one service removed and + // another added: none of them reads both defaults. + const noOps = (security?: SecurityRequirementSet[]) => + spec({ security, services: [{ name: 'Pets', operations: [] }] }); + expect(diffSpecs(noOps(undefined), spec({ security: apiKey })).changes).toEqual([ + { kind: 'security-changed', oldSecurity: [], newSecurity: apiKey, classification: 'additive' }, + { kind: 'operation-added', serviceName: 'Pets', operationName: 'listPets', classification: 'additive' }, + ]); + expect(diffSpecs(spec({ security: undefined }), noOps(apiKey)).changes).toEqual([ + { kind: 'security-changed', oldSecurity: [], newSecurity: apiKey, classification: 'additive' }, + { kind: 'operation-removed', serviceName: 'Pets', operationName: 'listPets', classification: 'breaking' }, + ]); + const inStore = spec({ security: apiKey, services: [{ name: 'Store', operations: [listPets] }] }); + expect(diffSpecs(spec({ security: undefined }), inStore).changes).toEqual([ + { kind: 'security-changed', oldSecurity: [], newSecurity: apiKey, classification: 'additive' }, + { kind: 'service-added', name: 'Store', classification: 'additive' }, + { kind: 'service-removed', name: 'Pets', classification: 'breaking' }, + ]); + + // A webhook is paired by name and method, so one whose method changed is + // a removal and an addition, and the receiver's side is not read either. + const hookOnly = (security: SecurityRequirementSet[] | undefined, httpMethod: Webhook['httpMethod']) => + spec({ security, services: [], webhooks: [hook({ httpMethod })] }); + expect(diffSpecs(hookOnly(apiKey, 'post'), hookOnly(undefined, 'put')).changes).toEqual([ + { kind: 'security-changed', oldSecurity: apiKey, newSecurity: [], classification: 'additive' }, + { kind: 'webhook-added', name: 'petAdopted', httpMethod: 'put', classification: 'additive' }, + { kind: 'webhook-removed', name: 'petAdopted', httpMethod: 'post', classification: 'breaking' }, + ]); + // The same webhook under its own method reads it: withdrawn, the handler breaks. + expect(diffSpecs(hookOnly(apiKey, 'post'), hookOnly(undefined, 'post')).changes).toEqual([ + { kind: 'security-changed', oldSecurity: apiKey, newSecurity: [], classification: 'breaking' }, + ]); }); it('is listed after the server changes and before the models', () => { diff --git a/test/fixtures/security-moved-default-after.json b/test/fixtures/security-moved-default-after.json new file mode 100644 index 00000000..b7b6b0f2 --- /dev/null +++ b/test/fixtures/security-moved-default-after.json @@ -0,0 +1,16 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Pets", "version": "1" }, + "security": [{ "bearer": [] }], + "components": { + "securitySchemes": { + "apiKey": { "type": "apiKey", "in": "header", "name": "X-Key" }, + "bearer": { "type": "http", "scheme": "bearer" } + } + }, + "paths": { + "/pets": { + "get": { "operationId": "listPets", "security": [{ "apiKey": [] }], "responses": { "204": { "description": "ok" } } } + } + } +} diff --git a/test/fixtures/security-moved-default-before.json b/test/fixtures/security-moved-default-before.json new file mode 100644 index 00000000..943e691c --- /dev/null +++ b/test/fixtures/security-moved-default-before.json @@ -0,0 +1,16 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Pets", "version": "1" }, + "security": [{ "apiKey": [] }], + "components": { + "securitySchemes": { + "apiKey": { "type": "apiKey", "in": "header", "name": "X-Key" }, + "bearer": { "type": "http", "scheme": "bearer" } + } + }, + "paths": { + "/pets": { + "get": { "operationId": "listPets", "responses": { "204": { "description": "ok" } } } + } + } +} From 33a183a9056b2f25fe29b23a73298d559094ffb8 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 14:15:31 -0400 Subject: [PATCH 04/59] fix(differ): report a body moved between two models both documents declare as retyped Since #330, paired media-type bodies are compared by shape (`sameShape`) so that a model the parser only renamed is not a retype. `sameShape` read any two differently named models through to their fields, including two that both documents declare, so a secondary body switched from `$ref: Note` to an identical `$ref: NoteCopy` was reported as nothing (exit 0), although a generated SDK now types that body by another model and no `model-removed` or `model-added` reports it. Two model or enum names that both documents declare are now different shapes, at every level `sameShape` reads through (array items, fields, variants, discriminator mappings). A pair one of whose names only one document declares is still read through: that is what a rename leaves, the old name gone, or a new name minted while the old one moved to another body (`CreatePetRequest` becomes `CreatePetApplicationXWwwFormUrlencodedRequest` once a JSON body takes `CreatePetRequest`). `sameShape` backs the media-type pairing, `retypedBodies` and `ownRepresentationKept`, so responses, request bodies and webhook payloads change alike. Closes #390 --- docs/cli.md | 2 +- src/differ/shapes.ts | 19 ++++-- test/differ/request-body-media-types.test.ts | 16 +++++ .../response-media-type-essence.test.ts | 62 +++++++++++++++++++ test/differ/shapes.test.ts | 27 ++++++++ 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 2fd31a8c..94afa13a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -92,7 +92,7 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Request body encoding.** `requestBodyEncoding` — the wire format a body is serialized as, read from the media type it is declared under: `json`, `form-data`, `form-urlencoded`, `binary` or `text` — differing between the two specs while both declare a body is reported as `requestBodyEncodingChanged: true` on the `operation-modified` change, with `oldRequestBodyEncoding`/`newRequestBodyEncoding` beside it. An SDK built for the old spec keeps serializing the body and labelling the request as that spec said, so the change is breaking when the new spec no longer accepts that representation — the media type the old body was read from is gone from its `requestBodyContentTypes`, or the body under it changed shape, compared as `retyped` is (`application/json` where the server now reads only `application/x-www-form-urlencoded`) — and additive when it still does, as when a media type the parser prefers is added beside it (`application/json` added beside a kept `application/x-www-form-urlencoded`: the parser now reads the body from JSON, existing callers keep sending the form body, and the server still reads it). A kept media type whose inline schema the parser names differently on the two sides (`CreatePetRequest` while it is the body `requestBody` is read from, `CreatePetApplicationXWwwFormUrlencodedRequest` once JSON is preferred) is still the same representation: the two models are compared field by field through the two specs' models, as `retyped` is, not by name. A body without media-type lists reads as breaking. A body appearing or disappearing takes its encoding with it and is `requestBodyChanged` alone; a body that changes type and encoding at once sets `requestBodyChanged` and `requestBodyEncodingChanged`. The encoding flag is reported beside the request body media-type comparison (below): a body moving from `application/json` to `application/x-www-form-urlencoded` sets `requestBodyEncodingChanged` and `requestBodyContentTypesChanged` both (the JSON media type is removed, the form one added), while a media type changing essence within one encoding (`application/json` → `application/vnd.api+json`) sets `requestBodyContentTypesChanged` alone. On a `webhook-modified` change the same comparison — made only while both specs declare a payload, as on an operation; a payload appearing or disappearing is `payload added` / `payload removed` alone and carries neither field — sets `requestBodyChanged` with `details` saying `encoding json → form-urlencoded` and the same two fields beside it, breaking from the receiver's side when the representation the sender now uses — the media type the new payload is read from, with its shape — is not one the old spec declared with that shape (the handler decodes a wire format it never saw), additive when it is (a media type dropped beside one the handler already decoded). A change whose only content is the encoding is still reported (the command exits 2 when it is breaking, 1 when it is additive), and the encoding is not mirrored into `behaviorChanges`. -**Response media types.** An `operation-modified` change lists, per response entry both specs declare, the media types the new spec `added` (additive), `removed` (breaking) or `retyped` (breaking) under `responseContentTypeChanges`, keyed by `statusCode` or, for the `default` and range responses, by `fallback`. Media types are matched by essence — `type/subtype`, case folded, parameters stripped — the rule the parser types a body by, so `Text/HTML` is `text/html` and `application/problem+json; charset=utf-8` is `application/problem+json`: a key that only changed case, whitespace or `charset` is no change, and reordering the keys is none either. A pair whose parameters other than `charset` differ (`application/pdf; version=1.7` → `; version=2.0`, a `profile` on `application/ld+json`) is listed under `reparameterized` with both spellings, additive: the body is typed the same, but the parameter may select another representation, so the change stays visible without being called breaking. Parameters are read as RFC 9110 writes them — a `;` inside a quoted value does not end the parameter, backslash escapes are decoded, the quotes are not part of the value — and compared as name/value pairs with names case-folded, in any order (never as one joined string, so `title="report; version=2"` and `title=report; version=2` are different parameters). Each media type is reported as written in the spec that declares it. Two keys of one essence declared side by side (`application/json` beside `application/json; charset=utf-8`) stay two media types: each pairs, in this order of preference, with the key spelled the same way on the other side, else one of its essence with the same parameters other than `charset` (so `Application/YAML; version=1` is still `application/yaml; version=1` wherever it moved), else one of its essence whose body is the same shape (so a key that only edited a parameter finds its body), else the other side's next unpaired key of its essence in document order; one left over is added or removed. A model the parser mints for a secondary media type is named from the essence too (`GetReportApplicationYamlResponse` for `application/yaml` and for `application/yaml; charset=utf-8` alike), so a key that only respells itself keeps its model name and is not reported as retyped. `retyped` means the body's shape changed: two bodies that are both models are compared field by field through the two specs' models (nested models and enums included, documentation aside), not by name. So when a key declared beside another spelling of its essence edits a parameter (`application/yaml; version=1` → `application/yaml; version=3`), which renames the model the parser mints for it, the body is reported as `reparameterized` (additive) and not as `retyped`; the rename surfaces as `model-removed` + `model-added`, which is what the generated SDK actually loses and gains. A schema change under such a key is still `retyped`. The same holds for a secondary body on an error, `default` or range response. +**Response media types.** An `operation-modified` change lists, per response entry both specs declare, the media types the new spec `added` (additive), `removed` (breaking) or `retyped` (breaking) under `responseContentTypeChanges`, keyed by `statusCode` or, for the `default` and range responses, by `fallback`. Media types are matched by essence — `type/subtype`, case folded, parameters stripped — the rule the parser types a body by, so `Text/HTML` is `text/html` and `application/problem+json; charset=utf-8` is `application/problem+json`: a key that only changed case, whitespace or `charset` is no change, and reordering the keys is none either. A pair whose parameters other than `charset` differ (`application/pdf; version=1.7` → `; version=2.0`, a `profile` on `application/ld+json`) is listed under `reparameterized` with both spellings, additive: the body is typed the same, but the parameter may select another representation, so the change stays visible without being called breaking. Parameters are read as RFC 9110 writes them — a `;` inside a quoted value does not end the parameter, backslash escapes are decoded, the quotes are not part of the value — and compared as name/value pairs with names case-folded, in any order (never as one joined string, so `title="report; version=2"` and `title=report; version=2` are different parameters). Each media type is reported as written in the spec that declares it. Two keys of one essence declared side by side (`application/json` beside `application/json; charset=utf-8`) stay two media types: each pairs, in this order of preference, with the key spelled the same way on the other side, else one of its essence with the same parameters other than `charset` (so `Application/YAML; version=1` is still `application/yaml; version=1` wherever it moved), else one of its essence whose body is the same shape (so a key that only edited a parameter finds its body), else the other side's next unpaired key of its essence in document order; one left over is added or removed. A model the parser mints for a secondary media type is named from the essence too (`GetReportApplicationYamlResponse` for `application/yaml` and for `application/yaml; charset=utf-8` alike), so a key that only respells itself keeps its model name and is not reported as retyped. `retyped` means the body's shape changed: two bodies that are both models are compared field by field through the two specs' models (nested models and enums included, documentation aside), not by name — unless both specs declare both names, since then neither was renamed: a body moved from `Note` to an identical `NoteCopy` that both specs declare is `retyped`, as the generated SDK now types it by another model and no `model-removed` or `model-added` reports it. So when a key declared beside another spelling of its essence edits a parameter (`application/yaml; version=1` → `application/yaml; version=3`), which renames the model the parser mints for it, the body is reported as `reparameterized` (additive) and not as `retyped`; the rename surfaces as `model-removed` + `model-added`, which is what the generated SDK actually loses and gains. A schema change under such a key is still `retyped`. The same holds for a secondary body on an error, `default` or range response. **Request body media types.** The media types a request body may be sent as — the `content` keys of the Request Body Object, carried as `requestBodyContentTypes` / `requestBodyContents` — are compared the same way, while both specs declare a body (a body appearing or disappearing is `requestBodyChanged` alone). On an `operation-modified` change, `requestBodyContentTypesChanged: true` comes with `requestBodyContentTypeChange` listing the media types `added` (additive: callers can newly send it), `removed` (breaking: a representation callers could send is gone) and `retyped` (breaking; only ever a secondary media type, since the body `requestBody` is read from is `requestBodyChanged`'s). As for responses, `retyped` is decided by shape, not by name: a `Request` model that a parameter edit on a sibling-distinguished key only renamed is not a retype and surfaces as `model-removed` + `model-added`, and a changed parameter other than `charset` is `reparameterized`, additive, on an operation and on a webhook alike (`details` reads `payload media type text/csv; version=1 → text/csv; version=2`). On a `webhook-modified` change the same comparison is read from the receiver's side and sets `requestBodyChanged` with `details` such as `payload media type text/csv added`, beside a `requestBodyContentTypeChange` with the lists: a media type the sender can newly use, or whose body changed shape, is breaking (the handler must decode it); one it can no longer use is additive. diff --git a/src/differ/shapes.ts b/src/differ/shapes.ts index 2004ff26..ad059d0e 100644 --- a/src/differ/shapes.ts +++ b/src/differ/shapes.ts @@ -58,15 +58,22 @@ const ENUM_VALUE_DOC_KEYS: ReadonlySet = new Set(['description']); * discriminator (property and mapped shapes); an enum ref is read through to * its values (name, value, deprecation). Documentation (descriptions, links, * examples) does not count. Without `shapes`, or for a name either document - * lacks, the two refs are different shapes. A pair of models met again while - * comparing their own fields (a self-referential shape) is taken as equal, so - * the comparison terminates. + * lacks, the two refs are different shapes. So are two names both documents + * declare, however alike: neither was renamed, so a body moved from `Note` to + * an identical `NoteCopy` is typed by another model, and no `model-removed` + * or `model-added` reports it. A pair of models met again while comparing + * their own fields (a self-referential shape) is taken as equal, so the + * comparison terminates. * * This is what lets a body the parser names after its media type keep * comparing equal across a rename: a parameter edit on a key declared beside * another spelling of its essence (`application/yaml; version=1` → * `; version=3`) renames the model minted for it, and that rename is the - * models diff's to report (`model-removed` + `model-added`), not a retype. + * models diff's to report (`model-removed` + `model-added`), not a retype. A + * rename may also hand the old name to another body: a form body that is + * `CreatePetRequest` alone is `CreatePetApplicationXWwwFormUrlencodedRequest` + * once a JSON body takes `CreatePetRequest`, which is still a rename, since + * the new name is one the old document lacks. */ export function sameShape(a: TypeRef, b: TypeRef, shapes?: DocumentShapes): boolean { return sameShapeWithin(a, b, shapes, new Set()); @@ -80,7 +87,7 @@ function sameShapeWithin(a: TypeRef, b: TypeRef, shapes: DocumentShapes | undefi if (b.kind !== 'model') return false; const oldModel = shapes.old.models.get(a.name); const newModel = shapes.new.models.get(b.name); - if (!oldModel || !newModel) return false; + if (!oldModel || !newModel || (shapes.new.models.has(a.name) && shapes.old.models.has(b.name))) return false; const pair = `model:${a.name}\u0000${b.name}`; if (seen.has(pair)) return true; seen.add(pair); @@ -90,7 +97,7 @@ function sameShapeWithin(a: TypeRef, b: TypeRef, shapes: DocumentShapes | undefi if (b.kind !== 'enum') return false; const oldEnum = shapes.old.enums.get(a.name); const newEnum = shapes.new.enums.get(b.name); - if (!oldEnum || !newEnum) return false; + if (!oldEnum || !newEnum || (shapes.new.enums.has(a.name) && shapes.old.enums.has(b.name))) return false; return sameEnumShape(oldEnum, newEnum); } case 'array': diff --git a/test/differ/request-body-media-types.test.ts b/test/differ/request-body-media-types.test.ts index 5fcc3854..2464a3bd 100644 --- a/test/differ/request-body-media-types.test.ts +++ b/test/differ/request-body-media-types.test.ts @@ -461,4 +461,20 @@ describe('diffSpecs — a webhook payload model that was only renamed is no medi classification: 'breaking', }); }); + + it('reports a payload moved to another model of its shape that both documents declare as retyped, breaking (issue #390)', () => { + const both = (noteModel: string): ApiSpec => ({ + ...spec(noteModel, note(noteModel)), + models: [adoptionEvent, note('OldNote'), note('NewNote')], + }); + const { changes } = diffSpecs(both('OldNote'), both('NewNote')); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + kind: 'webhook-modified', + requestBodyChanged: true, + requestBodyContentTypeChange: { added: [], removed: [], retyped: ['text/plain'], classification: 'breaking' }, + details: 'payload media type text/plain retyped', + classification: 'breaking', + }); + }); }); diff --git a/test/differ/response-media-type-essence.test.ts b/test/differ/response-media-type-essence.test.ts index a35093e4..eb9e8839 100644 --- a/test/differ/response-media-type-essence.test.ts +++ b/test/differ/response-media-type-essence.test.ts @@ -812,3 +812,65 @@ describe('pairing a lone 2xx: the entry spelled as it is wins over one that only ]); }); }); + +describe('parser to differ — a secondary body switched to another component of its shape (issue #390)', () => { + /** `listNotes`: JSON and YAML bodies `$ref`ing the given components, beside `Note` and an identical `NoteCopy`. */ + const notes = (json: string, yaml: string) => { + const note = { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' } } }; + return { + openapi: '3.1.0', + info: { title: 'Notes', version: '1' }, + paths: { + '/notes': { + get: { + operationId: 'listNotes', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { schema: { $ref: `#/components/schemas/${json}` } }, + 'application/yaml': { schema: { $ref: `#/components/schemas/${yaml}` } }, + }, + }, + }, + }, + }, + }, + components: { schemas: { Note: note, NoteCopy: note } }, + }; + }; + const diffDocuments = async (before: Record, after: Record) => { + const dir = mkdtempSync(join(tmpdir(), 'oagen-390-')); + try { + const oldPath = join(dir, 'old.json'); + const newPath = join(dir, 'new.json'); + writeFileSync(oldPath, JSON.stringify(before)); + writeFileSync(newPath, JSON.stringify(after)); + return diffSpecs(await parseSpec(oldPath), await parseSpec(newPath)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + + it('reports the YAML body as retyped, breaking: both documents declare both models, so neither was renamed', async () => { + const { changes, summary } = await diffDocuments(notes('Note', 'Note'), notes('Note', 'NoteCopy')); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + kind: 'operation-modified', + operationName: 'listNotes', + responseChanged: false, + responseContentTypeChanges: [ + { + statusCode: 200, + added: [], + removed: [], + retyped: ['application/yaml'], + reparameterized: [], + classification: 'breaking', + }, + ], + classification: 'breaking', + }); + expect(summary.breaking).toBe(1); + }); +}); diff --git a/test/differ/shapes.test.ts b/test/differ/shapes.test.ts index 81f44112..f4c9fdec 100644 --- a/test/differ/shapes.test.ts +++ b/test/differ/shapes.test.ts @@ -141,6 +141,33 @@ describe('sameShape', () => { expect(sameShape(model('OldPage'), model('NewPage'), shapes)).toBe(true); }); + it('compares two models or enums both documents declare by name, so a body moved between them is no rename (issue #390)', () => { + const note = (name: string): Model => ({ name, fields: [{ name: 'title', type: string, required: true }] }); + const both = { models: [note('Note'), note('NoteCopy')], enums: [level('Level'), level('LevelCopy')] }; + const shapes = shapesOf(both, both); + expect(sameShape(model('Note'), model('NoteCopy'), shapes)).toBe(false); + expect( + sameShape({ kind: 'array', items: model('Note') }, { kind: 'array', items: model('NoteCopy') }, shapes), + ).toBe(false); + expect(sameShape(enumRef('Level'), enumRef('LevelCopy'), shapes)).toBe(false); + // Read through a rename, a field moved between two such models is still a change. + const metas = [meta('Meta', 'Level'), meta('MetaCopy', 'Level')]; + expect( + sameShape( + model('OldPage'), + model('NewPage'), + shapesOf( + { models: [page('OldPage', 'Meta'), ...metas], enums: [level('Level')] }, + { models: [page('NewPage', 'MetaCopy'), ...metas], enums: [level('Level')] }, + ), + ), + ).toBe(false); + // A name one document alone declares is what a rename leaves: the old + // name gone, or a new one minted while the old name moved to another body. + expect(sameShape(model('Note'), model('NoteCopy'), shapesOf(both, { models: [note('NoteCopy')] }))).toBe(true); + expect(sameShape(model('Note'), model('NoteCopy'), shapesOf({ models: [note('Note')] }, both))).toBe(true); + }); + it('compares by name alone without the documents, or for a name a document lacks', () => { expect(sameShape(model('OldPage'), model('NewPage'))).toBe(false); expect(sameShape(model('Page'), model('Page'))).toBe(true); From 393804d75bd0555bacc09216a722eaf986e6f366 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 17:08:09 -0400 Subject: [PATCH 05/59] docs(differ): say retyped compares two names both specs declare by name The #455 review (finding 1, thread PRRT_kwDOReoxH86w7jMf) points out that the public `retyped` JSDoc on `ResponseContentTypesChange` and `RequestBodyContentTypesChange` still said two model refs are compared field by field, with the parser rename as the only exception, and that the Request body media types paragraph in docs/cli.md still said "`retyped` is decided by shape, not by name". Since this PR, two names both documents declare are compared by name: a YAML response moved from `$ref: Note` to an identical `$ref: NoteCopy` is `retyped: ['application/yaml']`, and a form body beside JSON moved the same way is `retyped: ['application/x-www-form-urlencoded']`. Both JSDoc blocks and that paragraph now state the exception, as the Response media types paragraph already does. A parseSpec + diffSpecs test pins the form body case; it fails with the base's `sameShape`. No behavior or existing expectation changes. --- docs/cli.md | 2 +- src/differ/types.ts | 10 ++++++-- test/differ/request-body-media-types.test.ts | 27 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 94afa13a..250e7c64 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -94,7 +94,7 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Response media types.** An `operation-modified` change lists, per response entry both specs declare, the media types the new spec `added` (additive), `removed` (breaking) or `retyped` (breaking) under `responseContentTypeChanges`, keyed by `statusCode` or, for the `default` and range responses, by `fallback`. Media types are matched by essence — `type/subtype`, case folded, parameters stripped — the rule the parser types a body by, so `Text/HTML` is `text/html` and `application/problem+json; charset=utf-8` is `application/problem+json`: a key that only changed case, whitespace or `charset` is no change, and reordering the keys is none either. A pair whose parameters other than `charset` differ (`application/pdf; version=1.7` → `; version=2.0`, a `profile` on `application/ld+json`) is listed under `reparameterized` with both spellings, additive: the body is typed the same, but the parameter may select another representation, so the change stays visible without being called breaking. Parameters are read as RFC 9110 writes them — a `;` inside a quoted value does not end the parameter, backslash escapes are decoded, the quotes are not part of the value — and compared as name/value pairs with names case-folded, in any order (never as one joined string, so `title="report; version=2"` and `title=report; version=2` are different parameters). Each media type is reported as written in the spec that declares it. Two keys of one essence declared side by side (`application/json` beside `application/json; charset=utf-8`) stay two media types: each pairs, in this order of preference, with the key spelled the same way on the other side, else one of its essence with the same parameters other than `charset` (so `Application/YAML; version=1` is still `application/yaml; version=1` wherever it moved), else one of its essence whose body is the same shape (so a key that only edited a parameter finds its body), else the other side's next unpaired key of its essence in document order; one left over is added or removed. A model the parser mints for a secondary media type is named from the essence too (`GetReportApplicationYamlResponse` for `application/yaml` and for `application/yaml; charset=utf-8` alike), so a key that only respells itself keeps its model name and is not reported as retyped. `retyped` means the body's shape changed: two bodies that are both models are compared field by field through the two specs' models (nested models and enums included, documentation aside), not by name — unless both specs declare both names, since then neither was renamed: a body moved from `Note` to an identical `NoteCopy` that both specs declare is `retyped`, as the generated SDK now types it by another model and no `model-removed` or `model-added` reports it. So when a key declared beside another spelling of its essence edits a parameter (`application/yaml; version=1` → `application/yaml; version=3`), which renames the model the parser mints for it, the body is reported as `reparameterized` (additive) and not as `retyped`; the rename surfaces as `model-removed` + `model-added`, which is what the generated SDK actually loses and gains. A schema change under such a key is still `retyped`. The same holds for a secondary body on an error, `default` or range response. -**Request body media types.** The media types a request body may be sent as — the `content` keys of the Request Body Object, carried as `requestBodyContentTypes` / `requestBodyContents` — are compared the same way, while both specs declare a body (a body appearing or disappearing is `requestBodyChanged` alone). On an `operation-modified` change, `requestBodyContentTypesChanged: true` comes with `requestBodyContentTypeChange` listing the media types `added` (additive: callers can newly send it), `removed` (breaking: a representation callers could send is gone) and `retyped` (breaking; only ever a secondary media type, since the body `requestBody` is read from is `requestBodyChanged`'s). As for responses, `retyped` is decided by shape, not by name: a `Request` model that a parameter edit on a sibling-distinguished key only renamed is not a retype and surfaces as `model-removed` + `model-added`, and a changed parameter other than `charset` is `reparameterized`, additive, on an operation and on a webhook alike (`details` reads `payload media type text/csv; version=1 → text/csv; version=2`). On a `webhook-modified` change the same comparison is read from the receiver's side and sets `requestBodyChanged` with `details` such as `payload media type text/csv added`, beside a `requestBodyContentTypeChange` with the lists: a media type the sender can newly use, or whose body changed shape, is breaking (the handler must decode it); one it can no longer use is additive. +**Request body media types.** The media types a request body may be sent as — the `content` keys of the Request Body Object, carried as `requestBodyContentTypes` / `requestBodyContents` — are compared the same way, while both specs declare a body (a body appearing or disappearing is `requestBodyChanged` alone). On an `operation-modified` change, `requestBodyContentTypesChanged: true` comes with `requestBodyContentTypeChange` listing the media types `added` (additive: callers can newly send it), `removed` (breaking: a representation callers could send is gone) and `retyped` (breaking; only ever a secondary media type, since the body `requestBody` is read from is `requestBodyChanged`'s). As for responses, `retyped` is decided by shape, not by name, unless both specs declare both names: a `Request` model that a parameter edit on a sibling-distinguished key only renamed is not a retype and surfaces as `model-removed` + `model-added`, while a form body beside JSON moved from `Note` to an identical `NoteCopy` that both specs declare is `retyped`, since neither was renamed. A changed parameter other than `charset` is `reparameterized`, additive, on an operation and on a webhook alike (`details` reads `payload media type text/csv; version=1 → text/csv; version=2`). On a `webhook-modified` change the same comparison is read from the receiver's side and sets `requestBodyChanged` with `details` such as `payload media type text/csv added`, beside a `requestBodyContentTypeChange` with the lists: a media type the sender can newly use, or whose body changed shape, is breaking (the handler must decode it); one it can no longer use is additive. **Response headers.** An `operation-modified` change lists, per response entry both specs declare, the headers the new spec added, removed, retyped, re-required, respelled or deprecated under `responseHeaderChanges`, each entry carrying `headerName` as written in the spec that declares it (the new spec's spelling for a header both declare) and the response entry it belongs to: `statusCode` for an entry with an explicit status code — the primary success response when the operation declares more than one 2xx/3xx response, every other 2xx/3xx entry and every error — `fallback` for the `default` and range responses, and `Operation.responseStatusCode` as `statusCode` for a lone 2xx (neither only for a hand-built IR whose lone 2xx carries no code). A change is located at the entry of the spec that declares what changed: `header-removed` at the old spec's entry, every other kind at the new spec's — so an operation that lost its only 2xx (a lone `200` that became a lone `404`), whose new primary carries no code, reports that entry's headers removed under `200`; a `responseContentTypeChanges` entry that only removes, when the new spec's entry carries no code, is located at the old spec's entry the same way. Entries pair up between the two specs as they do for `responseContentTypeChanges` (see Success status below), and headers within an entry by name case-insensitively, as HTTP compares header names (RFC 9110 §5.1): `X-Rate-Limit` and `x-rate-limit` are one header, so a case-only rename is one additive `header-name-changed` entry with `oldName`/`newName` beside it rather than a removal beside an addition; two names of one folding declared side by side stay two headers, paired by exact spelling first. The SDK reads these headers off the response and never sends them, so each kind is classified from the reader's side, as a webhook's parameters are: `header-added` is additive, required or not (the server simply starts sending it); `header-removed` is breaking (an accessor the SDK exposed is gone); `header-type-changed` is breaking and `header-format-changed` additive, with the two formats in `details`, as for a parameter; `header-required-changed` is additive for optional → required (the header is now always sent) and breaking for required → optional, `details` saying which; `header-deprecated-changed` is additive either way, with `oldDeprecated`/`newDeprecated` and `details` beside it, and is mirrored into `behaviorChanges` as a `deprecated-changed` entry with `node: 'response-header'` (see Deprecation above). A 2xx the old spec declared and the new does not has no other flag to report its headers, so each is `header-removed`; an error or fallback response only one spec declares is `errorsChanged` or `fallbackResponsesChanged` alone, and a 2xx the new spec adds is no header change. A change whose only content is a header is still reported (the command exits 1 or 2 by its classification). A webhook's accepted responses carry no headers in the IR, so a `webhook-modified` change has no such list. diff --git a/src/differ/types.ts b/src/differ/types.ts index 53614e88..aed3f637 100644 --- a/src/differ/types.ts +++ b/src/differ/types.ts @@ -699,7 +699,10 @@ export interface ResponseContentTypesChange { * renames the model the parser mints for it) is no retype and surfaces as * `model-removed` + `model-added` instead — other than the one the entry's * own `type` is read from, which `responseChanged` and `errorsChanged` - * already report. + * already report. Two model or enum refs whose names both specs declare + * are compared by name, nested ones included, however alike: neither was + * renamed, so a body moved from `Note` to an identical `NoteCopy` is + * retyped. */ retyped: string[]; /** @@ -743,7 +746,10 @@ export interface RequestBodyContentTypesChange { * parameter edit on a key declared beside another spelling of its essence * renames the model the parser mints for it) is no retype and surfaces as * `model-removed` + `model-added` instead — other than the one - * `requestBody` is read from, which `requestBodyChanged` reports. + * `requestBody` is read from, which `requestBodyChanged` reports. Two + * model or enum refs whose names both specs declare are compared by name, + * nested ones included, however alike: neither was renamed, so a body + * moved from `Note` to an identical `NoteCopy` is retyped. */ retyped: string[]; /** diff --git a/test/differ/request-body-media-types.test.ts b/test/differ/request-body-media-types.test.ts index 2464a3bd..7e0dcb6c 100644 --- a/test/differ/request-body-media-types.test.ts +++ b/test/differ/request-body-media-types.test.ts @@ -381,6 +381,33 @@ describe('parseSpec to diffSpecs — the issue #278 body under application/json; classification: 'breaking', }); }); + + it('reports a form body moved to an identical component both documents declare as retyped, breaking (issue #390 review)', async () => { + const note = { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' } } }; + /** The JSON body on `Note` beside a form body on `form`, with `Note` and an identical `NoteCopy` declared. */ + const notes = (form: string) => ({ + ...document({ + 'application/json': { schema: { $ref: '#/components/schemas/Note' } }, + 'application/x-www-form-urlencoded': { schema: { $ref: `#/components/schemas/${form}` } }, + }), + components: { schemas: { Note: note, NoteCopy: note } }, + }); + const { changes } = await diffDocuments(notes('Note'), notes('NoteCopy')); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + kind: 'operation-modified', + requestBodyChanged: false, + requestBodyEncodingChanged: false, + requestBodyContentTypeChange: { + added: [], + removed: [], + retyped: ['application/x-www-form-urlencoded'], + reparameterized: [], + classification: 'breaking', + }, + classification: 'breaking', + }); + }); }); describe('diffSpecs — a webhook payload model that was only renamed is no media-type change', () => { From 032b9b3648f12022294206eb33b06673a0d4028d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 14:26:12 -0400 Subject: [PATCH 06/59] fix(differ)!: report an operation gaining or losing its only 2xx/3xx when it has no body Since #317 an operation that declares no 2xx or 3xx response has `response` set to `unknown`, which is also the `response` of a lone `204`, a lone `302` or a `200` without `content`. `diffOperations` compared `response`, and `responseStatusCode` only while both sides carry one, so an operation gaining or losing such a response was no change: `DELETE /pets` going from no `responses` to a lone `204`, or back, exited 0, and a lone `204` that became a lone `404` reported only `errorsChanged`, additive, with the removed `204` missing. `responseChanged` now also fires when `responseStatusCode` is present on one side only; the parser sets it exactly when a 2xx or 3xx is declared. A bodiless 2xx that moves to another code (`204` to `205`) is still `responseStatusCodeChanged` alone, additive. BREAKING CHANGE: `oagen diff` reports an operation that gains or loses its only 2xx/3xx response without a body as `responseChanged`, breaking. The issue's no-responses and `204` documents now exit 2 (was 0) both ways, and its `204` to `404` documents exit 2 (was 1, with `errorsChanged` alone). Closes #371 --- docs/cli.md | 2 +- src/differ/operations.ts | 16 ++++++++---- src/differ/types.ts | 9 +++++-- test/differ/operations.test.ts | 48 ++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 250e7c64..7b8c307c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -98,7 +98,7 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Response headers.** An `operation-modified` change lists, per response entry both specs declare, the headers the new spec added, removed, retyped, re-required, respelled or deprecated under `responseHeaderChanges`, each entry carrying `headerName` as written in the spec that declares it (the new spec's spelling for a header both declare) and the response entry it belongs to: `statusCode` for an entry with an explicit status code — the primary success response when the operation declares more than one 2xx/3xx response, every other 2xx/3xx entry and every error — `fallback` for the `default` and range responses, and `Operation.responseStatusCode` as `statusCode` for a lone 2xx (neither only for a hand-built IR whose lone 2xx carries no code). A change is located at the entry of the spec that declares what changed: `header-removed` at the old spec's entry, every other kind at the new spec's — so an operation that lost its only 2xx (a lone `200` that became a lone `404`), whose new primary carries no code, reports that entry's headers removed under `200`; a `responseContentTypeChanges` entry that only removes, when the new spec's entry carries no code, is located at the old spec's entry the same way. Entries pair up between the two specs as they do for `responseContentTypeChanges` (see Success status below), and headers within an entry by name case-insensitively, as HTTP compares header names (RFC 9110 §5.1): `X-Rate-Limit` and `x-rate-limit` are one header, so a case-only rename is one additive `header-name-changed` entry with `oldName`/`newName` beside it rather than a removal beside an addition; two names of one folding declared side by side stay two headers, paired by exact spelling first. The SDK reads these headers off the response and never sends them, so each kind is classified from the reader's side, as a webhook's parameters are: `header-added` is additive, required or not (the server simply starts sending it); `header-removed` is breaking (an accessor the SDK exposed is gone); `header-type-changed` is breaking and `header-format-changed` additive, with the two formats in `details`, as for a parameter; `header-required-changed` is additive for optional → required (the header is now always sent) and breaking for required → optional, `details` saying which; `header-deprecated-changed` is additive either way, with `oldDeprecated`/`newDeprecated` and `details` beside it, and is mirrored into `behaviorChanges` as a `deprecated-changed` entry with `node: 'response-header'` (see Deprecation above). A 2xx the old spec declared and the new does not has no other flag to report its headers, so each is `header-removed`; an error or fallback response only one spec declares is `errorsChanged` or `fallbackResponsesChanged` alone, and a 2xx the new spec adds is no header change. A change whose only content is a header is still reported (the command exits 1 or 2 by its classification). A webhook's accepted responses carry no headers in the IR, so a `webhook-modified` change has no such list. -**Success status.** Response entries pair up between the two specs by status code — every 2xx/3xx entry, the primary among them (a lone 2xx by `Operation.responseStatusCode`, the code the parser read `response` from), and every error — so a lone `201` pairs with the other spec's `201` whether that spec declares it alone or beside a `200` it added, and the added `200` is no header or media-type change. When neither primary has a counterpart by code and both carry one, the primary moved (a lone `201` became a lone `200`): the two primaries pair and the move is reported once, as `responseStatusCodeChanged: true` on the `operation-modified` change with `oldResponseStatusCode`/`newResponseStatusCode` beside it, rather than every header and media type as removed under one code and added under the other. `responseStatusCodeChanged` is set whenever both specs carry a code and the two differ — a `201` that became the primary beside a `200` whose body lost its schema included — and is additive on its own: the old code may still be declared (the `200` added beside a lone `201` is that case), and whether a bare status move breaks callers is left to a separate issue — the flag says the move happened so a changelog can. An operation gaining or losing its only 2xx/3xx is not a move. It is not mirrored into `behaviorChanges`. Only a hand-built IR whose lone 2xx carries no code falls back to pairing it with the entry of the other spec that offers its media types, else with that spec's primary. +**Success status.** Response entries pair up between the two specs by status code — every 2xx/3xx entry, the primary among them (a lone 2xx by `Operation.responseStatusCode`, the code the parser read `response` from), and every error — so a lone `201` pairs with the other spec's `201` whether that spec declares it alone or beside a `200` it added, and the added `200` is no header or media-type change. When neither primary has a counterpart by code and both carry one, the primary moved (a lone `201` became a lone `200`): the two primaries pair and the move is reported once, as `responseStatusCodeChanged: true` on the `operation-modified` change with `oldResponseStatusCode`/`newResponseStatusCode` beside it, rather than every header and media type as removed under one code and added under the other. `responseStatusCodeChanged` is set whenever both specs carry a code and the two differ — a `201` that became the primary beside a `200` whose body lost its schema included — and is additive on its own: the old code may still be declared (the `200` added beside a lone `201` is that case), and whether a bare status move breaks callers is left to a separate issue — the flag says the move happened so a changelog can. It is not mirrored into `behaviorChanges`. An operation gaining or losing its only 2xx/3xx is not a move, but it is still reported: as `responseChanged: true`, breaking, whether or not that response has a body. `response` is `unknown` both for a 2xx/3xx without a body (a lone `204` or `302`) and for none at all, so `responseStatusCode` appearing or disappearing is what tells the two apart. Only a hand-built IR whose lone 2xx carries no code falls back to pairing it with the entry of the other spec that offers its media types, else with that spec's primary. **Named unions.** A `model-modified` change on a named union (`Model.variants`) lists what moved under `variantChanges`, each entry naming the `variant` by a short type label (`Cat`, `nullable`, `array`). Variants are matched by type, not by position, so a reorder is no change. `variant-added` is additive, like a new enum value, from either side of the wire — a caller may construct the new variant but need not, and a reader may receive it as it may a new enum value; a required field the union model itself gains is a `field-added` entry, judged by direction as any field is. `variant-removed` is breaking. A discriminator mapping entry that comes with an added variant — a new key whose target is a model no variant of the old union names, under any wrapper (`nullable` added beside a kept `Cat` brings no new model, so a key for `Cat` added with it is an alias) — or goes with a removed one is part of that variant's entry, carried as its `details` (`mapping added for 'bird'`, `mapping removed for 'dog'`, the keys of every model the variant names listed once each and sorted) and not reported on its own, so a variant added to a discriminated union is additive whether the spec writes the `mapping` or the parser derives it from the variants' consts; an entry of an undiscriminated union carries no `details`. Every other discriminator change is one breaking `discriminator-changed` entry, since it changes how an existing payload is dispatched: the discriminator added or removed (`details` says which), moved to another property (`property changed from 'kind' to 'type'`), or its mapping changed for a key the old union dispatched otherwise — retargeted, added for a model the old union already dispatched to (or for no variant), or removed while its model stays — with `mapping changed for 'dog'` naming every such key, sorted. A change of composition keyword between two unions is one breaking `composition-changed` entry (`composition changed from 'anyOf' to 'oneOf'`); a model that becomes or stops being a union reports each variant added or removed instead. An allOf+oneOf dispatcher — a model with `discriminator` and no `variants`, whose mapping alone names what it dispatches to — is compared as a union of the mapping's targets, so a target added to or removed from its mapping is the same `variant-added` or `variant-removed`. A change whose only content is a variant entry is still reported (the command exits 1 or 2 by its classification), and variant entries are not mirrored into `behaviorChanges`. diff --git a/src/differ/operations.ts b/src/differ/operations.ts index f4cb1032..cf6b4957 100644 --- a/src/differ/operations.ts +++ b/src/differ/operations.ts @@ -105,7 +105,12 @@ export function diffOperations( if (!oldOp) continue; const paramChanges = diffParams(oldOp, newOp); - const responseChanged = !typeRefsEqual(oldOp.response, newOp.response); + // `response` is `unknown` both for a 2xx/3xx without a body (a lone `204` + // or `302`) and for none at all, so the operation gaining or losing its + // only 2xx/3xx is told by `responseStatusCode` appearing or disappearing. + const responseChanged = + !typeRefsEqual(oldOp.response, newOp.response) || + (oldOp.responseStatusCode === undefined) !== (newOp.responseStatusCode === undefined); const requestBodyChanged = !requestBodiesEqual(oldOp.requestBody, newOp.requestBody); // The required flag and the encoding are compared only while both sides // carry a body: a body appearing or disappearing is already @@ -154,10 +159,11 @@ export function diffOperations( // The status code `response` is read from (`responseStatusCode`, present // exactly when a 2xx or 3xx is declared), compared only while both sides // carry one, as `requestBodyRequired` is while both carry a body: an - // operation gaining or losing its only 2xx/3xx is not a move. Reported - // as additive: the old code may still be declared (a `200` added beside - // a lone `201` makes the `200` the primary), and whether a bare status - // move breaks callers is left to a separate issue. + // operation gaining or losing its only 2xx/3xx is not a move but + // `responseChanged`. Reported as additive: the old code may still be + // declared (a `200` added beside a lone `201` makes the `200` the + // primary), and whether a bare status move breaks callers is left to a + // separate issue. const bothCarryStatus = oldOp.responseStatusCode !== undefined && newOp.responseStatusCode !== undefined; const responseStatusCodeChanged = bothCarryStatus && oldOp.responseStatusCode !== newOp.responseStatusCode; const status = responseStatusCodeChanged diff --git a/src/differ/types.ts b/src/differ/types.ts index aed3f637..f8d0d42a 100644 --- a/src/differ/types.ts +++ b/src/differ/types.ts @@ -351,6 +351,11 @@ export interface OperationModified { serviceName: string; operationName: string; paramChanges: ParamChange[]; + /** + * `Operation.response` changed type, or the operation gained or lost its + * only 2xx/3xx response (`responseStatusCode` present on one side only), + * with or without a body. Breaking. + */ responseChanged: boolean; /** * The body appeared, disappeared, or changed type. Breaking, except that a @@ -561,8 +566,8 @@ export interface OperationModified { * move breaks callers is left to a separate issue — this flag only says * the move happened, so a changelog can. Compared only while both sides * carry a code, as `requestBodyRequired` is while both carry a body; an - * operation gaining or losing its only 2xx/3xx is not a move. Not mirrored - * into `behaviorChanges`. + * operation gaining or losing its only 2xx/3xx is not a move but + * `responseChanged`. Not mirrored into `behaviorChanges`. */ responseStatusCodeChanged: boolean; /** Present exactly when `responseStatusCodeChanged`: the code on each side, old → new. */ diff --git a/test/differ/operations.test.ts b/test/differ/operations.test.ts index dbd6c75d..dbaa8a63 100644 --- a/test/differ/operations.test.ts +++ b/test/differ/operations.test.ts @@ -1216,3 +1216,51 @@ describe('diffOperations response descriptions', () => { expect(diffOperations('Users', [described], [getUser])).toEqual([]); }); }); + +describe('diffOperations — an operation gaining or losing its only 2xx/3xx, which has no body (issue #371)', () => { + /** The issue's `DELETE /pets` (`deletePets`), as the parser builds it from `responses` or from no `responses` key. */ + const deletePets = (responses?: Record): Operation[] => + extractOperations({ + '/pets': { delete: { operationId: 'deletePets', ...(responses ? { responses } : {}) } }, + } as never).services.flatMap((s) => s.operations); + const none = deletePets(); + const deleted = deletePets({ '204': { description: 'Deleted' } }); + const notFound = deletePets({ '404': { description: 'Not found' } }); + const redirect = deletePets({ '302': { description: 'Moved' } }); + /** Each change as the issue prints it: kind, operation, every `…Changed` flag set, classification. */ + const report = (from: Operation[], to: Operation[]): string[] => + diffOperations('Pets', from, to).map((change) => { + const flags = Object.entries(change).flatMap(([key, value]) => + key.endsWith('Changed') && value === true ? [` ${key}`] : [], + ); + return `${change.kind} ${'operationName' in change ? change.operationName : ''}${flags.join('')}: ${change.classification}`; + }); + + it('reports it modified and breaking, as before #317, though `response` is `unknown` on both sides', () => { + // Only `responseStatusCode`, present on one side, tells the documents apart. + expect([none, deleted, notFound, redirect].map(([op]) => [op.response, op.responseStatusCode])).toEqual([ + [{ kind: 'primitive', type: 'unknown' }, undefined], + [{ kind: 'primitive', type: 'unknown' }, 204], + [{ kind: 'primitive', type: 'unknown' }, undefined], + [{ kind: 'primitive', type: 'unknown' }, 302], + ]); + // Used to be `[]` both ways (exit 0). + expect(report(none, deleted)).toEqual(['operation-modified deletePets responseChanged: breaking']); + expect(report(deleted, none)).toEqual(['operation-modified deletePets responseChanged: breaking']); + // Used to be `errorsChanged` alone, additive (exit 1), the removed 204 missing. + expect(report(deleted, notFound)).toEqual([ + 'operation-modified deletePets responseChanged errorsChanged: breaking', + ]); + // A lone 3xx is the operation's only success response too. + expect(report(redirect, none)).toEqual(['operation-modified deletePets responseChanged: breaking']); + }); + + it('leaves a bodiless 2xx that moves to another code to responseStatusCodeChanged, and no 2xx/3xx on either side alone', () => { + const resetContent = deletePets({ '205': { description: 'Reset' } }); + expect(report(deleted, resetContent)).toEqual([ + 'operation-modified deletePets responseStatusCodeChanged: additive', + ]); + expect(report(none, notFound)).toEqual(['operation-modified deletePets errorsChanged: additive']); + expect(report(none, none)).toEqual([]); + }); +}); From edba958dc2179295cf14a83520a0fac5146a4977 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 14:40:01 -0400 Subject: [PATCH 07/59] fix(differ): report no header or media type of a 2xx added to an operation that had none Since #346 an operation that declared no 2xx/3xx and gains some reported the headers of one of them under `responseHeaderChanges`, though the docs say a 2xx the new spec adds is no header change. The old operation's primary carries no code, so `pairContentEntries` paired it by shape with the new 2xx that offers the same media types, which is none: the bodiless `204` when there is one, else the new primary, whose media types were also reported added under `responseContentTypeChanges`. An old primary that carries no code, media types or headers (the parser's primary for an operation declaring no 2xx/3xx) now pairs with nothing, so every 2xx the new spec adds stays unreported there; the operation is already `responseChanged`. A hand-built IR's code-less lone 2xx that carries media types or headers still pairs by shape, and an operation that loses its only 2xx still reports what it declared under its old code. The #296 test that pinned `X-Trace` added at `200` for an operation going from a lone `404` to a lone `200` now expects no header change. Closes #409 --- docs/cli.md | 2 +- src/differ/operations.ts | 14 +++-- src/differ/types.ts | 13 +++-- test/differ/response-headers.test.ts | 81 ++++++++++++++++++++++++++-- 4 files changed, 99 insertions(+), 11 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 7b8c307c..fe3221d8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -98,7 +98,7 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Response headers.** An `operation-modified` change lists, per response entry both specs declare, the headers the new spec added, removed, retyped, re-required, respelled or deprecated under `responseHeaderChanges`, each entry carrying `headerName` as written in the spec that declares it (the new spec's spelling for a header both declare) and the response entry it belongs to: `statusCode` for an entry with an explicit status code — the primary success response when the operation declares more than one 2xx/3xx response, every other 2xx/3xx entry and every error — `fallback` for the `default` and range responses, and `Operation.responseStatusCode` as `statusCode` for a lone 2xx (neither only for a hand-built IR whose lone 2xx carries no code). A change is located at the entry of the spec that declares what changed: `header-removed` at the old spec's entry, every other kind at the new spec's — so an operation that lost its only 2xx (a lone `200` that became a lone `404`), whose new primary carries no code, reports that entry's headers removed under `200`; a `responseContentTypeChanges` entry that only removes, when the new spec's entry carries no code, is located at the old spec's entry the same way. Entries pair up between the two specs as they do for `responseContentTypeChanges` (see Success status below), and headers within an entry by name case-insensitively, as HTTP compares header names (RFC 9110 §5.1): `X-Rate-Limit` and `x-rate-limit` are one header, so a case-only rename is one additive `header-name-changed` entry with `oldName`/`newName` beside it rather than a removal beside an addition; two names of one folding declared side by side stay two headers, paired by exact spelling first. The SDK reads these headers off the response and never sends them, so each kind is classified from the reader's side, as a webhook's parameters are: `header-added` is additive, required or not (the server simply starts sending it); `header-removed` is breaking (an accessor the SDK exposed is gone); `header-type-changed` is breaking and `header-format-changed` additive, with the two formats in `details`, as for a parameter; `header-required-changed` is additive for optional → required (the header is now always sent) and breaking for required → optional, `details` saying which; `header-deprecated-changed` is additive either way, with `oldDeprecated`/`newDeprecated` and `details` beside it, and is mirrored into `behaviorChanges` as a `deprecated-changed` entry with `node: 'response-header'` (see Deprecation above). A 2xx the old spec declared and the new does not has no other flag to report its headers, so each is `header-removed`; an error or fallback response only one spec declares is `errorsChanged` or `fallbackResponsesChanged` alone, and a 2xx the new spec adds is no header change. A change whose only content is a header is still reported (the command exits 1 or 2 by its classification). A webhook's accepted responses carry no headers in the IR, so a `webhook-modified` change has no such list. -**Success status.** Response entries pair up between the two specs by status code — every 2xx/3xx entry, the primary among them (a lone 2xx by `Operation.responseStatusCode`, the code the parser read `response` from), and every error — so a lone `201` pairs with the other spec's `201` whether that spec declares it alone or beside a `200` it added, and the added `200` is no header or media-type change. When neither primary has a counterpart by code and both carry one, the primary moved (a lone `201` became a lone `200`): the two primaries pair and the move is reported once, as `responseStatusCodeChanged: true` on the `operation-modified` change with `oldResponseStatusCode`/`newResponseStatusCode` beside it, rather than every header and media type as removed under one code and added under the other. `responseStatusCodeChanged` is set whenever both specs carry a code and the two differ — a `201` that became the primary beside a `200` whose body lost its schema included — and is additive on its own: the old code may still be declared (the `200` added beside a lone `201` is that case), and whether a bare status move breaks callers is left to a separate issue — the flag says the move happened so a changelog can. It is not mirrored into `behaviorChanges`. An operation gaining or losing its only 2xx/3xx is not a move, but it is still reported: as `responseChanged: true`, breaking, whether or not that response has a body. `response` is `unknown` both for a 2xx/3xx without a body (a lone `204` or `302`) and for none at all, so `responseStatusCode` appearing or disappearing is what tells the two apart. Only a hand-built IR whose lone 2xx carries no code falls back to pairing it with the entry of the other spec that offers its media types, else with that spec's primary. +**Success status.** Response entries pair up between the two specs by status code — every 2xx/3xx entry, the primary among them (a lone 2xx by `Operation.responseStatusCode`, the code the parser read `response` from), and every error — so a lone `201` pairs with the other spec's `201` whether that spec declares it alone or beside a `200` it added, and the added `200` is no header or media-type change. When neither primary has a counterpart by code and both carry one, the primary moved (a lone `201` became a lone `200`): the two primaries pair and the move is reported once, as `responseStatusCodeChanged: true` on the `operation-modified` change with `oldResponseStatusCode`/`newResponseStatusCode` beside it, rather than every header and media type as removed under one code and added under the other. `responseStatusCodeChanged` is set whenever both specs carry a code and the two differ — a `201` that became the primary beside a `200` whose body lost its schema included — and is additive on its own: the old code may still be declared (the `200` added beside a lone `201` is that case), and whether a bare status move breaks callers is left to a separate issue — the flag says the move happened so a changelog can. It is not mirrored into `behaviorChanges`. An operation gaining or losing its only 2xx/3xx is not a move, but it is still reported: as `responseChanged: true`, breaking, whether or not that response has a body. `response` is `unknown` both for a 2xx/3xx without a body (a lone `204` or `302`) and for none at all, so `responseStatusCode` appearing or disappearing is what tells the two apart. Only a hand-built IR whose lone 2xx carries no code falls back to pairing it with the entry of the other spec that offers its media types, else with that spec's primary; an operation that declared no 2xx/3xx pairs with nothing, so every 2xx the new spec adds to it is no header or media-type change — the operation is already reported as `responseChanged`. **Named unions.** A `model-modified` change on a named union (`Model.variants`) lists what moved under `variantChanges`, each entry naming the `variant` by a short type label (`Cat`, `nullable`, `array`). Variants are matched by type, not by position, so a reorder is no change. `variant-added` is additive, like a new enum value, from either side of the wire — a caller may construct the new variant but need not, and a reader may receive it as it may a new enum value; a required field the union model itself gains is a `field-added` entry, judged by direction as any field is. `variant-removed` is breaking. A discriminator mapping entry that comes with an added variant — a new key whose target is a model no variant of the old union names, under any wrapper (`nullable` added beside a kept `Cat` brings no new model, so a key for `Cat` added with it is an alias) — or goes with a removed one is part of that variant's entry, carried as its `details` (`mapping added for 'bird'`, `mapping removed for 'dog'`, the keys of every model the variant names listed once each and sorted) and not reported on its own, so a variant added to a discriminated union is additive whether the spec writes the `mapping` or the parser derives it from the variants' consts; an entry of an undiscriminated union carries no `details`. Every other discriminator change is one breaking `discriminator-changed` entry, since it changes how an existing payload is dispatched: the discriminator added or removed (`details` says which), moved to another property (`property changed from 'kind' to 'type'`), or its mapping changed for a key the old union dispatched otherwise — retargeted, added for a model the old union already dispatched to (or for no variant), or removed while its model stays — with `mapping changed for 'dog'` naming every such key, sorted. A change of composition keyword between two unions is one breaking `composition-changed` entry (`composition changed from 'anyOf' to 'oneOf'`); a model that becomes or stops being a union reports each variant added or removed instead. An allOf+oneOf dispatcher — a model with `discriminator` and no `variants`, whose mapping alone names what it dispatches to — is compared as a union of the mapping's targets, so a target added to or removed from its mapping is the same `variant-added` or `variant-removed`. A change whose only content is a variant entry is still reported (the command exits 1 or 2 by its classification), and variant entries are not mirrored into `behaviorChanges`. diff --git a/src/differ/operations.ts b/src/differ/operations.ts index cf6b4957..3419a616 100644 --- a/src/differ/operations.ts +++ b/src/differ/operations.ts @@ -756,8 +756,14 @@ function diffResponseContentTypes( * response it most plausibly still is — and, when none does, with the other * side's primary; so an operation that lost its only 2xx pairs that entry * with its new, empty primary, and `diffResponseHeaders` and - * `diffResponseContentTypes` report what it declared under its old code. A - * new entry nothing pairs with is left out of the map. + * `diffResponseContentTypes` report what it declared under its old code. + * An old primary that carries no media types or headers either — the + * primary of an operation that declared no 2xx/3xx carries neither — has + * nothing to compare and pairs with nothing: every 2xx of the new spec is + * one it adds, which is no header or media-type change (`responseChanged` + * reports it), rather than one of them — the 2xx that offers no media + * types, else the new primary — having all its headers and media types + * reported added. A new entry nothing pairs with is left out of the map. */ function pairContentEntries(oldEntries: ContentEntry[], newEntries: ContentEntry[]): Map { const before = new Map(oldEntries.map((entry) => [entry.key, entry])); @@ -776,7 +782,9 @@ function pairContentEntries(oldEntries: ContentEntry[], newEntries: ContentEntry pairs.set(newPrimary, oldPrimary); } else if (newPrimary.statusCode === undefined) { pairs.set(newPrimary, survivingEntry(newPrimary, oldEntries) ?? oldPrimary); - } else { + } else if (oldPrimary.bodies.length > 0 || oldPrimary.headers.length > 0) { + // Only when it has something to compare: the empty primary of an + // operation declaring no 2xx/3xx pairs with nothing. pairs.set(survivingEntry(oldPrimary, newEntries) ?? newPrimary, oldPrimary); } return pairs; diff --git a/src/differ/types.ts b/src/differ/types.ts index f8d0d42a..dfdd4184 100644 --- a/src/differ/types.ts +++ b/src/differ/types.ts @@ -599,8 +599,12 @@ export interface OperationModified { * codes pair with each other, the move reported once as * `responseStatusCodeChanged`; a lone 2xx that carries no code (a hand-built * IR) pairs with the entry of the other spec that offers its media types, - * else with that spec's primary, and is reported with `statusCode` unset; - * the `default` and range responses by key, with `fallback` set. Within an entry, headers pair + * else with that spec's primary, and is reported with `statusCode` unset, + * though only one that carries media types or headers pairs as the old + * spec's: one that carries neither, as the primary of an operation that + * declared no 2xx/3xx does, pairs with nothing, so a 2xx the new spec adds + * to it is no header change; the `default` and range responses by key, + * with `fallback` set. Within an entry, headers pair * by name case-insensitively (RFC 9110 §5.1, `foldHeaderName`), so * `X-Rate-Limit` and `x-rate-limit` are one header: a pair whose written * names differ is reported as `header-name-changed` with `oldName`/`newName` @@ -665,7 +669,10 @@ export interface ResponseHeaderChange { * (`responseStatusCodeChanged` reports the move); a primary that carries no * code (an operation declaring no 2xx/3xx, or a hand-built IR's lone 2xx) is * paired with the entry of the other spec that offers its media types, else - * with that spec's primary. A change is located at the new spec's entry, + * with that spec's primary, except the old spec's when it carries no media + * types or headers either, as the primary of an operation that declared no + * 2xx/3xx does: that one pairs with nothing, so a 2xx the new spec adds to + * it is no media-type change. A change is located at the new spec's entry, * or — when that entry carries no code and the change only removes, as * when an operation lost its only 2xx — at the old spec's entry, which * declared what is gone, so `statusCode` is unset only for a lone 2xx of a diff --git a/test/differ/response-headers.test.ts b/test/differ/response-headers.test.ts index 2ac0f54b..3205d902 100644 --- a/test/differ/response-headers.test.ts +++ b/test/differ/response-headers.test.ts @@ -232,15 +232,88 @@ describe('issue #296 repro: headers on a lone 200 dropped, retyped and deprecate ], classification: 'breaking', }); - // An added header is located at the new entry, which carries the code. + // The other way round, the 200 is one the new spec adds, which is no + // header change: gaining it is `responseChanged`. Used to report + // `X-Trace` added at 200 (issue #409). expect(diffSpecs(only404, lone200).changes[0]).toMatchObject({ - responseHeaderChanges: [ - { kind: 'header-added', headerName: 'X-Trace', statusCode: 200, classification: 'additive' }, - ], + responseChanged: true, + responseHeadersChanged: false, + responseHeaderChanges: [], + classification: 'breaking', }); }); }); +describe('issue #409 repro: an operation that declared no 2xx gains some', () => { + /** `POST /pets` (`createPet`) declaring `responses`, as the issue writes it. */ + const documentWith = (responses: Record) => ({ + openapi: '3.1.0', + info: { title: 'Pets', version: '1' }, + paths: { '/pets': { post: { operationId: 'createPet', responses } } }, + }); + const notFound = { description: 'Not found' }; + const created = { + description: 'Created', + headers: { 'X-Pet-Id': { schema: { type: 'string' } } }, + content: { 'application/json': { schema: { type: 'object', properties: { id: { type: 'string' } } } } }, + }; + const queued = { description: 'Queued', headers: { 'X-Queue-Id': { schema: { type: 'string' } } } }; + const before = documentWith({ '404': notFound }); + const after = documentWith({ '200': created, '204': queued, '404': notFound }); + const afterLone = documentWith({ '200': created, '404': notFound }); + + /** The `operation-modified` change between two documents (an inline body model comes or goes beside it). */ + async function operationChange(oldDoc: unknown, newDoc: unknown) { + const { changes } = diffSpecs( + await parseSpec(await writeSpec('old.json', oldDoc)), + await parseSpec(await writeSpec('new.json', newDoc)), + ); + const change = changes.find((c) => c.kind === 'operation-modified'); + if (change?.kind !== 'operation-modified') throw new Error('expected an operation-modified change'); + return change; + } + + it.each([ + ['after.json', after], + ['after-lone.json', afterLone], + ])('reports no header or media type of a 2xx %s adds, only responseChanged', async (_, newDoc) => { + // Used to report `X-Queue-Id` added at 204 (after.json), or `X-Pet-Id` + // and `application/json` added at 200 (after-lone.json): the old + // primary, which carries no code, paired by its (no) media types with + // one of the new 2xx entries. + expect(await operationChange(before, newDoc)).toMatchObject({ + responseChanged: true, + responseHeadersChanged: false, + responseHeaderChanges: [], + responseContentTypesChanged: false, + responseContentTypeChanges: [], + classification: 'breaking', + }); + }); + + it('still reports what each 2xx declared when the operation loses them all, under its own code', async () => { + const change = await operationChange(after, before); + expect(change.responseHeaderChanges).toHaveLength(2); + expect(change.responseHeaderChanges).toEqual( + expect.arrayContaining([ + { kind: 'header-removed', headerName: 'X-Pet-Id', statusCode: 200, classification: 'breaking' }, + { kind: 'header-removed', headerName: 'X-Queue-Id', statusCode: 204, classification: 'breaking' }, + ]), + ); + expect(change.responseContentTypeChanges).toEqual([ + { + statusCode: 200, + added: [], + removed: ['application/json'], + retyped: [], + reparameterized: [], + classification: 'breaking', + }, + ]); + expect(change).toMatchObject({ responseChanged: true, classification: 'breaking' }); + }); +}); + describe('diffOperations — a lone 2xx pairs by its status code (Greptile round 1)', () => { /** A lone `201` carrying `Location`, as the parser builds an operation declaring only a `201`. */ const lone201 = listPets([header('Location', { required: true })], { response: pet, responseStatusCode: 201 }); From 87c4ed97221a41b181699f11497f25a553537de3 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 15:00:27 -0400 Subject: [PATCH 08/59] fix(differ)!: keep a variant added to an inline union additive Since #358 a variant added to a named union is an additive `variant-added`, from either side of the wire. A union written inline, as a field's type or an operation's response or request body, is no model, so the differ compared it as one type with `typeRefsEqual`, which wants the same variants in the same positions: adding `$ref: Bird` to a field's `oneOf: [Cat, Dog]` was a breaking `field-type-changed`, and the same edit to a `200` body a breaking `operation-modified`, so `oagen diff` exited 2 for both of the issue's document pairs. `addedUnionVariants` (src/differ/models.ts) tells when two inline unions differ only by variants the new one gained: the old variants still there in their order, every other variant one no old variant is, and nothing else changed but what a named union's `variant-added` carries, judged by `diffVariants` itself (now typed to read an inline `UnionType` as well as a `Model`), so a mapping key the new variant brings is part of the addition while a retargeted key, an alias, a moved or dropped discriminator or a changed composition keyword is not. It reads through the same `nullable`, array or map wrapper on both sides, and never widens an `allOf`. `diffFields` reports such a field as an additive `field-type-changed` whose `details` names the variants (`variant Bird added`), and `diffOperations` keeps such a response or request body out of the operation's breaking flags, while gaining or losing the only 2xx/3xx stays breaking. Every other inline union change, a reorder included, is the breaking type change it was. `docs/cli.md` gains an **Inline unions** paragraph. BREAKING CHANGE: `oagen diff` reports a variant added to an inline union of a field, a response or a request body as additive (exit 1, was 2): a `field-type-changed` entry classified `additive` with `details` such as `variant Bird added`, or a `responseChanged` / `requestBodyChanged` that no longer makes the operation breaking. Closes #428 --- docs/cli.md | 2 + src/differ/models.ts | 60 ++++- src/differ/operations.ts | 24 +- src/differ/types.ts | 15 +- test/differ/inline-unions.test.ts | 360 ++++++++++++++++++++++++++++++ 5 files changed, 447 insertions(+), 14 deletions(-) create mode 100644 test/differ/inline-unions.test.ts diff --git a/docs/cli.md b/docs/cli.md index fe3221d8..bff75fac 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -102,6 +102,8 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Named unions.** A `model-modified` change on a named union (`Model.variants`) lists what moved under `variantChanges`, each entry naming the `variant` by a short type label (`Cat`, `nullable`, `array`). Variants are matched by type, not by position, so a reorder is no change. `variant-added` is additive, like a new enum value, from either side of the wire — a caller may construct the new variant but need not, and a reader may receive it as it may a new enum value; a required field the union model itself gains is a `field-added` entry, judged by direction as any field is. `variant-removed` is breaking. A discriminator mapping entry that comes with an added variant — a new key whose target is a model no variant of the old union names, under any wrapper (`nullable` added beside a kept `Cat` brings no new model, so a key for `Cat` added with it is an alias) — or goes with a removed one is part of that variant's entry, carried as its `details` (`mapping added for 'bird'`, `mapping removed for 'dog'`, the keys of every model the variant names listed once each and sorted) and not reported on its own, so a variant added to a discriminated union is additive whether the spec writes the `mapping` or the parser derives it from the variants' consts; an entry of an undiscriminated union carries no `details`. Every other discriminator change is one breaking `discriminator-changed` entry, since it changes how an existing payload is dispatched: the discriminator added or removed (`details` says which), moved to another property (`property changed from 'kind' to 'type'`), or its mapping changed for a key the old union dispatched otherwise — retargeted, added for a model the old union already dispatched to (or for no variant), or removed while its model stays — with `mapping changed for 'dog'` naming every such key, sorted. A change of composition keyword between two unions is one breaking `composition-changed` entry (`composition changed from 'anyOf' to 'oneOf'`); a model that becomes or stops being a union reports each variant added or removed instead. An allOf+oneOf dispatcher — a model with `discriminator` and no `variants`, whose mapping alone names what it dispatches to — is compared as a union of the mapping's targets, so a target added to or removed from its mapping is the same `variant-added` or `variant-removed`. A change whose only content is a variant entry is still reported (the command exits 1 or 2 by its classification), and variant entries are not mirrored into `behaviorChanges`. +**Inline unions.** A union written inline — a field's type, or an operation's response or request body, bare or under the same `nullable`, array or map wrapper on both sides (a field typed as an array of a `oneOf`) — is not a model, so it is compared as one type, by its variants in order and its discriminator, with one exception: a union that only gained variants is additive, as a variant added to a named union is, from either side of the wire. It only gained variants when the old variants are all still there in their order, every other variant is one no old variant is (inserted anywhere among them), and nothing else changed but what a named union's `variant-added` carries: the same composition keyword where both sides carry one, and the same discriminator but for the mapping keys the new variants bring, judged as under Named unions. On a field that is a `field-type-changed` entry classified additive, its `details` naming the variants by the labels Named unions uses (`variant Bird added`, `variants Bird, Fish added`); on an operation it sets `responseChanged` or `requestBodyChanged` without making the change breaking. Every other difference stays a breaking type change (`field-type-changed` with `details` reading `type changed`, or a breaking `responseChanged` or `requestBodyChanged`): a variant removed or swapped for another, the old variants reordered or one repeated, a mapping key retargeted, added for a variant the old union already had or for none, or dropped while its variant stays, the discriminator added, removed or moved, a variant added beside a changed composition keyword, a member added to an `allOf` (which narrows it), a wrapper added or removed, and an operation gaining or losing its only 2xx/3xx whatever its body. A union written anywhere else — a parameter's or a response header's type, any response body but the primary success one, a secondary media type's body, a webhook's payload — is still compared as one type. + ## `oagen extract` Extract the public API surface from an existing SDK. Produces a JSON file used as input for compat overlay and verification. diff --git a/src/differ/models.ts b/src/differ/models.ts index 652a0af9..bfbd9fea 100644 --- a/src/differ/models.ts +++ b/src/differ/models.ts @@ -1,4 +1,4 @@ -import type { Model, TypeRef } from '../ir/types.js'; +import type { Model, TypeRef, UnionType } from '../ir/types.js'; import { assertNever } from '../ir/types.js'; import type { Change, FieldChange, VariantChange } from './types.js'; import { classifyFieldChange, deprecationDetails, deprecationFlip, extensionsShift } from './classify.js'; @@ -57,6 +57,9 @@ export function diffModels(oldModels: Model[], newModels: Model[], directions?: return changes; } +/** What `diffVariants` reads of a union: a named union's `Model`, or an inline `UnionType`. */ +type UnionSource = Pick & Pick; + /** * Changes to a named union's `variants` and `discriminator`. A variant is * matched by type equality (`typeRefsEqual`), not by position, so reordering @@ -85,9 +88,10 @@ export function diffModels(oldModels: Model[], newModels: Model[], directions?: * change of composition keyword between two unions (`anyOf` → `oneOf` rejects * values that match more than one branch; the reverse loosens what callers may * receive). A model that gains or loses `variants` altogether reports each - * variant added or removed, not a composition change. + * variant added or removed, not a composition change. An inline union is read + * the same way (`addedUnionVariants`). */ -function diffVariants(oldModel: Model, newModel: Model): VariantChange[] { +function diffVariants(oldModel: UnionSource, newModel: UnionSource): VariantChange[] { const changes: VariantChange[] = []; const oldVariants = dispatchedVariants(oldModel); const newVariants = dispatchedVariants(newModel); @@ -164,7 +168,7 @@ function diffVariants(oldModel: Model, newModel: Model): VariantChange[] { * `discriminator.mapping` alone names them — one model ref per distinct * mapping target, in mapping order. A model with neither has none. */ -function dispatchedVariants(model: Model): TypeRef[] { +function dispatchedVariants(model: UnionSource): TypeRef[] { if (model.variants) return model.variants; const names = new Set(Object.values(model.discriminator?.mapping ?? {})); return [...names].map((name) => ({ kind: 'model', name })); @@ -274,6 +278,46 @@ function quoteKeys(keys: readonly string[]): string { return keys.map((k) => `'${k}'`).join(', '); } +/** + * The labels of the variants an inline union gained (`Bird`, `integer`), when + * that is all that changed between `oldType` and `newType`; `undefined` when + * they are not two such unions (#428). Both are unions, or the same + * `nullable`, array or map wrapper around two, and the new union is the old + * one with variants inserted: the old variants in their order — an inline + * union is otherwise compared as one type, variant by variant in order + * (`typeRefsEqual`), so a reorder or a repeat is not this — and every other + * variant one no old variant is. What else moved is judged as for a named + * union (`diffVariants`): a mapping key the new variants bring is part of + * their addition, while a composition keyword changed, or the discriminator + * added, removed, moved or remapped for an old variant, is not a widening. + * Nor is a member added to an `allOf`, which narrows it. Such a change is + * additive as a named union's `variant-added` is, from either side of the + * wire: a caller may send the new variant but need not, and a reader may + * receive it as it may a new enum value. + */ +export function addedUnionVariants(oldType: TypeRef, newType: TypeRef): string[] | undefined { + if (oldType.kind === 'nullable' && newType.kind === 'nullable') { + return addedUnionVariants(oldType.inner, newType.inner); + } + if (oldType.kind === 'array' && newType.kind === 'array') { + return addedUnionVariants(oldType.items, newType.items); + } + if (oldType.kind === 'map' && newType.kind === 'map') { + return addedUnionVariants(oldType.valueType, newType.valueType); + } + if (oldType.kind !== 'union' || newType.kind !== 'union') return undefined; + if (oldType.compositionKind === 'allOf' || newType.compositionKind === 'allOf') return undefined; + let kept = 0; + for (const variant of newType.variants) { + if (kept < oldType.variants.length && typeRefsEqual(oldType.variants[kept], variant)) kept++; + else if (oldType.variants.some((old) => typeRefsEqual(old, variant))) return undefined; + } + if (kept < oldType.variants.length) return undefined; + const changes = diffVariants(oldType, newType); + if (changes.length === 0 || changes.some((change) => change.kind !== 'variant-added')) return undefined; + return changes.map((change) => change.variant); +} + /** A short, human-readable label for a union variant in a change report. */ function variantLabel(ref: TypeRef): string { switch (ref.kind) { @@ -322,11 +366,19 @@ function diffFields(oldModel: Model, newModel: Model, direction: ModelDirection if (!oldField) continue; if (!typeRefsEqual(oldField.type, newField.type)) { + const added = addedUnionVariants(oldField.type, newField.type); if (primitivesDifferOnlyByFormat(oldField.type, newField.type)) { changes.push({ ...classifyFieldChange('field-format-changed', name), details: formatChangeDetails(oldField.type, newField.type), }); + } else if (added) { + changes.push({ + kind: 'field-type-changed', + fieldName: name, + classification: 'additive', + details: `variant${added.length > 1 ? 's' : ''} ${added.join(', ')} added`, + }); } else { changes.push({ kind: 'field-type-changed', diff --git a/src/differ/operations.ts b/src/differ/operations.ts index 3419a616..348c4db7 100644 --- a/src/differ/operations.ts +++ b/src/differ/operations.ts @@ -45,7 +45,7 @@ import { requestBodyEncodingShift, tagsShift, } from './classify.js'; -import { formatChangeDetails, primitivesDifferOnlyByFormat, typeRefsEqual } from './models.js'; +import { addedUnionVariants, formatChangeDetails, primitivesDifferOnlyByFormat, typeRefsEqual } from './models.js'; import { classifySecurityChange, securityShift, type DocumentSecurity } from './security.js'; /** @@ -108,9 +108,8 @@ export function diffOperations( // `response` is `unknown` both for a 2xx/3xx without a body (a lone `204` // or `302`) and for none at all, so the operation gaining or losing its // only 2xx/3xx is told by `responseStatusCode` appearing or disappearing. - const responseChanged = - !typeRefsEqual(oldOp.response, newOp.response) || - (oldOp.responseStatusCode === undefined) !== (newOp.responseStatusCode === undefined); + const successGainedOrLost = (oldOp.responseStatusCode === undefined) !== (newOp.responseStatusCode === undefined); + const responseChanged = !typeRefsEqual(oldOp.response, newOp.response) || successGainedOrLost; const requestBodyChanged = !requestBodiesEqual(oldOp.requestBody, newOp.requestBody); // The required flag and the encoding are compared only while both sides // carry a body: a body appearing or disappearing is already @@ -257,9 +256,20 @@ export function diffOperations( // requestBody: a body appearing as optional is additive — callers gain an // argument they may leave out, like an optional `param-added`. A body // appearing as required, a body disappearing, and a body changing type - // are breaking. An absent flag reads as optional, as it does in OpenAPI. + // are breaking, except a body written as an inline union that only + // gained variants: callers may send the new variant but need not, as + // for a named union's `variant-added` (`addedUnionVariants`, #428). An + // absent flag reads as optional, as it does in OpenAPI. const optionalRequestBodyAdded = !oldOp.requestBody && !!newOp.requestBody && !newOp.requestBodyRequired; - const requestBodyBreaking = requestBodyChanged && !optionalRequestBodyAdded; + const requestBodyWidened = + oldOp.requestBody && newOp.requestBody ? !!addedUnionVariants(oldOp.requestBody, newOp.requestBody) : false; + const requestBodyBreaking = requestBodyChanged && !optionalRequestBodyAdded && !requestBodyWidened; + // response: a new type is breaking, except a response written as an + // inline union that only gained variants, which a reader may now + // receive as it may a new enum value (#428); gaining or losing the only + // 2xx/3xx is breaking whatever the body. + const responseBreaking = + responseChanged && (successGainedOrLost || !addedUnionVariants(oldOp.response, newOp.response)); // requestBodyEncoding: an SDK built for the old spec keeps sending the // representation that spec's body was read from. Breaking when the new // spec no longer accepts it — that media type gone, or its shape @@ -295,7 +305,7 @@ export function diffOperations( classifySecurityChange(security.oldSecurity, security.newSecurity, 'caller') === 'breaking'; const hasBreaking = - responseChanged || + responseBreaking || requestBodyBreaking || requestBodyRequiredBreaking || requestBodyEncodingBreaking || diff --git a/src/differ/types.ts b/src/differ/types.ts index dfdd4184..77423310 100644 --- a/src/differ/types.ts +++ b/src/differ/types.ts @@ -265,7 +265,12 @@ export interface FieldChange { /** For `field-extensions-changed` (additive, see `ExtensionsChanged`): the field's `extensions` on each side, each when it had any there. */ oldExtensions?: Extensions; newExtensions?: Extensions; - /** For `field-deprecated-changed`: `live → deprecated` or `deprecated → live`. */ + /** + * For `field-deprecated-changed`: `live → deprecated` or `deprecated → live`. + * For `field-type-changed`: `type changed`, breaking, or — additive, for a + * field typed by an inline union that only gained variants — `variant Bird + * added` / `variants Bird, Fish added`. + */ details?: string; } @@ -354,13 +359,17 @@ export interface OperationModified { /** * `Operation.response` changed type, or the operation gained or lost its * only 2xx/3xx response (`responseStatusCode` present on one side only), - * with or without a body. Breaking. + * with or without a body. Breaking, except a response written as an + * inline union that only gained variants, which is additive as a named + * union's `variant-added` is. */ responseChanged: boolean; /** * The body appeared, disappeared, or changed type. Breaking, except that a * body appearing with `requestBodyRequired` not `true` is additive: callers - * gain an argument they may leave out, like an optional `param-added`. + * gain an argument they may leave out, like an optional `param-added`; and + * so is a body written as an inline union that only gained variants, as a + * named union's `variant-added` is. */ requestBodyChanged: boolean; /** diff --git a/test/differ/inline-unions.test.ts b/test/differ/inline-unions.test.ts new file mode 100644 index 00000000..18b158cc --- /dev/null +++ b/test/differ/inline-unions.test.ts @@ -0,0 +1,360 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { diffSpecs } from '../../src/differ/diff.js'; +import { diffModels } from '../../src/differ/models.js'; +import { diffOperations } from '../../src/differ/operations.js'; +import type { Change, DiffReport, FieldChange } from '../../src/differ/types.js'; +import type { ApiSpec, Model, Operation, TypeRef, UnionType } from '../../src/ir/types.js'; +import { parseSpec } from '../../src/parser/parse.js'; + +const cat: TypeRef = { kind: 'model', name: 'Cat' }; +const dog: TypeRef = { kind: 'model', name: 'Dog' }; +const bird: TypeRef = { kind: 'model', name: 'Bird' }; +const fish: TypeRef = { kind: 'model', name: 'Fish' }; +const string: TypeRef = { kind: 'primitive', type: 'string' }; +const integer: TypeRef = { kind: 'primitive', type: 'integer' }; + +/** An inline `oneOf` discriminated on `kind`, as the parser reads one over `$ref` variants with no written mapping. */ +const union = (variants: TypeRef[], over: Partial = {}): UnionType => ({ + kind: 'union', + variants, + compositionKind: 'oneOf', + discriminator: { property: 'kind', mapping: {} }, + ...over, +}); + +/** The same union with a written mapping. */ +const mapped = (variants: TypeRef[], mapping: Record): UnionType => + union(variants, { discriminator: { property: 'kind', mapping } }); + +/** `Owner`, whose required `pet` field is typed `type`. */ +const owner = (type: TypeRef): Model => ({ name: 'Owner', fields: [{ name: 'pet', type, required: true }] }); + +/** The field changes `diffModels` reports when `Owner.pet` goes from `before` to `after`. */ +function petChanges(before: TypeRef, after: TypeRef): FieldChange[] { + return diffModels([owner(before)], [owner(after)]).flatMap((c) => + c.kind === 'model-modified' ? c.fieldChanges : [], + ); +} + +const additive = (details: string): FieldChange => ({ + kind: 'field-type-changed', + fieldName: 'pet', + classification: 'additive', + details, +}); +const breaking: FieldChange = { + kind: 'field-type-changed', + fieldName: 'pet', + classification: 'breaking', + details: 'type changed', +}; + +describe('diffModels — a variant added to an inline union (issue #428)', () => { + it("reports a field's union that only gained a variant as an additive field-type-changed naming it", () => { + expect(diffModels([owner(union([cat, dog]))], [owner(union([cat, dog, bird]))])).toEqual([ + { + kind: 'model-modified', + name: 'Owner', + fieldChanges: [additive('variant Bird added')], + deprecatedChanged: false, + classification: 'additive', + }, + ]); + }); + + it('takes a new variant anywhere among the old ones, several at once, and the mapping keys they bring', () => { + expect(petChanges(union([cat, dog]), union([bird, cat, dog]))).toEqual([additive('variant Bird added')]); + expect(petChanges(union([cat, dog]), union([cat, bird, dog]))).toEqual([additive('variant Bird added')]); + expect(petChanges(union([cat, dog]), union([cat, bird, dog, fish]))).toEqual([ + additive('variants Bird, Fish added'), + ]); + expect( + petChanges( + mapped([cat, dog], { cat: 'Cat', dog: 'Dog' }), + mapped([cat, dog, bird], { cat: 'Cat', dog: 'Dog', bird: 'Bird' }), + ), + ).toEqual([additive('variant Bird added')]); + }); + + it('reads an undiscriminated union, an anyOf and a multi-type union alike', () => { + const undiscriminated = (variants: TypeRef[]): TypeRef => ({ kind: 'union', variants, compositionKind: 'oneOf' }); + expect(petChanges(undiscriminated([cat, string]), undiscriminated([cat, string, integer]))).toEqual([ + additive('variant integer added'), + ]); + const anyOf = (variants: TypeRef[]): TypeRef => ({ kind: 'union', variants, compositionKind: 'anyOf' }); + expect(petChanges(anyOf([cat, dog]), anyOf([cat, dog, bird]))).toEqual([additive('variant Bird added')]); + // `type: [string, integer]`, which carries no composition keyword. + const multiType = (variants: TypeRef[]): TypeRef => ({ kind: 'union', variants }); + expect( + petChanges(multiType([string, integer]), multiType([string, integer, { kind: 'primitive', type: 'boolean' }])), + ).toEqual([additive('variant boolean added')]); + }); + + it('reads through the same nullable, array or map wrapper on both sides', () => { + const wrappers: Array<(inner: TypeRef) => TypeRef> = [ + (inner) => ({ kind: 'nullable', inner }), + (inner) => ({ kind: 'array', items: inner }), + (inner) => ({ kind: 'map', valueType: inner }), + (inner) => ({ kind: 'array', items: { kind: 'nullable', inner } }), + ]; + for (const wrap of wrappers) { + expect(petChanges(wrap(union([cat, dog])), wrap(union([cat, dog, bird])))).toEqual([ + additive('variant Bird added'), + ]); + expect(petChanges(wrap(union([cat, dog, bird])), wrap(union([cat, dog])))).toEqual([breaking]); + } + }); + + it('keeps every other change to an inline union a breaking type change', () => { + const cases: Array<[string, TypeRef, TypeRef]> = [ + ['a variant removed', union([cat, dog, bird]), union([cat, dog])], + ['a variant swapped for another', union([cat, dog]), union([cat, bird])], + ['the variants reordered', union([cat, dog]), union([dog, cat])], + ['the variants reordered beside the addition', union([cat, dog]), union([dog, cat, bird])], + ['an old variant repeated beside the addition', union([cat, dog]), union([cat, bird, cat, dog])], + [ + 'a mapping key retargeted beside the addition', + mapped([cat, dog], { cat: 'Cat', dog: 'Dog' }), + mapped([cat, dog, bird], { cat: 'Cat', dog: 'Bird', bird: 'Bird' }), + ], + [ + 'an alias for a kept variant beside the addition', + mapped([cat, dog], { cat: 'Cat', dog: 'Dog' }), + mapped([cat, dog, bird], { cat: 'Cat', dog: 'Dog', bird: 'Bird', kitty: 'Cat' }), + ], + [ + 'the discriminator added beside the addition', + union([cat, dog], { discriminator: undefined }), + union([cat, dog, bird]), + ], + [ + 'the discriminator removed beside the addition', + union([cat, dog]), + union([cat, dog, bird], { discriminator: undefined }), + ], + [ + 'the discriminator moved beside the addition', + union([cat, dog]), + union([cat, dog, bird], { discriminator: { property: 'type', mapping: {} } }), + ], + [ + 'the composition keyword changed beside the addition', + union([cat, dog], { compositionKind: 'anyOf' }), + union([cat, dog, bird]), + ], + [ + 'an allOf gaining a member, which narrows it', + union([cat, dog], { compositionKind: 'allOf', discriminator: undefined }), + union([cat, dog, bird], { compositionKind: 'allOf', discriminator: undefined }), + ], + ['a model becoming a union', cat, union([cat, dog])], + [ + 'a nullable wrapper added beside the addition', + union([cat, dog]), + { kind: 'nullable', inner: union([cat, dog, bird]) }, + ], + [ + 'array items moved under a map beside the addition', + { kind: 'array', items: union([cat, dog]) }, + { kind: 'map', valueType: union([cat, dog, bird]) }, + ], + ]; + for (const [label, before, after] of cases) { + expect([label, petChanges(before, after)]).toEqual([label, [breaking]]); + } + }); +}); + +describe('diffOperations — a variant added to an inline union body (issue #428)', () => { + const getPet = (response: TypeRef): Operation => ({ + name: 'getPet', + httpMethod: 'get', + path: '/pets/{id}', + pathParams: [], + queryParams: [], + headerParams: [], + response, + responseStatusCode: 200, + errors: [], + injectIdempotencyKey: false, + }); + const createPet = (requestBody: TypeRef): Operation => ({ + ...getPet({ kind: 'primitive', type: 'unknown' }), + name: 'createPet', + httpMethod: 'post', + path: '/pets', + responseStatusCode: 204, + requestBody, + requestBodyRequired: true, + }); + const flags = (changes: Change[]) => + changes.map((c) => + c.kind === 'operation-modified' + ? `${c.operationName} responseChanged=${c.responseChanged} requestBodyChanged=${c.requestBodyChanged}: ${c.classification}` + : `${c.kind}: ${c.classification}`, + ); + + it('keeps a response that only gained a variant additive, and its reverse breaking', () => { + const before = getPet(union([cat, dog])); + const after = getPet(union([cat, dog, bird])); + expect(flags(diffOperations('Pets', [before], [after]))).toEqual([ + 'getPet responseChanged=true requestBodyChanged=false: additive', + ]); + expect(flags(diffOperations('Pets', [after], [before]))).toEqual([ + 'getPet responseChanged=true requestBodyChanged=false: breaking', + ]); + }); + + it('keeps a request body that only gained a variant additive, and its reverse breaking', () => { + const before = createPet(union([cat, dog])); + const after = createPet(union([cat, bird, dog])); + expect(flags(diffOperations('Pets', [before], [after]))).toEqual([ + 'createPet responseChanged=false requestBodyChanged=true: additive', + ]); + expect(flags(diffOperations('Pets', [after], [before]))).toEqual([ + 'createPet responseChanged=false requestBodyChanged=true: breaking', + ]); + }); + + it('keeps every other body change breaking', () => { + expect(flags(diffOperations('Pets', [getPet(union([cat, dog]))], [getPet(union([dog, cat, bird]))]))).toEqual([ + 'getPet responseChanged=true requestBodyChanged=false: breaking', + ]); + expect(flags(diffOperations('Pets', [createPet(union([cat, dog]))], [createPet(union([cat, bird]))]))).toEqual([ + 'createPet responseChanged=false requestBodyChanged=true: breaking', + ]); + // The operation losing its only 2xx is breaking whatever its body was. + const { responseStatusCode: _code, ...noSuccess } = getPet(union([cat, dog, bird])); + expect(flags(diffOperations('Pets', [getPet(union([cat, dog]))], [noSuccess]))).toEqual([ + 'getPet responseChanged=true requestBodyChanged=false: breaking', + ]); + }); +}); + +describe("diffSpecs — the issue's documents, end to end (issue #428)", () => { + const ref = (name: string) => `#/components/schemas/${name}`; + const variant = (kind: string) => ({ + type: 'object', + required: ['kind'], + properties: { kind: { type: 'string', const: kind } }, + }); + const pets = { Bird: variant('bird'), Cat: variant('cat'), Dog: variant('dog') }; + const petUnion = (names: string[]) => ({ + oneOf: names.map((name) => ({ $ref: ref(name) })), + discriminator: { propertyName: 'kind' }, + }); + const document = (paths: object, schemas: object) => ({ + openapi: '3.1.0', + info: { title: 'Pets', version: '1.0.0' }, + paths, + components: { schemas: { ...pets, ...schemas } }, + }); + const getById = (operationId: string, schema: object) => ({ + get: { + operationId, + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { '200': { description: 'ok', content: { 'application/json': { schema } } } }, + }, + }); + /** `pet` is a required field of `Owner`, which `GET /owners/{id}` returns. */ + const fieldDocument = (pet: object) => + document( + { '/owners/{id}': getById('getOwner', { $ref: ref('Owner') }) }, + { Owner: { type: 'object', required: ['pet'], properties: { pet } } }, + ); + /** The union is the `200` body of `GET /pets/{id}`. */ + const bodyDocument = (names: string[]) => document({ '/pets/{id}': getById('getPet', petUnion(names)) }, {}); + /** The union is the required request body of `POST /pets`. */ + const requestDocument = (names: string[]) => + document( + { + '/pets': { + post: { + operationId: 'createPet', + requestBody: { required: true, content: { 'application/json': { schema: petUnion(names) } } }, + responses: { '204': { description: 'created' } }, + }, + }, + }, + {}, + ); + + let dir: string; + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'oagen-428-')); + }); + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + async function parse(name: string, doc: object): Promise { + const file = join(dir, `${name}.json`); + writeFileSync(file, JSON.stringify(doc)); + return parseSpec(file); + } + const summarize = (report: DiffReport) => ({ + changes: report.changes.map((c) => { + const name = 'name' in c ? c.name : 'operationName' in c ? c.operationName : c.kind; + const fields = c.kind === 'model-modified' ? c.fieldChanges.map((f) => ` ${f.fieldName} ${f.details}`) : []; + const response = c.kind === 'operation-modified' && c.responseChanged ? ' responseChanged' : ''; + const body = c.kind === 'operation-modified' && c.requestBodyChanged ? ' requestBodyChanged' : ''; + return `${name}${fields.join(',')}${response}${body}: ${c.classification}`; + }), + breaking: report.summary.breaking, + }); + + it('reports a variant added to a field union as additive, and its removal as breaking', async () => { + const oldSpec = await parse('field-old', fieldDocument(petUnion(['Cat', 'Dog']))); + const newSpec = await parse('field-new', fieldDocument(petUnion(['Cat', 'Dog', 'Bird']))); + expect(summarize(diffSpecs(oldSpec, newSpec))).toEqual({ + changes: ['Owner pet variant Bird added: additive'], + breaking: 0, + }); + expect(summarize(diffSpecs(newSpec, oldSpec))).toEqual({ + changes: ['Owner pet type changed: breaking'], + breaking: 1, + }); + }); + + it('reports a variant added to a response union as additive, and its removal as breaking', async () => { + const oldSpec = await parse('body-old', bodyDocument(['Cat', 'Dog'])); + const newSpec = await parse('body-new', bodyDocument(['Cat', 'Dog', 'Bird'])); + const report = diffSpecs(oldSpec, newSpec); + expect(summarize(report)).toEqual({ changes: ['getPet responseChanged: additive'], breaking: 0 }); + expect(report.changes[0]).toMatchObject({ responseContentTypeChanges: [], responseHeaderChanges: [] }); + expect(summarize(diffSpecs(newSpec, oldSpec))).toEqual({ + changes: ['getPet responseChanged: breaking'], + breaking: 1, + }); + }); + + it('reports a variant added to a request body union as additive, and its removal as breaking', async () => { + const oldSpec = await parse('request-old', requestDocument(['Cat', 'Dog'])); + const newSpec = await parse('request-new', requestDocument(['Cat', 'Dog', 'Bird'])); + expect(summarize(diffSpecs(oldSpec, newSpec))).toEqual({ + changes: ['createPet requestBodyChanged: additive'], + breaking: 0, + }); + expect(summarize(diffSpecs(newSpec, oldSpec))).toEqual({ + changes: ['createPet requestBodyChanged: breaking'], + breaking: 1, + }); + }); + + it('reports an undiscriminated `Cat | string` gaining `integer` as additive', async () => { + const oldSpec = await parse( + 'undiscriminated-old', + fieldDocument({ oneOf: [{ $ref: ref('Cat') }, { type: 'string' }] }), + ); + const newSpec = await parse( + 'undiscriminated-new', + fieldDocument({ oneOf: [{ $ref: ref('Cat') }, { type: 'string' }, { type: 'integer' }] }), + ); + expect(summarize(diffSpecs(oldSpec, newSpec))).toEqual({ + changes: ['Owner pet variant integer added: additive'], + breaking: 0, + }); + }); +}); From b402209b505675bca0dd9d5e027ed1322c792570 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 15:07:14 -0400 Subject: [PATCH 09/59] fix(differ): widen an inline union only under one composition keyword `addedUnionVariants` left the keyword to `diffVariants`, which compares two written keywords only. The request-body parser writes none on a `oneOf` it builds (an `anyOf` body keeps `anyOf`), so a request body going from `anyOf: [Cat, Dog]` to `oneOf: [Cat, Dog, Bird]`, or back, passed as a variant added and came out additive, though `oneOf` rejects requests that match both old variants and `anyOf` loosens what the server may accept. The keyword must now be the same on both sides, an unwritten one included, so either transition stays a breaking `requestBodyChanged`. --- docs/cli.md | 2 +- src/differ/models.ts | 21 ++++++++++-------- test/differ/inline-unions.test.ts | 36 ++++++++++++++++++++++++++----- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index bff75fac..4f97deda 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -102,7 +102,7 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Named unions.** A `model-modified` change on a named union (`Model.variants`) lists what moved under `variantChanges`, each entry naming the `variant` by a short type label (`Cat`, `nullable`, `array`). Variants are matched by type, not by position, so a reorder is no change. `variant-added` is additive, like a new enum value, from either side of the wire — a caller may construct the new variant but need not, and a reader may receive it as it may a new enum value; a required field the union model itself gains is a `field-added` entry, judged by direction as any field is. `variant-removed` is breaking. A discriminator mapping entry that comes with an added variant — a new key whose target is a model no variant of the old union names, under any wrapper (`nullable` added beside a kept `Cat` brings no new model, so a key for `Cat` added with it is an alias) — or goes with a removed one is part of that variant's entry, carried as its `details` (`mapping added for 'bird'`, `mapping removed for 'dog'`, the keys of every model the variant names listed once each and sorted) and not reported on its own, so a variant added to a discriminated union is additive whether the spec writes the `mapping` or the parser derives it from the variants' consts; an entry of an undiscriminated union carries no `details`. Every other discriminator change is one breaking `discriminator-changed` entry, since it changes how an existing payload is dispatched: the discriminator added or removed (`details` says which), moved to another property (`property changed from 'kind' to 'type'`), or its mapping changed for a key the old union dispatched otherwise — retargeted, added for a model the old union already dispatched to (or for no variant), or removed while its model stays — with `mapping changed for 'dog'` naming every such key, sorted. A change of composition keyword between two unions is one breaking `composition-changed` entry (`composition changed from 'anyOf' to 'oneOf'`); a model that becomes or stops being a union reports each variant added or removed instead. An allOf+oneOf dispatcher — a model with `discriminator` and no `variants`, whose mapping alone names what it dispatches to — is compared as a union of the mapping's targets, so a target added to or removed from its mapping is the same `variant-added` or `variant-removed`. A change whose only content is a variant entry is still reported (the command exits 1 or 2 by its classification), and variant entries are not mirrored into `behaviorChanges`. -**Inline unions.** A union written inline — a field's type, or an operation's response or request body, bare or under the same `nullable`, array or map wrapper on both sides (a field typed as an array of a `oneOf`) — is not a model, so it is compared as one type, by its variants in order and its discriminator, with one exception: a union that only gained variants is additive, as a variant added to a named union is, from either side of the wire. It only gained variants when the old variants are all still there in their order, every other variant is one no old variant is (inserted anywhere among them), and nothing else changed but what a named union's `variant-added` carries: the same composition keyword where both sides carry one, and the same discriminator but for the mapping keys the new variants bring, judged as under Named unions. On a field that is a `field-type-changed` entry classified additive, its `details` naming the variants by the labels Named unions uses (`variant Bird added`, `variants Bird, Fish added`); on an operation it sets `responseChanged` or `requestBodyChanged` without making the change breaking. Every other difference stays a breaking type change (`field-type-changed` with `details` reading `type changed`, or a breaking `responseChanged` or `requestBodyChanged`): a variant removed or swapped for another, the old variants reordered or one repeated, a mapping key retargeted, added for a variant the old union already had or for none, or dropped while its variant stays, the discriminator added, removed or moved, a variant added beside a changed composition keyword, a member added to an `allOf` (which narrows it), a wrapper added or removed, and an operation gaining or losing its only 2xx/3xx whatever its body. A union written anywhere else — a parameter's or a response header's type, any response body but the primary success one, a secondary media type's body, a webhook's payload — is still compared as one type. +**Inline unions.** A union written inline — a field's type, or an operation's response or request body, bare or under the same `nullable`, array or map wrapper on both sides (a field typed as an array of a `oneOf`) — is not a model, so it is compared as one type, by its variants in order and its discriminator, with one exception: a union that only gained variants is additive, as a variant added to a named union is, from either side of the wire. It only gained variants when the old variants are all still there in their order, every other variant is one no old variant is (inserted anywhere among them), and nothing else changed but what a named union's `variant-added` carries: the same composition keyword, and the same discriminator but for the mapping keys the new variants bring, judged as under Named unions. On a field that is a `field-type-changed` entry classified additive, its `details` naming the variants by the labels Named unions uses (`variant Bird added`, `variants Bird, Fish added`); on an operation it sets `responseChanged` or `requestBodyChanged` without making the change breaking. Every other difference stays a breaking type change (`field-type-changed` with `details` reading `type changed`, or a breaking `responseChanged` or `requestBodyChanged`): a variant removed or swapped for another, the old variants reordered or one repeated, a mapping key retargeted, added for a variant the old union already had or for none, or dropped while its variant stays, the discriminator added, removed or moved, a variant added beside a changed composition keyword, a member added to an `allOf` (which narrows it), a wrapper added or removed, and an operation gaining or losing its only 2xx/3xx whatever its body. A union written anywhere else — a parameter's or a response header's type, any response body but the primary success one, a secondary media type's body, a webhook's payload — is still compared as one type. ## `oagen extract` diff --git a/src/differ/models.ts b/src/differ/models.ts index bfbd9fea..cac960e9 100644 --- a/src/differ/models.ts +++ b/src/differ/models.ts @@ -286,14 +286,14 @@ function quoteKeys(keys: readonly string[]): string { * one with variants inserted: the old variants in their order — an inline * union is otherwise compared as one type, variant by variant in order * (`typeRefsEqual`), so a reorder or a repeat is not this — and every other - * variant one no old variant is. What else moved is judged as for a named - * union (`diffVariants`): a mapping key the new variants bring is part of - * their addition, while a composition keyword changed, or the discriminator - * added, removed, moved or remapped for an old variant, is not a widening. - * Nor is a member added to an `allOf`, which narrows it. Such a change is - * additive as a named union's `variant-added` is, from either side of the - * wire: a caller may send the new variant but need not, and a reader may - * receive it as it may a new enum value. + * variant one no old variant is. The composition keyword is the same, and + * what else moved is judged as for a named union (`diffVariants`): a mapping + * key the new variants bring is part of their addition, while the + * discriminator added, removed, moved or remapped for an old variant is not + * a widening. Nor is a member added to an `allOf`, which narrows it. Such a + * change is additive as a named union's `variant-added` is, from either side + * of the wire: a caller may send the new variant but need not, and a reader + * may receive it as it may a new enum value. */ export function addedUnionVariants(oldType: TypeRef, newType: TypeRef): string[] | undefined { if (oldType.kind === 'nullable' && newType.kind === 'nullable') { @@ -306,7 +306,10 @@ export function addedUnionVariants(oldType: TypeRef, newType: TypeRef): string[] return addedUnionVariants(oldType.valueType, newType.valueType); } if (oldType.kind !== 'union' || newType.kind !== 'union') return undefined; - if (oldType.compositionKind === 'allOf' || newType.compositionKind === 'allOf') return undefined; + // The same keyword, written or not on both sides: `diffVariants` compares + // two written ones only, and the request-body parser writes none on a + // `oneOf`, so `anyOf` beside it would pass for no change. + if (oldType.compositionKind !== newType.compositionKind || oldType.compositionKind === 'allOf') return undefined; let kept = 0; for (const variant of newType.variants) { if (kept < oldType.variants.length && typeRefsEqual(oldType.variants[kept], variant)) kept++; diff --git a/test/differ/inline-unions.test.ts b/test/differ/inline-unions.test.ts index 18b158cc..e331d923 100644 --- a/test/differ/inline-unions.test.ts +++ b/test/differ/inline-unions.test.ts @@ -145,6 +145,12 @@ describe('diffModels — a variant added to an inline union (issue #428)', () => union([cat, dog], { compositionKind: 'anyOf' }), union([cat, dog, bird]), ], + [ + // The request-body parser writes no keyword on a `oneOf` it builds. + 'a keyword written on one side only beside the addition', + union([cat, dog], { compositionKind: 'anyOf' }), + union([cat, dog, bird], { compositionKind: undefined }), + ], [ 'an allOf gaining a member, which narrows it', union([cat, dog], { compositionKind: 'allOf', discriminator: undefined }), @@ -267,14 +273,14 @@ describe("diffSpecs — the issue's documents, end to end (issue #428)", () => { ); /** The union is the `200` body of `GET /pets/{id}`. */ const bodyDocument = (names: string[]) => document({ '/pets/{id}': getById('getPet', petUnion(names)) }, {}); - /** The union is the required request body of `POST /pets`. */ - const requestDocument = (names: string[]) => + /** `schema` is the required request body of `POST /pets`. */ + const requestDocument = (schema: object) => document( { '/pets': { post: { operationId: 'createPet', - requestBody: { required: true, content: { 'application/json': { schema: petUnion(names) } } }, + requestBody: { required: true, content: { 'application/json': { schema } } }, responses: { '204': { description: 'created' } }, }, }, @@ -331,8 +337,8 @@ describe("diffSpecs — the issue's documents, end to end (issue #428)", () => { }); it('reports a variant added to a request body union as additive, and its removal as breaking', async () => { - const oldSpec = await parse('request-old', requestDocument(['Cat', 'Dog'])); - const newSpec = await parse('request-new', requestDocument(['Cat', 'Dog', 'Bird'])); + const oldSpec = await parse('request-old', requestDocument(petUnion(['Cat', 'Dog']))); + const newSpec = await parse('request-new', requestDocument(petUnion(['Cat', 'Dog', 'Bird']))); expect(summarize(diffSpecs(oldSpec, newSpec))).toEqual({ changes: ['createPet requestBodyChanged: additive'], breaking: 0, @@ -343,6 +349,26 @@ describe("diffSpecs — the issue's documents, end to end (issue #428)", () => { }); }); + it('keeps a request body whose keyword moved between anyOf and oneOf beside the addition breaking', async () => { + // The request-body parser builds a `oneOf` with no `compositionKind`, an `anyOf` with one (Greptile). + const anyOf = (names: string[]) => { + const { oneOf, ...rest } = petUnion(names); + return { ...rest, anyOf: oneOf }; + }; + const pairs: Array<[string, object, object]> = [ + ['anyOf-to-oneOf', anyOf(['Cat', 'Dog']), petUnion(['Cat', 'Dog', 'Bird'])], + ['oneOf-to-anyOf', petUnion(['Cat', 'Dog']), anyOf(['Cat', 'Dog', 'Bird'])], + ]; + for (const [pair, before, after] of pairs) { + const oldSpec = await parse(`${pair}-old`, requestDocument(before)); + const newSpec = await parse(`${pair}-new`, requestDocument(after)); + expect([pair, summarize(diffSpecs(oldSpec, newSpec))]).toEqual([ + pair, + { changes: ['createPet requestBodyChanged: breaking'], breaking: 1 }, + ]); + } + }); + it('reports an undiscriminated `Cat | string` gaining `integer` as additive', async () => { const oldSpec = await parse( 'undiscriminated-old', From c4766aab05cb9c9c92862e1d8dee4bbcf1e243eb Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 15:23:22 -0400 Subject: [PATCH 10/59] fix(differ): report a list envelope field added, removed or retyped `PaginationMeta.envelopeFields` is every property of an unwrapped list envelope besides the data array, what an emitter types the envelope from. `diffOperations` compared those fields only for their `extensions` (`diffEnvelopeFieldExtensions`), skipping a field one side lacked, so `total_count` added to `{ data, has_more }`, removed from it or retyped to `string` was no change and `oagen diff` exited 0. The envelope's fields are now compared as a model's fields are, by `diffFields` (src/differ/models.ts, now exported and reading anything that carries `fields`), judged as a response-only model since the SDK only ever reads the envelope: a field added is additive, required or not; one removed or retyped is breaking; format, requiredness, access flags, `deprecated` and extensions are classified as on any model's field, each an entry in `envelopeFieldChanges`. The operation is breaking when an entry is. A deprecation flip on an envelope field is mirrored into `behaviorChanges` as an `envelope-field` node, as its extension change already was. `docs/cli.md` gains a **List envelopes** paragraph; the Deprecation and Extensions paragraphs, the Deprecated section of `docs/architecture/ir-types.md` and the `envelopeFieldChanges` JSDoc say the same. Closes #439 --- docs/architecture/ir-types.md | 2 +- docs/cli.md | 8 +- src/differ/diff.ts | 20 +- src/differ/models.ts | 11 +- src/differ/operations.ts | 55 ++--- src/differ/types.ts | 24 +- test/differ/deprecated.test.ts | 68 ++++++ test/differ/pagination-changes.test.ts | 294 ++++++++++++++++++++++++- 8 files changed, 424 insertions(+), 58 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 6269b75a..03e8f2c1 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -823,7 +823,7 @@ Wherever two inline models are folded into one by structural agreement — list- **Source per node.** Each node reads `deprecated` from one place: `Operation` from the Operation Object's own keyword; `Parameter` from any of the Parameter Object (after a `components.parameters` `$ref` is resolved), its inline `schema` and the component schema its `schema.$ref` points at — deprecated when any of the three says `deprecated: true`, so a `false` on one level does not stop a `true` on another; `ResponseHeader` from the Header Object only (after a `components/headers` `$ref` is resolved), never from its schema; `Field` from the property schema itself, a keyword written beside a `$ref` included, while the referenced model keeps its own and an `allOf` member's is not read — or, for a bare `$ref` to an array component, which has no model to keep the flag, from the component as well (see Array components); `Model` and `Enum` from the schema's own top-level keyword as described above. At every site the parser tests `=== true`: a `false`, absent or non-boolean keyword (`"yes"`, `1`) leaves the key absent, so the IR never carries anything but a literal `true`, on any of the six nodes. `EnumValue.deprecated` reads the value's own `const` member — an enum declared as a `oneOf`/`anyOf` of `const` schemas — by the same `=== true` rule, and is absent on every value of a plain `enum:` list, which has no per-value keyword (see Enum below). -The differ compares the flag on every node that carries it — operation, parameter, response header, model, field, enum, enum value, webhook and webhook parameter — and reads it the way the parser writes it: only `true` is deprecated, so `false` and an absent key are the same thing, and a document that spells out `deprecated: false` diffs as unchanged against one that omits it. A flip is reported on the owning `-modified` change: `deprecatedChanged: true` on `operation-modified`, `model-modified`, `enum-modified` and `webhook-modified`, with `oldDeprecated`/`newDeprecated` beside it (present exactly when the flag is); a `param-deprecated-changed`, `header-deprecated-changed`, `field-deprecated-changed` or `value-deprecated-changed` entry, carrying the same two values and a `details` that reads `live → deprecated` or `deprecated → live`, in the change's `paramChanges`, `responseHeaderChanges`, `fieldChanges` or `valueChanges` for an operation's or a webhook's parameter, an operation's response header, a field or an enum value. A model, enum, operation or webhook whose only change is the flag is still reported, with an empty nested list. Becoming or ceasing to be deprecated changes no contract — the node is still there with the same shape — so every one of these is additive, in either direction; it still counts as `modified` in the summary, and a breaking change on the same node (a removed field beside the model's flag, a changed response beside the operation's, a retyped payload beside the webhook's) keeps the change breaking. Each flip is also mirrored into `behaviorChanges` as a `deprecated-changed` entry naming the node (`operation`, `parameter`, `response-header`, `model`, `field`, `enum`, `enum-value`, `webhook` or `webhook-parameter`) and copying `oldDeprecated`/`newDeprecated` from the change, the way a parameter default change is, so a changelog can say which way it went without reading both specs. Parameters are compared in every location a node carries them — an operation's path, query, header and cookie parameters, a webhook's header, query and cookie parameters — and matched by location and name, since a path `id` and a query `id` may sit on one operation and a webhook's header and query parameters may share a name — a header's name compared case-insensitively, so a header deprecated and respelled `x-pet-token` in one step is still the `X-Pet-Token` it was, and its flip is reported; every `paramChanges` entry carries `paramLocation` to say which one it is about. A response header's flag is compared on every response entry that declares headers — the primary and every other 2xx/3xx entry, every error, the `default` and every range — with headers matched by name case-insensitively, and every `responseHeaderChanges` entry carries its `statusCode` or `fallback` to say which entry it is about (see Response Headers above). +The differ compares the flag on every node that carries it — operation, parameter, response header, model, field, list envelope field, enum, enum value, webhook and webhook parameter — and reads it the way the parser writes it: only `true` is deprecated, so `false` and an absent key are the same thing, and a document that spells out `deprecated: false` diffs as unchanged against one that omits it. A flip is reported on the owning `-modified` change: `deprecatedChanged: true` on `operation-modified`, `model-modified`, `enum-modified` and `webhook-modified`, with `oldDeprecated`/`newDeprecated` beside it (present exactly when the flag is); a `param-deprecated-changed`, `header-deprecated-changed`, `field-deprecated-changed` or `value-deprecated-changed` entry, carrying the same two values and a `details` that reads `live → deprecated` or `deprecated → live`, in the change's `paramChanges`, `responseHeaderChanges`, `fieldChanges`, `envelopeFieldChanges` or `valueChanges` for an operation's or a webhook's parameter, an operation's response header, a field, an operation's list envelope field (`pagination.envelopeFields`, matched by name while both specs paginate) or an enum value. A model, enum, operation or webhook whose only change is the flag is still reported, with an empty nested list. Becoming or ceasing to be deprecated changes no contract — the node is still there with the same shape — so every one of these is additive, in either direction; it still counts as `modified` in the summary, and a breaking change on the same node (a removed field beside the model's flag, a changed response beside the operation's, a retyped payload beside the webhook's) keeps the change breaking. Each flip is also mirrored into `behaviorChanges` as a `deprecated-changed` entry naming the node (`operation`, `parameter`, `response-header`, `model`, `field`, `envelope-field`, `enum`, `enum-value`, `webhook` or `webhook-parameter`) and copying `oldDeprecated`/`newDeprecated` from the change, the way a parameter default change is, so a changelog can say which way it went without reading both specs. Parameters are compared in every location a node carries them — an operation's path, query, header and cookie parameters, a webhook's header, query and cookie parameters — and matched by location and name, since a path `id` and a query `id` may sit on one operation and a webhook's header and query parameters may share a name — a header's name compared case-insensitively, so a header deprecated and respelled `x-pet-token` in one step is still the `X-Pet-Token` it was, and its flip is reported; every `paramChanges` entry carries `paramLocation` to say which one it is about. A response header's flag is compared on every response entry that declares headers — the primary and every other 2xx/3xx entry, every error, the `default` and every range — with headers matched by name case-insensitively, and every `responseHeaderChanges` entry carries its `statusCode` or `fallback` to say which entry it is about (see Response Headers above). ## Enum diff --git a/docs/cli.md b/docs/cli.md index 4f97deda..92b7c956 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -59,7 +59,7 @@ Every entry in `changes` carries a `kind` and a `classification` (`additive` or | `service-removed` | breaking | `name` | | `operation-added` | additive | `serviceName`, `operationName` | | `operation-removed` | breaking | `serviceName`, `operationName` | -| `operation-modified` | from `paramChanges` and the `…Changed` flags | `serviceName`, `operationName`, `paramChanges[]`, `responseChanged`, `requestBodyChanged`, `pathChanged`, … (see `OperationModified` in `src/differ/types.ts`) | +| `operation-modified` | from `paramChanges`, `envelopeFieldChanges` and the `…Changed` flags | `serviceName`, `operationName`, `paramChanges[]`, `responseChanged`, `requestBodyChanged`, `pathChanged`, … (see `OperationModified` in `src/differ/types.ts`) | | `webhook-added` | additive | `name`, `httpMethod` (the pair that identifies a webhook, see [Webhook](architecture/ir-types.md#webhook)) | | `webhook-removed` | breaking | `name`, `httpMethod` | | `webhook-modified` | from `paramChanges` and the `…Changed` flags, read from the receiver's side | `name`, `httpMethod`, `paramChanges[]`, `requestBodyChanged`, `responsesChanged`, `deprecatedChanged`, `securityChanged`, `tagsChanged`, … (see `WebhookModified` in `src/differ/types.ts`) | @@ -78,7 +78,7 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve | `variable-enum-value-added` | additive | `value` can now be picked | | `variable-enum-value-removed` | breaking; additive when the template never references it | `value` can no longer be picked | -**Deprecation.** A node becoming deprecated, or no longer being deprecated, is reported and is additive in either direction (nothing a caller compiles against changes): `deprecatedChanged: true` on an `operation-modified`, `model-modified`, `enum-modified` or `webhook-modified` change, with `oldDeprecated`/`newDeprecated` beside it, and a `param-deprecated-changed`, `header-deprecated-changed`, `field-deprecated-changed` or `value-deprecated-changed` entry — carrying the same two values and `details` reading `live → deprecated` or `deprecated → live` — in that change's `paramChanges` (an operation's or a webhook's), `responseHeaderChanges` (an operation's response headers, see Response headers below), `fieldChanges` or `valueChanges`. A change whose only content is the flag is still reported (so the command exits 1), and a breaking change on the same node keeps the change breaking. Only `true` counts as deprecated: `false` and an absent key are the same thing. Each flip is also mirrored into `behaviorChanges` as a `deprecated-changed` entry naming the node (`operation`, `parameter`, `response-header`, `model`, `field`, `enum`, `enum-value`, `webhook` or `webhook-parameter`) and copying `oldDeprecated`/`newDeprecated` from the change, beside the `param-default-changed` entries collected there; a `response-header` entry also carries the header's `statusCode` or `fallback` when its change does. +**Deprecation.** A node becoming deprecated, or no longer being deprecated, is reported and is additive in either direction (nothing a caller compiles against changes): `deprecatedChanged: true` on an `operation-modified`, `model-modified`, `enum-modified` or `webhook-modified` change, with `oldDeprecated`/`newDeprecated` beside it, and a `param-deprecated-changed`, `header-deprecated-changed`, `field-deprecated-changed` or `value-deprecated-changed` entry — carrying the same two values and `details` reading `live → deprecated` or `deprecated → live` — in that change's `paramChanges` (an operation's or a webhook's), `responseHeaderChanges` (an operation's response headers, see Response headers below), `fieldChanges`, `envelopeFieldChanges` (an operation's list envelope fields, see List envelopes below) or `valueChanges`. A change whose only content is the flag is still reported (so the command exits 1), and a breaking change on the same node keeps the change breaking. Only `true` counts as deprecated: `false` and an absent key are the same thing. Each flip is also mirrored into `behaviorChanges` as a `deprecated-changed` entry naming the node (`operation`, `parameter`, `response-header`, `model`, `field`, `envelope-field`, `enum`, `enum-value`, `webhook` or `webhook-parameter`) and copying `oldDeprecated`/`newDeprecated` from the change, beside the `param-default-changed` entries collected there; a `response-header` entry also carries the header's `statusCode` or `fallback` when its change does, and an `envelope-field` entry the operation and field names, as its `extensions-changed` entry does. **Documentation.** A `description` is not compared on any node — a model's, a field's, an enum's, an enum value's, an operation's, a parameter's, a response header's, a response's, a request body's — so a change to one, including a description appearing or disappearing, is not reported and the command exits 0 when nothing else changed: documentation is not shape, and no change carries a flag for it. A body comparison that reads a model or enum ref through to its shape (see Response media types) ignores documentation the same way. @@ -86,7 +86,7 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Tags.** Operations are matched by name within a service, and the parser groups an operation into a service by `tags[0]` — the first usable entry, non-empty strings only, repeats folded (then the path-prefix split, see [Service naming](architecture/ir-types.md#service--operation)), so a `tags` edit is reported by where the operation ends up. When it stays in a service of the same name — any edit that leaves the first entry alone, a first tag respelled to the same service name (`pets` → `Pets`), or a first tag changed to one the split files back under the same path-named service — it is `tagsChanged: true` on the `operation-modified` change, with `oldTags`/`newTags` beside it (`[]` for none; the lists are compared in document order, so a reorder after the first entry counts). When the first entry moves it to a service of another name, there is no `operation-modified` to carry a flag: the diff reports the move, `operation-removed` from the old service (breaking) and `operation-added` to the new one (additive), or `service-removed` (breaking) and `service-added` (additive) when a service goes or comes with it — so swapping `['Pets', 'Store']` to `['Store', 'Pets']` on a document's only operation exits 2. A webhook belongs to no service, so every edit to its `tags` is `tagsChanged` on its `webhook-modified` change. `tagsChanged` is always additive: tags group and document a method and change nothing a caller compiles against. A change whose only content is such a tags edit is still reported (so the command exits 1), and tags are not mirrored into `behaviorChanges`. -**Extensions.** A node's `extensions` — the `x-*` keys the operation, model, field or enum was built with (see [Extensions](architecture/ir-types.md#extensions)) — differing between the two specs is reported and is additive: a key added, removed or given another value, compared deeply with key order aside, an absent key and an empty object both reading as none. It is `extensionsChanged: true` on the `operation-modified`, `model-modified` or `enum-modified` change, with `oldExtensions`/`newExtensions` beside it — each present only when the node had extensions on that side — and a `field-extensions-changed` entry carrying the same two values in a `model-modified` change's `fieldChanges`. A list envelope's fields (`Operation.pagination.envelopeFields`) are matched by name while both specs paginate, and an extension change on one — a scalar companion such as `has_more`, which no model carries — is a `field-extensions-changed` entry in the `operation-modified` change's `envelopeFieldChanges` (present only when there is one); a companion object's model is compared like any model, so its fields report under `model-modified`. No other property of an envelope field is compared. The flag is present only when the extensions differ, unlike `deprecatedChanged`, so a change report without an extension change keeps its shape. Extensions are not shape: a change to them is never a `field-type-changed`, `param-type-changed` or retyped body, a body comparison that reads a model or enum ref through to its shape ignores them as it ignores documentation, and a breaking change on the same node keeps the change breaking. A change whose only content is an extension change is still reported (so the command exits 1), since an emitter may derive behavior from a key it knows. Each is mirrored into `behaviorChanges` as an `extensions-changed` entry naming the node (`operation`, `model`, `field`, `enum` or `envelope-field`, the last with the operation and field names) and copying `oldExtensions`/`newExtensions` from the change. A parameter, response header, webhook, request body or response object carries no `extensions`, so none is compared there. +**Extensions.** A node's `extensions` — the `x-*` keys the operation, model, field or enum was built with (see [Extensions](architecture/ir-types.md#extensions)) — differing between the two specs is reported and is additive: a key added, removed or given another value, compared deeply with key order aside, an absent key and an empty object both reading as none. It is `extensionsChanged: true` on the `operation-modified`, `model-modified` or `enum-modified` change, with `oldExtensions`/`newExtensions` beside it — each present only when the node had extensions on that side — and a `field-extensions-changed` entry carrying the same two values in a `model-modified` change's `fieldChanges`. A list envelope's fields (`Operation.pagination.envelopeFields`) are compared as a model's fields are (see List envelopes below), so an extension change on one — a scalar companion such as `has_more`, which no model carries — is a `field-extensions-changed` entry in the `operation-modified` change's `envelopeFieldChanges`; a companion object's model is compared like any model, so its fields report under `model-modified`. The flag is present only when the extensions differ, unlike `deprecatedChanged`, so a change report without an extension change keeps its shape. Extensions are not shape: a change to them is never a `field-type-changed`, `param-type-changed` or retyped body, a body comparison that reads a model or enum ref through to its shape ignores them as it ignores documentation, and a breaking change on the same node keeps the change breaking. A change whose only content is an extension change is still reported (so the command exits 1), since an emitter may derive behavior from a key it knows. Each is mirrored into `behaviorChanges` as an `extensions-changed` entry naming the node (`operation`, `model`, `field`, `enum` or `envelope-field`, the last with the operation and field names) and copying `oldExtensions`/`newExtensions` from the change. A parameter, response header, webhook, request body or response object carries no `extensions`, so none is compared there. **Parameters.** An operation's parameters are compared in every location it carries them — path, query, header and cookie — and a webhook's in header, query and cookie, through one walk, so a change is reported wherever the parameter lives. Parameters are matched by location and name (`parameterKey` in `src/ir/parameter-key.ts`, the identity the parser merges path-item and operation parameters on) — OpenAPI allows a path `id` beside a query `id` on one operation — and every `paramChanges` entry carries `paramLocation` (`path`, `query`, `header` or `cookie` on an operation; `header`, `query` or `cookie` on a webhook) to say which one it is about. A header's name is matched case-insensitively, since HTTP header names are (RFC 9110 §5.1): a header written `X-Pet-Token` in one spec and `x-pet-token` in the other is one parameter, its type, requiredness, default and `deprecated` compared as usual, and the spelling change is a `param-name-changed` entry — additive, with `oldName`/`newName` and `details` reading `name X-Pet-Token → x-pet-token` — beside whatever else changed, not a `param-removed` beside a `param-added`. The inline enum or model a header's schema mints is named from the lower-cased header name on both sides (`PetsXRatelimitPolicy` for `X-RateLimit-Policy` and `x-ratelimit-policy` alike; the same for a response header's), so a case change leaves the header's type, and the `enums` and `models` lists, where they were: no `param-type-changed`, no `enum-removed`, no `model-removed`. On the entries of a paired parameter, `paramName` is the name as the new spec writes it; a `param-removed` entry carries the old spec's name, which the new spec no longer has, and a `param-added` entry the new spec's. Path, query and cookie names are case-sensitive, so `Limit` and `limit` are two query parameters. A `param-name-changed` entry is not mirrored into `behaviorChanges`. A query parameter the request body owns (`Operation.bodyOwnedQueryParams`: declared as a body property too, on a body-carrying method) is not compared as a parameter — it travels in the body, whose model the differ compares field by field — so a query parameter the body takes over is reported as `param-removed` at `query`. A parameter's `contentType` — the `content` key its value is serialized under, absent for a `schema` parameter (see `Parameter` in [docs/architecture/ir-types.md](architecture/ir-types.md)) — is compared by media type essence (`type/subtype`, case folded, parameters stripped): a parameter that moves from `schema` to `content` or back, or between two essences (`application/json` → `text/plain`), is a `param-content-type-changed` entry, breaking in both directions and on a webhook too, since the value is encoded another way on the wire; it carries `oldContentType`/`newContentType` (each as written, `null` for the `schema` side) and `details` reading `content type → application/json`, and is reported beside, not instead of, whatever else changed about the parameter — a parameter whose schema is the same on both sides reports it alone, with no `param-type-changed`, `enum-removed` or `model-removed`. A respelling that keeps the essence (`application/json` → `application/json; charset=utf-8`) is not reported. The entry is not mirrored into `behaviorChanges`. @@ -104,6 +104,8 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Inline unions.** A union written inline — a field's type, or an operation's response or request body, bare or under the same `nullable`, array or map wrapper on both sides (a field typed as an array of a `oneOf`) — is not a model, so it is compared as one type, by its variants in order and its discriminator, with one exception: a union that only gained variants is additive, as a variant added to a named union is, from either side of the wire. It only gained variants when the old variants are all still there in their order, every other variant is one no old variant is (inserted anywhere among them), and nothing else changed but what a named union's `variant-added` carries: the same composition keyword, and the same discriminator but for the mapping keys the new variants bring, judged as under Named unions. On a field that is a `field-type-changed` entry classified additive, its `details` naming the variants by the labels Named unions uses (`variant Bird added`, `variants Bird, Fish added`); on an operation it sets `responseChanged` or `requestBodyChanged` without making the change breaking. Every other difference stays a breaking type change (`field-type-changed` with `details` reading `type changed`, or a breaking `responseChanged` or `requestBodyChanged`): a variant removed or swapped for another, the old variants reordered or one repeated, a mapping key retargeted, added for a variant the old union already had or for none, or dropped while its variant stays, the discriminator added, removed or moved, a variant added beside a changed composition keyword, a member added to an `allOf` (which narrows it), a wrapper added or removed, and an operation gaining or losing its only 2xx/3xx whatever its body. A union written anywhere else — a parameter's or a response header's type, any response body but the primary success one, a secondary media type's body, a webhook's payload — is still compared as one type. +**List envelopes.** When the parser unwraps a list envelope, the envelope's other properties — the pagination companion (`has_more`, `list_metadata`, `total_count`, …) and any discriminant such as `object: "list"` — are `Operation.pagination.envelopeFields` (see [PaginationMeta](architecture/ir-types.md#paginationmeta)), which an emitter types the envelope from beside the data array. While both specs paginate they are compared as a model's fields are, matched by name, an absent list reading as no field, and what changed is listed under the `operation-modified` change's `envelopeFieldChanges` (present only when there is an entry) with the entries and classifications a model's `fieldChanges` would carry, the envelope judged as a response-only model since the SDK only ever reads it: a field added is an additive `field-added`, a required one included (`details` reading `required on a response-only model`); a field removed is a breaking `field-removed`; a field retyped is a breaking `field-type-changed`, except that a format-only change is an additive `field-format-changed` and an inline union that only gained variants is additive (see Inline unions above); and `field-required-changed`, `field-access-changed`, `field-deprecated-changed` and `field-extensions-changed` report the rest as on a model's field, the last two mirrored into `behaviorChanges` as an `envelope-field` node (see Deprecation and Extensions above). The operation is breaking when an entry is, so `total_count` added to a `{ data, has_more }` envelope exits 1, and removed or retyped exits 2. A companion object's own fields are compared on its model under `model-modified`; its envelope field is an entry only when the field itself changes, the model it names included (`ListMetadata` → `Pagination` is a `field-type-changed`). Envelope fields are not compared while only one spec paginates, which `paginatedChanged` reports, and a list response kept as a model (a `$ref` to a component, say) carries none: that model's fields are compared under `model-modified`. + ## `oagen extract` Extract the public API surface from an existing SDK. Produces a JSON file used as input for compat overlay and verification. diff --git a/src/differ/diff.ts b/src/differ/diff.ts index 8d02467b..b67b9d59 100644 --- a/src/differ/diff.ts +++ b/src/differ/diff.ts @@ -214,18 +214,14 @@ function collectBehaviorChanges(changes: Change[]): BehaviorChange[] { // A list envelope's field, which no model carries, named by the // operation it belongs to. for (const fc of c.envelopeFieldChanges ?? []) { - if (fc.kind !== 'field-extensions-changed') continue; - out.push( - extended( - { - node: 'envelope-field', - serviceName: c.serviceName, - operationName: c.operationName, - fieldName: fc.fieldName, - }, - fc, - ), - ); + const field: ExtensionsNode = { + node: 'envelope-field', + serviceName: c.serviceName, + operationName: c.operationName, + fieldName: fc.fieldName, + }; + if (fc.kind === 'field-deprecated-changed') out.push(flipped(field, fc)); + else if (fc.kind === 'field-extensions-changed') out.push(extended(field, fc)); } if (c.securityChanged) { out.push( diff --git a/src/differ/models.ts b/src/differ/models.ts index cac960e9..b6967199 100644 --- a/src/differ/models.ts +++ b/src/differ/models.ts @@ -343,7 +343,16 @@ function variantLabel(ref: TypeRef): string { return assertNever(ref); } -function diffFields(oldModel: Model, newModel: Model, direction: ModelDirection = 'unknown'): FieldChange[] { +/** + * What changed between two field lists, matched by name: a model's fields, or + * a list envelope's (`PaginationMeta.envelopeFields`, which `diffOperations` + * compares the same way, #439). + */ +export function diffFields( + oldModel: Pick, + newModel: Pick, + direction: ModelDirection = 'unknown', +): FieldChange[] { const changes: FieldChange[] = []; const oldByName = new Map(oldModel.fields.map((f) => [f.name, f])); const newByName = new Map(newModel.fields.map((f) => [f.name, f])); diff --git a/src/differ/operations.ts b/src/differ/operations.ts index 348c4db7..e18ba96b 100644 --- a/src/differ/operations.ts +++ b/src/differ/operations.ts @@ -26,7 +26,6 @@ import { import type { DocumentShapes } from './shapes.js'; import type { Change, - FieldChange, ParamChange, ParamLocation, ResponseContentTypesChange, @@ -35,7 +34,6 @@ import type { } from './types.js'; import { locatedParams, paramKey, parameterContentTypeShift } from './params.js'; import { - classifyFieldChange, classifyParamChange, classifyResponseHeaderChange, deprecationDetails, @@ -45,7 +43,13 @@ import { requestBodyEncodingShift, tagsShift, } from './classify.js'; -import { addedUnionVariants, formatChangeDetails, primitivesDifferOnlyByFormat, typeRefsEqual } from './models.js'; +import { + addedUnionVariants, + diffFields, + formatChangeDetails, + primitivesDifferOnlyByFormat, + typeRefsEqual, +} from './models.js'; import { classifySecurityChange, securityShift, type DocumentSecurity } from './security.js'; /** @@ -203,11 +207,19 @@ export function diffOperations( // it. The flag is present only when they differ (`extensionsShift`). const extensions = extensionsShift(oldOp, newOp); const extensionsChanged = extensions !== undefined; - // The list envelope's fields, matched by name while both specs paginate: - // a scalar companion (`has_more`) sits on no model `diffModels` reads, so - // its extensions are compared here; a companion object's model is - // compared in the models diff (workos/oagen#369 round 2). - const envelopeFieldChanges = bothPaginated ? diffEnvelopeFieldExtensions(oldPagination, newPagination) : []; + // The list envelope's fields, matched by name while both specs paginate, + // compared as a model's fields are: they sit on no model `diffModels` + // reads, and the envelope an emitter types from them gains, loses or + // retypes a property with them (#439). The envelope is only ever read, + // so it is judged as a response-only model is. A companion object's own + // fields are compared in the models diff (workos/oagen#369 round 2). + const envelopeFieldChanges = bothPaginated + ? diffFields( + { fields: oldPagination?.envelopeFields ?? [] }, + { fields: newPagination?.envelopeFields ?? [] }, + 'response', + ) + : []; const envelopeFieldsChanged = envelopeFieldChanges.length > 0; const hasChanges = @@ -303,6 +315,9 @@ export function diffOperations( const securityBreaking = security !== undefined && classifySecurityChange(security.oldSecurity, security.newSecurity, 'caller') === 'breaking'; + // envelope fields: breaking when an entry is (a field removed or + // retyped, say), as a model is for its fields. + const envelopeFieldsBreaking = envelopeFieldChanges.some((c) => c.classification === 'breaking'); const hasBreaking = responseBreaking || @@ -328,6 +343,7 @@ export function diffOperations( fallbackBreaking || serversBreaking || securityBreaking || + envelopeFieldsBreaking || paramChanges.some((pc) => pc.classification === 'breaking'); changes.push({ kind: 'operation-modified', @@ -377,29 +393,6 @@ export function diffOperations( return changes; } -/** - * The envelope fields (`PaginationMeta.envelopeFields`) whose `extensions` - * differ between two paginated versions, matched by field name: one - * `field-extensions-changed` entry each, additive, with the two sides - * beside it (`extensionsShift`). A field only one side declares is not - * reported here — nothing else about an envelope field is compared by the - * differ — and a companion object's model is compared in `diffModels`. - */ -function diffEnvelopeFieldExtensions( - oldPagination: Operation['pagination'], - newPagination: Operation['pagination'], -): FieldChange[] { - const oldFields = new Map((oldPagination?.envelopeFields ?? []).map((field) => [field.name, field])); - const changes: FieldChange[] = []; - for (const field of newPagination?.envelopeFields ?? []) { - const before = oldFields.get(field.name); - if (!before) continue; - const shift = extensionsShift(before, field); - if (shift) changes.push({ ...classifyFieldChange('field-extensions-changed', field.name), ...shift }); - } - return changes; -} - function endOfIterationEqual( a: PaginationEndOfIteration | undefined, b: PaginationEndOfIteration | undefined, diff --git a/src/differ/types.ts b/src/differ/types.ts index 77423310..4c552e81 100644 --- a/src/differ/types.ts +++ b/src/differ/types.ts @@ -86,6 +86,7 @@ export type DeprecatedNode = } | { node: 'model'; modelName: string } | { node: 'field'; modelName: string; fieldName: string } + | { node: 'envelope-field'; serviceName: string; operationName: string; fieldName: string } | { node: 'enum'; enumName: string } | { node: 'enum-value'; enumName: string; valueName: string } | { node: 'webhook'; name: string; httpMethod: string } @@ -542,14 +543,21 @@ export interface OperationModified { oldExtensions?: Extensions; newExtensions?: Extensions; /** - * The fields of the list envelope (`Operation.pagination.envelopeFields`) - * whose `extensions` differ, one `field-extensions-changed` entry each - * with the two sides beside it, matched by field name while both specs - * paginate. Present only when there is at least one. A scalar companion - * (`has_more`) sits on no model `diffModels` reads, so this is where its - * change is reported; a companion object's model is compared in the - * models diff like any model. No other property of an envelope field is - * compared by the differ. + * What changed about the list envelope's fields + * (`Operation.pagination.envelopeFields`, which an emitter types the + * envelope from), matched by field name while both specs paginate and + * compared as a model's fields are (`diffFields`), an absent list reading + * as no field. Each entry is classified as on a response-only model, since + * the envelope is only ever read: a field added is additive, required or + * not; one removed or retyped is breaking, but for an inline union that + * only gained variants; format, requiredness, access flags, `deprecated` + * and extensions are judged as on any model's field. The operation is + * breaking when an entry is. Present only when there is at least one. A + * scalar companion (`has_more`) sits on no model `diffModels` reads, so + * this is where its change is reported; a companion object's own fields + * are compared in the models diff like any model's, and its envelope field + * is an entry here only when the field itself changes, the model it names + * included. */ envelopeFieldChanges?: FieldChange[]; /** diff --git a/test/differ/deprecated.test.ts b/test/differ/deprecated.test.ts index 1565e27a..9e2af646 100644 --- a/test/differ/deprecated.test.ts +++ b/test/differ/deprecated.test.ts @@ -608,6 +608,74 @@ describe('diffSpecs – deprecated behaviorChanges and summary (issue #256)', () }); }); +describe('diffSpecs – a list envelope field deprecated (issue #439)', () => { + /** `GET /pets?after=` returning `{ data: Pet[], has_more }`, `has_more` carrying `deprecated` as given. */ + const listPets = (deprecated?: boolean): Operation => ({ + name: 'listPets', + httpMethod: 'get', + path: '/pets', + pathParams: [], + queryParams: [{ name: 'after', type: { kind: 'primitive', type: 'string' }, required: false }], + headerParams: [], + response: { kind: 'array', items: { kind: 'model', name: 'Pet' } }, + errors: [], + injectIdempotencyKey: false, + pagination: { + strategy: 'cursor', + param: 'after', + dataPath: 'data', + itemType: { kind: 'model', name: 'Pet' }, + envelopeFields: [ + { + name: 'has_more', + type: { kind: 'primitive', type: 'boolean' }, + required: false, + ...(deprecated !== undefined ? { deprecated } : {}), + }, + ], + }, + }); + const withList = (op: Operation): ApiSpec => ({ ...v1, services: [{ name: 'Pets', operations: [op] }] }); + + it('reports the flip in envelopeFieldChanges and mirrors it as an envelope-field node, which no model carries', () => { + const diff = diffSpecs(withList(listPets()), withList(listPets(true))); + expect(diff.changes).toMatchObject([ + { + kind: 'operation-modified', + operationName: 'listPets', + deprecatedChanged: false, + envelopeFieldChanges: [ + { + kind: 'field-deprecated-changed', + fieldName: 'has_more', + classification: 'additive', + ...became, + details: 'live → deprecated', + }, + ], + classification: 'additive', + }, + ]); + expect(diff.behaviorChanges).toEqual([ + { + kind: 'deprecated-changed', + node: 'envelope-field', + serviceName: 'Pets', + operationName: 'listPets', + fieldName: 'has_more', + ...became, + classification: 'additive', + }, + ]); + expect(diff.summary).toEqual({ added: 0, removed: 0, modified: 1, breaking: 0, additive: 1, behaviorChanges: 1 }); + + expect(diffSpecs(withList(listPets(true)), withList(listPets())).behaviorChanges).toMatchObject([ + { node: 'envelope-field', fieldName: 'has_more', ...ceased }, + ]); + expect(diffSpecs(withList(listPets(false)), withList(listPets())).changes).toEqual([]); + }); +}); + describe('diffWebhooks – deprecated (Greptile round 2)', () => { it('reports a webhook becoming deprecated as its only change, additive, with both sides of the flip', () => { expect(diffWebhooks([petAdopted], [deprecatedHook])).toEqual([ diff --git a/test/differ/pagination-changes.test.ts b/test/differ/pagination-changes.test.ts index 6ea56512..9ea598f4 100644 --- a/test/differ/pagination-changes.test.ts +++ b/test/differ/pagination-changes.test.ts @@ -1,6 +1,12 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { diffSpecs } from '../../src/differ/diff.js'; import { diffOperations } from '../../src/differ/operations.js'; -import type { Operation, PaginationMeta } from '../../src/ir/types.js'; +import type { FieldChange } from '../../src/differ/types.js'; +import type { ApiSpec, Field, Operation, PaginationMeta, TypeRef } from '../../src/ir/types.js'; +import { parseSpec } from '../../src/parser/parse.js'; /** * Every change to how an iterator pages is reported (#221): the strategy, the @@ -148,3 +154,287 @@ describe('diffOperations — pagination strategy, parameters and end of iteratio expect(change.classification).toBe('breaking'); }); }); + +/** + * `pagination.envelopeFields` is every property of an unwrapped list envelope + * besides the data array — what an emitter types the envelope from — so a + * field added to it, removed from it or retyped is compared as the same edit + * to a model's field is (#439). + */ +describe('diffOperations — the list envelope’s fields (issue #439)', () => { + const integer: TypeRef = { kind: 'primitive', type: 'integer' }; + const hasMore: Field = { name: 'has_more', type: { kind: 'primitive', type: 'boolean' }, required: false }; + const totalCount = (over: Partial = {}): Field => ({ + name: 'total_count', + type: integer, + required: false, + ...over, + }); + + /** `GET /pets?after=` returning `{ data: string[], …envelopeFields }`. */ + const listPets = (envelopeFields?: Field[]): Operation => ({ + name: 'listPets', + httpMethod: 'get', + path: '/pets', + pathParams: [], + queryParams: [{ name: 'after', type: { kind: 'primitive', type: 'string' }, required: false }], + headerParams: [], + response: { kind: 'array', items: { kind: 'primitive', type: 'string' } }, + errors: [], + injectIdempotencyKey: false, + pagination: { + strategy: 'cursor', + param: 'after', + dataPath: 'data', + itemType: { kind: 'primitive', type: 'string' }, + ...(envelopeFields ? { envelopeFields } : {}), + endOfIteration: { kind: 'hasMore', path: 'has_more' }, + }, + }); + + /** The one `operation-modified` change `before` → `after` reports, with its `envelopeFieldChanges`. */ + function envelopeChange(before: Field[] | undefined, after: Field[] | undefined) { + const changes = diffOperations('Pets', [listPets(before)], [listPets(after)]); + expect(changes).toHaveLength(1); + const change = changes[0]; + if (change.kind !== 'operation-modified') throw new Error(`expected operation-modified, got ${change.kind}`); + return change; + } + + it('reports a field added to the envelope as additive, and its removal as breaking', () => { + const added = envelopeChange([hasMore], [hasMore, totalCount()]); + expect(added.envelopeFieldChanges).toEqual([ + { kind: 'field-added', fieldName: 'total_count', classification: 'additive' }, + ]); + expect(added.responseChanged).toBe(false); + expect(added.classification).toBe('additive'); + + const removed = envelopeChange([hasMore, totalCount()], [hasMore]); + expect(removed.envelopeFieldChanges).toEqual([ + { kind: 'field-removed', fieldName: 'total_count', classification: 'breaking' }, + ]); + expect(removed.classification).toBe('breaking'); + }); + + it('keeps a required field added additive: the envelope is only ever read, as a response-only model is', () => { + const change = envelopeChange([hasMore], [hasMore, totalCount({ required: true })]); + expect(change.envelopeFieldChanges).toEqual([ + { + kind: 'field-added', + fieldName: 'total_count', + classification: 'additive', + details: 'required on a response-only model', + }, + ]); + expect(change.classification).toBe('additive'); + }); + + it('reports a field retyped as breaking, and a format-only change as additive', () => { + const retyped = envelopeChange( + [hasMore, totalCount()], + [hasMore, totalCount({ type: { kind: 'primitive', type: 'string' } })], + ); + expect(retyped.envelopeFieldChanges).toEqual([ + { kind: 'field-type-changed', fieldName: 'total_count', classification: 'breaking', details: 'type changed' }, + ]); + expect(retyped.classification).toBe('breaking'); + + const formatted = envelopeChange( + [hasMore, totalCount()], + [hasMore, totalCount({ type: { kind: 'primitive', type: 'integer', format: 'int64' } })], + ); + expect(formatted.envelopeFieldChanges).toEqual([ + { + kind: 'field-format-changed', + fieldName: 'total_count', + classification: 'additive', + details: 'format → int64', + }, + ]); + expect(formatted.classification).toBe('additive'); + }); + + it('compares requiredness, access flags and deprecation as on a model’s field', () => { + const cases: Array<[Partial, Partial, FieldChange[], 'additive' | 'breaking']> = [ + [ + {}, + { required: true }, + [ + { + kind: 'field-required-changed', + fieldName: 'total_count', + classification: 'breaking', + details: 'optional → required', + }, + ], + 'breaking', + ], + [ + { required: true }, + {}, + [ + { + kind: 'field-required-changed', + fieldName: 'total_count', + classification: 'additive', + details: 'required → optional', + }, + ], + 'additive', + ], + [ + {}, + { readOnly: true }, + [{ kind: 'field-access-changed', fieldName: 'total_count', classification: 'breaking' }], + 'breaking', + ], + [ + {}, + { deprecated: true }, + [ + { + kind: 'field-deprecated-changed', + fieldName: 'total_count', + classification: 'additive', + oldDeprecated: false, + newDeprecated: true, + details: 'live → deprecated', + }, + ], + 'additive', + ], + ]; + for (const [before, after, entries, classification] of cases) { + const change = envelopeChange([hasMore, totalCount(before)], [hasMore, totalCount(after)]); + expect([after, change.envelopeFieldChanges, change.classification]).toEqual([after, entries, classification]); + } + }); + + it('reads an absent list as no field, and leaves the key off when no envelope field changed', () => { + expect(envelopeChange(undefined, [hasMore]).envelopeFieldChanges).toEqual([ + { kind: 'field-added', fieldName: 'has_more', classification: 'additive' }, + ]); + expect(envelopeChange([hasMore], undefined).envelopeFieldChanges).toEqual([ + { kind: 'field-removed', fieldName: 'has_more', classification: 'breaking' }, + ]); + expect(diffOperations('Pets', [listPets(undefined)], [listPets([])])).toEqual([]); + expect(diffOperations('Pets', [listPets([hasMore])], [listPets([{ ...hasMore }])])).toEqual([]); + const tagged = diffOperations('Pets', [listPets([hasMore])], [{ ...listPets([hasMore]), tags: ['pets'] }]); + expect(tagged).toHaveLength(1); + expect(tagged[0]).not.toHaveProperty('envelopeFieldChanges'); + }); + + it('compares the fields only while both specs paginate', () => { + const unpaginated: Operation = { ...listPets(), pagination: undefined }; + for (const [before, after] of [ + [unpaginated, listPets([hasMore])], + [listPets([hasMore]), unpaginated], + ]) { + const changes = diffOperations('Pets', [before], [after]); + expect(changes).toMatchObject([{ kind: 'operation-modified', paginatedChanged: true }]); + expect(changes[0]).not.toHaveProperty('envelopeFieldChanges'); + } + }); +}); + +describe("diffSpecs — the issue's documents, end to end (issue #439)", () => { + /** `GET /pets?after=` returning `{ data: string[], has_more: boolean, …extra }`. */ + const document = (extra: Record = {}) => ({ + openapi: '3.1.0', + info: { title: 'Pets', version: '1.0.0' }, + paths: { + '/pets': { + get: { + operationId: 'listPets', + parameters: [{ name: 'after', in: 'query', schema: { type: 'string' } }], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { type: 'string' } }, + has_more: { type: 'boolean' }, + ...extra, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + let dir: string; + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'oagen-439-')); + }); + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + async function parse(name: string, doc: object): Promise { + const file = join(dir, `${name}.json`); + writeFileSync(file, JSON.stringify(doc)); + return parseSpec(file); + } + /** + * Each change as ` : ` + * beside the change's own classification, and the summary counts `oagen + * diff` takes its exit code from (2 for any breaking, else 1 for any + * modified). + */ + const summarize = (report: ReturnType) => ({ + changes: report.changes.flatMap((c) => + c.kind === 'operation-modified' + ? (c.envelopeFieldChanges ?? []).map((f) => `${c.operationName} ${f.fieldName} ${f.kind}: ${f.classification}`) + : [c.kind], + ), + classifications: report.changes.map((c) => c.classification), + modified: report.summary.modified, + breaking: report.summary.breaking, + }); + + it('reports `total_count` added as additive (exit 1), and removed or retyped as breaking (exit 2)', async () => { + const oldSpec = await parse('old', document()); + const newSpec = await parse('new', document({ total_count: { type: 'integer' } })); + const retyped = await parse('retyped', document({ total_count: { type: 'string' } })); + const envelope = (spec: ApiSpec) => + spec.services[0]!.operations[0]!.pagination?.envelopeFields?.map( + ({ name, type }) => `${name}: ${type.kind === 'primitive' ? type.type : type.kind}`, + ); + expect([envelope(oldSpec), envelope(newSpec), envelope(retyped)]).toEqual([ + ['has_more: boolean'], + ['has_more: boolean', 'total_count: integer'], + ['has_more: boolean', 'total_count: string'], + ]); + + expect(summarize(diffSpecs(oldSpec, newSpec))).toEqual({ + changes: ['listPets total_count field-added: additive'], + classifications: ['additive'], + modified: 1, + breaking: 0, + }); + expect(summarize(diffSpecs(newSpec, oldSpec))).toEqual({ + changes: ['listPets total_count field-removed: breaking'], + classifications: ['breaking'], + modified: 1, + breaking: 1, + }); + expect(summarize(diffSpecs(newSpec, retyped))).toEqual({ + changes: ['listPets total_count field-type-changed: breaking'], + classifications: ['breaking'], + modified: 1, + breaking: 1, + }); + expect(summarize(diffSpecs(newSpec, newSpec))).toEqual({ + changes: [], + classifications: [], + modified: 0, + breaking: 0, + }); + }); +}); From f01d7aa26dfca987dea4c149ffd36a7e81f0e863 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 15:48:31 -0400 Subject: [PATCH 11/59] fix(differ)!: report a move to or from an unread content map and keep JSON-to-JSON moves additive A parameter declared under a `content` map none of whose media types the parser reads (`application/xml`, a JSON entry without a schema) carried no `contentType`, so `parameterContentTypeShift` took it for a `schema` parameter: `schema` -> `application/xml` was not reported at all and `application/json` -> `application/xml` named `` as the new side. And every `param-content-type-changed` was breaking, though a move between `application/json` and a `+json` media type JSON-encodes the value on both sides, so nothing a caller sends changes. The parser now carries every key a parameter's `content` map declares as `Parameter.contentTypes`, read or not, as `requestBodyContentTypes` is carried for a body; `contentType` keeps its meaning, so an emitter's encoding rule is unchanged. The differ compares `contentType`, else the first of `contentTypes`, by essence, and also tells a read map from an unread one of the same essence (a JSON entry that lost its schema is no longer JSON-encoded), marking an unread side `(unread)` in `details`. `jsonEncodedOnBothSides` makes a move between two JSON media types the parser reads additive on operations and webhooks alike. Closes #410 BREAKING CHANGE: a parameter declared with a non-empty `content` map (and no `schema`) now carries `Parameter.contentTypes`; `oagen diff` reports a parameter moving to or from an unread `content` map as a breaking `param-content-type-changed` naming that map's media type, and classifies a move between two JSON media types the parser reads (`application/json` -> `application/vnd.api+json`) as additive instead of breaking. --- docs/architecture/emitter-contract.md | 2 +- docs/architecture/ir-types.md | 5 +- docs/cli.md | 2 +- skills/check-emitter-parity/SKILL.md | 1 + .../references/generator-guide.md | 2 +- src/differ/classify.ts | 23 +- src/differ/operations.ts | 14 +- src/differ/params.ts | 81 ++++-- src/differ/types.ts | 9 +- src/differ/webhooks.ts | 12 +- src/ir/types.ts | 25 +- src/parser/operations.ts | 15 +- test/differ/param-content-type.test.ts | 238 +++++++++++++++++- test/parser/parameter-content.test.ts | 90 ++++++- 14 files changed, 459 insertions(+), 60 deletions(-) diff --git a/docs/architecture/emitter-contract.md b/docs/architecture/emitter-contract.md index 4647c3e2..b061a2a2 100644 --- a/docs/architecture/emitter-contract.md +++ b/docs/architecture/emitter-contract.md @@ -110,7 +110,7 @@ Emitters should handle these additional variants introduced for open-source read - **Encoding:** `'form-urlencoded'` for `application/x-www-form-urlencoded` request bodies; every request body media type is matched by essence (parameters and case aside), and a `+json` media type is `'json'` - **HTTP methods:** `head`, `options`, `trace` — HEAD returns no body, OPTIONS/TRACE are informational - **Cookie params:** Optional `cookieParams` array on operations that use cookie parameters -- **Content parameters:** Optional `contentType: string` on `Parameter` — the `content` key a `content`-declared parameter's value is serialized under, as written (`application/json`, `application/json; charset=utf-8`), present exactly when the parameter is declared with `content` and the map is read; serialize such a value in that media type (JSON-encode an `application/json` or `+json` value) instead of by `style`/`explode` +- **Content parameters:** Optional `contentType: string` on `Parameter` — the `content` key a `content`-declared parameter's value is serialized under, as written (`application/json`, `application/json; charset=utf-8`), present exactly when the parameter is declared with `content` and the map is read; serialize such a value in that media type (JSON-encode an `application/json` or `+json` value) instead of by `style`/`explode`. Optional `contentTypes: string[]` beside it lists every media type the `content` map declares, as written, read or not: a map none of whose media types is read keeps its media type there, and its value, a string, is sent as written - **Webhooks:** Optional `webhooks: Webhook[]` on `ApiSpec` — the document's top-level `webhooks` map (OpenAPI 3.1), one entry per HTTP method of each key, present only when the document declares at least one. A webhook is not an SDK method: it has no path, belongs to no `Service` and gets no resolved operation name. Each carries the payload (`requestBody`, with `requestBodyEncoding`, `requestBodyContentType`, `requestBodyDescription` and the `requestBodyContentTypes` / `requestBodyContents` lists read as an operation's are, and `requestBodyRequired` present, and `true`, only when the payload is required), the `headerParams`, `queryParams` and `cookieParams` the request carries, the `responses` the sender expects back, and `summary`, `description`, `externalDocs`, `deprecated`, `tags` and `security` as an operation has them; generate a payload type (the model is already in `spec.models`) or a typed handler per event, keyed by `name` + `httpMethod` - **Named unions:** Optional `variants: TypeRef[]` and `compositionKind: 'oneOf' | 'anyOf'` on `Model` (present together) for a component schema that is itself a `oneOf`/`anyOf`: emit such a model as a union of `variants`, not as a data class — `fields` holds only the inline variants' properties merged as optional fields. `discriminator` is set on a named union that is discriminated — a `oneOf` or `anyOf` with an explicit `discriminator:` keyword, or a `oneOf` without the keyword whose variants are all nameable and pin distinct string constants on one property; an `anyOf` is never discriminated implicitly — as it is on an allOf+oneOf dispatcher, so an emitter that keys dispatchers on it emits one for such a union too; its `mapping` values are IR model names. A `$ref` to a named union that admits `null` resolves to `nullable` - **Extensions:** Optional `extensions: Record` on `Operation`, `Model`, `Field` and `Enum` — the `x-*` keys of the object the node was built from, each under the key as written with its value verbatim, less the keys the parser reads into typed fields (an Operation Object's `x-pagination`, `x-oagen-async`, `x-mutually-exclusive-parameter-groups` and `x-mutually-exclusive-body-groups`; a Schema Object's `x-enum-descriptions` and `x-enumDescriptions`), present only when at least one key remains. Read the keys the SDK knows (`x-idempotent: true` on a `POST` that is safe to retry, `x-since` for an `@since` tag); the values come from the document unvalidated, so escape one before writing it into generated source, as a `description` is escaped. No other node carries them (see [Extensions](ir-types.md#extensions)) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 03e8f2c1..3a7f0bb5 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -464,7 +464,8 @@ interface Parameter { examples?: unknown[]; // Every example value, in document order; only when non-empty style?: "form" | "simple" | "label" | "matrix"; explode?: boolean; - contentType?: string; // The `content` key the value is serialized under, as written; only for a `content` parameter + contentType?: string; // The `content` key the value is serialized under, as written; only for a `content` parameter whose map is read + contentTypes?: string[]; // Every `content` key, in document order and as written, read or not; only for a `content` parameter constraints?: Constraints; // Validation keywords; see Constraints below } ``` @@ -473,7 +474,7 @@ A parameter is identified by its location and its name (`parameterKey` in `src/i A parameter's schema is its `schema` or, when it declares a `content` map in its place, the schema of the entry read from that map: `application/json` or a `+json` media type when the entry declares a schema, else `text/plain` with or without one, each matched by media type essence — `type/subtype` with parameters stripped and case folded, so `application/json; charset=utf-8` and `APPLICATION/JSON` are read as `application/json` — and, within one tier, the first matching key in document order. A `text/plain` entry without a schema types the parameter as a `string`. A map none of whose media types is read types it as a `string` too, with a warning that names the media types, once per operation parameter, once per webhook parameter and once per response header; an empty map is not reported. A `schema` beside a `content` map wins and the map is not consulted. Every rule in this section that reads the parameter's `schema` — its `type`, the inline model or enum it mints, `description`, `deprecated`, `default`, `example` and `examples`, `externalDocs`, `constraints`, and the page start a `page` parameter gives `PaginationMeta.pageStart` — reads this schema, in every location, on an operation and on a webhook alike, so a parameter declared either way is typed by one rule. -`Parameter.contentType` is the media type the value is serialized in: the `content` key the parameter's schema was read from, as written (`application/json; charset=utf-8`). It is present exactly when the parameter is declared with `content` and the map is read — a schema-less `text/plain` entry included — and absent for a `schema` parameter and for a `content` map none of whose media types is read, whose string value is sent as written. It is carried wherever a parameter is: an operation's own and path-level parameters, a webhook's, and one reached through a `components/parameters` ref. An emitter that serializes parameters by `style` and `explode` must instead serialize such a value in that media type — JSON-encode an `application/json` or `+json` value (`filter={"owner":"a","tag":"b"}`, not `owner=a&tag=b`), send a `text/plain` value as text. `oagen diff` compares the two sides by media type essence and reports a difference as a breaking `param-content-type-changed` (see [docs/cli.md](../cli.md)); the webhook stem naming does not read it. +`Parameter.contentType` is the media type the value is serialized in: the `content` key the parameter's schema was read from, as written (`application/json; charset=utf-8`). It is present exactly when the parameter is declared with `content` and the map is read — a schema-less `text/plain` entry included — and absent for a `schema` parameter and for a `content` map none of whose media types is read, whose string value is sent as written. `Parameter.contentTypes` lists every media type the map declares — its keys, in document order and as written — whenever the parameter is declared with a `content` map that declares at least one, read or not, as `requestBodyContentTypes` does for a body; a `schema` beside the map wins, and the parameter carries neither. OpenAPI allows the map one entry, which `contentType` names when it is read; for a map none of whose media types is read, `contentTypes` is where the media type the document declares the value in is kept (`['application/xml']`), the value itself being a string sent as written. Both are carried wherever a parameter is: an operation's own and path-level parameters, a webhook's, and one reached through a `components/parameters` ref. An emitter that serializes parameters by `style` and `explode` must instead serialize a value that has a `contentType` in that media type — JSON-encode an `application/json` or `+json` value (`filter={"owner":"a","tag":"b"}`, not `owner=a&tag=b`), send a `text/plain` value as text. `oagen diff` compares the two sides by media type essence — `contentType`, else the first of `contentTypes`, so a parameter moving to or from an unread map is not taken for a `schema` one — and reports a difference as a `param-content-type-changed`, breaking unless both sides are read from a JSON media type (see [docs/cli.md](../cli.md)); the webhook stem naming reads neither. `Parameter.description` is the first non-empty `description` found on, in order, the parameter itself, its schema, and the `$ref`'d component schema. An empty string at any level counts as absent, and the field is `undefined` (never `""`) when nothing in the chain is documented. A `description` written beside a `$ref` into `components/parameters` is the parameter's own in an OAS 3.1 document and ignored in a 3.0 one (see Reference Objects below). `deprecated` and `example` read the same three levels, each by its own rule: `deprecated` is `true` when any level says `deprecated: true` (see Deprecation below), `example` takes the first level that gives a value (an explicit `null` is kept; see Parameter Examples below). `default` does not: it is read from the inline `schema`, else the `$ref`'d component schema, with `??`, so a `false` or `0` default is kept, and the Parameter Object itself is never consulted since OpenAPI gives it no `default`. `Parameter.externalDocs` follows the same chain from the schema down — the parameter's `schema` (a sibling of a `$ref` included), then the component schema its `$ref` points at — an unusable entry at one level falling through to the next; a Parameter Object declares no `externalDocs` of its own. diff --git a/docs/cli.md b/docs/cli.md index 92b7c956..7a08dc6e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -88,7 +88,7 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Extensions.** A node's `extensions` — the `x-*` keys the operation, model, field or enum was built with (see [Extensions](architecture/ir-types.md#extensions)) — differing between the two specs is reported and is additive: a key added, removed or given another value, compared deeply with key order aside, an absent key and an empty object both reading as none. It is `extensionsChanged: true` on the `operation-modified`, `model-modified` or `enum-modified` change, with `oldExtensions`/`newExtensions` beside it — each present only when the node had extensions on that side — and a `field-extensions-changed` entry carrying the same two values in a `model-modified` change's `fieldChanges`. A list envelope's fields (`Operation.pagination.envelopeFields`) are compared as a model's fields are (see List envelopes below), so an extension change on one — a scalar companion such as `has_more`, which no model carries — is a `field-extensions-changed` entry in the `operation-modified` change's `envelopeFieldChanges`; a companion object's model is compared like any model, so its fields report under `model-modified`. The flag is present only when the extensions differ, unlike `deprecatedChanged`, so a change report without an extension change keeps its shape. Extensions are not shape: a change to them is never a `field-type-changed`, `param-type-changed` or retyped body, a body comparison that reads a model or enum ref through to its shape ignores them as it ignores documentation, and a breaking change on the same node keeps the change breaking. A change whose only content is an extension change is still reported (so the command exits 1), since an emitter may derive behavior from a key it knows. Each is mirrored into `behaviorChanges` as an `extensions-changed` entry naming the node (`operation`, `model`, `field`, `enum` or `envelope-field`, the last with the operation and field names) and copying `oldExtensions`/`newExtensions` from the change. A parameter, response header, webhook, request body or response object carries no `extensions`, so none is compared there. -**Parameters.** An operation's parameters are compared in every location it carries them — path, query, header and cookie — and a webhook's in header, query and cookie, through one walk, so a change is reported wherever the parameter lives. Parameters are matched by location and name (`parameterKey` in `src/ir/parameter-key.ts`, the identity the parser merges path-item and operation parameters on) — OpenAPI allows a path `id` beside a query `id` on one operation — and every `paramChanges` entry carries `paramLocation` (`path`, `query`, `header` or `cookie` on an operation; `header`, `query` or `cookie` on a webhook) to say which one it is about. A header's name is matched case-insensitively, since HTTP header names are (RFC 9110 §5.1): a header written `X-Pet-Token` in one spec and `x-pet-token` in the other is one parameter, its type, requiredness, default and `deprecated` compared as usual, and the spelling change is a `param-name-changed` entry — additive, with `oldName`/`newName` and `details` reading `name X-Pet-Token → x-pet-token` — beside whatever else changed, not a `param-removed` beside a `param-added`. The inline enum or model a header's schema mints is named from the lower-cased header name on both sides (`PetsXRatelimitPolicy` for `X-RateLimit-Policy` and `x-ratelimit-policy` alike; the same for a response header's), so a case change leaves the header's type, and the `enums` and `models` lists, where they were: no `param-type-changed`, no `enum-removed`, no `model-removed`. On the entries of a paired parameter, `paramName` is the name as the new spec writes it; a `param-removed` entry carries the old spec's name, which the new spec no longer has, and a `param-added` entry the new spec's. Path, query and cookie names are case-sensitive, so `Limit` and `limit` are two query parameters. A `param-name-changed` entry is not mirrored into `behaviorChanges`. A query parameter the request body owns (`Operation.bodyOwnedQueryParams`: declared as a body property too, on a body-carrying method) is not compared as a parameter — it travels in the body, whose model the differ compares field by field — so a query parameter the body takes over is reported as `param-removed` at `query`. A parameter's `contentType` — the `content` key its value is serialized under, absent for a `schema` parameter (see `Parameter` in [docs/architecture/ir-types.md](architecture/ir-types.md)) — is compared by media type essence (`type/subtype`, case folded, parameters stripped): a parameter that moves from `schema` to `content` or back, or between two essences (`application/json` → `text/plain`), is a `param-content-type-changed` entry, breaking in both directions and on a webhook too, since the value is encoded another way on the wire; it carries `oldContentType`/`newContentType` (each as written, `null` for the `schema` side) and `details` reading `content type → application/json`, and is reported beside, not instead of, whatever else changed about the parameter — a parameter whose schema is the same on both sides reports it alone, with no `param-type-changed`, `enum-removed` or `model-removed`. A respelling that keeps the essence (`application/json` → `application/json; charset=utf-8`) is not reported. The entry is not mirrored into `behaviorChanges`. +**Parameters.** An operation's parameters are compared in every location it carries them — path, query, header and cookie — and a webhook's in header, query and cookie, through one walk, so a change is reported wherever the parameter lives. Parameters are matched by location and name (`parameterKey` in `src/ir/parameter-key.ts`, the identity the parser merges path-item and operation parameters on) — OpenAPI allows a path `id` beside a query `id` on one operation — and every `paramChanges` entry carries `paramLocation` (`path`, `query`, `header` or `cookie` on an operation; `header`, `query` or `cookie` on a webhook) to say which one it is about. A header's name is matched case-insensitively, since HTTP header names are (RFC 9110 §5.1): a header written `X-Pet-Token` in one spec and `x-pet-token` in the other is one parameter, its type, requiredness, default and `deprecated` compared as usual, and the spelling change is a `param-name-changed` entry — additive, with `oldName`/`newName` and `details` reading `name X-Pet-Token → x-pet-token` — beside whatever else changed, not a `param-removed` beside a `param-added`. The inline enum or model a header's schema mints is named from the lower-cased header name on both sides (`PetsXRatelimitPolicy` for `X-RateLimit-Policy` and `x-ratelimit-policy` alike; the same for a response header's), so a case change leaves the header's type, and the `enums` and `models` lists, where they were: no `param-type-changed`, no `enum-removed`, no `model-removed`. On the entries of a paired parameter, `paramName` is the name as the new spec writes it; a `param-removed` entry carries the old spec's name, which the new spec no longer has, and a `param-added` entry the new spec's. Path, query and cookie names are case-sensitive, so `Limit` and `limit` are two query parameters. A `param-name-changed` entry is not mirrored into `behaviorChanges`. A query parameter the request body owns (`Operation.bodyOwnedQueryParams`: declared as a body property too, on a body-carrying method) is not compared as a parameter — it travels in the body, whose model the differ compares field by field — so a query parameter the body takes over is reported as `param-removed` at `query`. A parameter's media type — its `contentType`, the `content` key its value is serialized under; for a `content` map none of whose media types is read, the first of its `contentTypes`; none for a `schema` parameter (see `Parameter` in [docs/architecture/ir-types.md](architecture/ir-types.md)) — is compared by media type essence (`type/subtype`, case folded, parameters stripped): a parameter that moves from `schema` to `content` or back, an unread map included (`schema` → `application/xml`), or between two essences (`application/json` → `text/plain`, `application/json` → `application/xml`), is a `param-content-type-changed` entry, breaking in both directions and on a webhook too, since the value is encoded another way on the wire. So is a map read on one side only that keeps its essence (a JSON entry that gained or lost its schema), since an unread map's string value is sent as written. A move between two JSON media types the parser reads (`application/json` and any `+json` type: `application/json` → `application/vnd.api+json`, or between two `+json` types) is reported too, but additive: the value is JSON-encoded on both sides, so nothing a caller sends or a handler decodes changes. The entry carries `oldContentType`/`newContentType` (each as written, `null` for the `schema` side) and `details` reading `content type → application/json`, an unread side followed by `(unread)` (`content type → application/xml (unread)`), and is reported beside, not instead of, whatever else changed about the parameter — a parameter whose schema is the same on both sides reports it alone, with no `param-type-changed`, `enum-removed` or `model-removed`. A respelling that keeps the essence (`application/json` → `application/json; charset=utf-8`) is not reported. The entry is not mirrored into `behaviorChanges`. **Request body encoding.** `requestBodyEncoding` — the wire format a body is serialized as, read from the media type it is declared under: `json`, `form-data`, `form-urlencoded`, `binary` or `text` — differing between the two specs while both declare a body is reported as `requestBodyEncodingChanged: true` on the `operation-modified` change, with `oldRequestBodyEncoding`/`newRequestBodyEncoding` beside it. An SDK built for the old spec keeps serializing the body and labelling the request as that spec said, so the change is breaking when the new spec no longer accepts that representation — the media type the old body was read from is gone from its `requestBodyContentTypes`, or the body under it changed shape, compared as `retyped` is (`application/json` where the server now reads only `application/x-www-form-urlencoded`) — and additive when it still does, as when a media type the parser prefers is added beside it (`application/json` added beside a kept `application/x-www-form-urlencoded`: the parser now reads the body from JSON, existing callers keep sending the form body, and the server still reads it). A kept media type whose inline schema the parser names differently on the two sides (`CreatePetRequest` while it is the body `requestBody` is read from, `CreatePetApplicationXWwwFormUrlencodedRequest` once JSON is preferred) is still the same representation: the two models are compared field by field through the two specs' models, as `retyped` is, not by name. A body without media-type lists reads as breaking. A body appearing or disappearing takes its encoding with it and is `requestBodyChanged` alone; a body that changes type and encoding at once sets `requestBodyChanged` and `requestBodyEncodingChanged`. The encoding flag is reported beside the request body media-type comparison (below): a body moving from `application/json` to `application/x-www-form-urlencoded` sets `requestBodyEncodingChanged` and `requestBodyContentTypesChanged` both (the JSON media type is removed, the form one added), while a media type changing essence within one encoding (`application/json` → `application/vnd.api+json`) sets `requestBodyContentTypesChanged` alone. On a `webhook-modified` change the same comparison — made only while both specs declare a payload, as on an operation; a payload appearing or disappearing is `payload added` / `payload removed` alone and carries neither field — sets `requestBodyChanged` with `details` saying `encoding json → form-urlencoded` and the same two fields beside it, breaking from the receiver's side when the representation the sender now uses — the media type the new payload is read from, with its shape — is not one the old spec declared with that shape (the handler decodes a wire format it never saw), additive when it is (a media type dropped beside one the handler already decoded). A change whose only content is the encoding is still reported (the command exits 2 when it is breaking, 1 when it is additive), and the encoding is not mirrored into `behaviorChanges`. diff --git a/skills/check-emitter-parity/SKILL.md b/skills/check-emitter-parity/SKILL.md index 969ae1d1..86caab6e 100644 --- a/skills/check-emitter-parity/SKILL.md +++ b/skills/check-emitter-parity/SKILL.md @@ -136,6 +136,7 @@ A missing kind means an entire category of types won't generate correctly. - `constraints` — Validation limits (min/max, length, pattern, item counts) for doc comments or argument checks - `examples` — Every example value (the list `example` is drawn from) - `contentType` — The media type a `content`-declared parameter's value is serialized in (`application/json`): the value must be encoded in it (JSON) rather than by `style`/`explode`; absent for a `schema` parameter +- `contentTypes` — Every media type a `content`-declared parameter's map declares, as written, read or not, for the param doc tag: a map none of whose media types is read has no `contentType`, and its string value is sent as written #### Model diff --git a/skills/generate-emitter/references/generator-guide.md b/skills/generate-emitter/references/generator-guide.md index cd2994a5..57f57e63 100644 --- a/skills/generate-emitter/references/generator-guide.md +++ b/skills/generate-emitter/references/generator-guide.md @@ -171,7 +171,7 @@ Use the shared utilities from `@workos/oagen` (`toPascalCase`, `toSnakeCase`, `t - `servers?` — set only when the operation (or its Path Item) declares servers that differ from the document's; send its requests to `op.servers ?? spec.servers` - `parameterGroups?` — mutually-exclusive parameter groups (`name`, `optional`, `variants`, `wrapperName?`) for sum-type rendering; the grouped parameters stay in their location arrays - `extensions?` — the Operation Object's own `x-*` keys, keys as written and values verbatim, less the ones the parser reads into typed fields (`x-pagination`, `x-oagen-async`, `x-mutually-exclusive-parameter-groups`, `x-mutually-exclusive-body-groups`); read the keys you know (`x-idempotent`, `x-since`) and escape a value before writing it into source -- Each `Parameter` has `name`, `type`, `required`, `description?`, `deprecated?`, `externalDocs?` (the parameter schema's link, else its `$ref` target's), `default?`, `example?`, `examples?`, `style?`, `explode?`, `contentType?` (the `content` key a `content`-declared parameter is serialized under, e.g. `application/json`: serialize the value in that media type instead of by `style`/`explode`), `constraints?` +- Each `Parameter` has `name`, `type`, `required`, `description?`, `deprecated?`, `externalDocs?` (the parameter schema's link, else its `$ref` target's), `default?`, `example?`, `examples?`, `style?`, `explode?`, `contentType?` (the `content` key a `content`-declared parameter is serialized under, e.g. `application/json`: serialize the value in that media type instead of by `style`/`explode`), `contentTypes?` (every media type the `content` map declares, as written, read or not; a map none of whose media types is read has no `contentType`, and its string value is sent as written), `constraints?` - `type` may reference a model the parser created for an inline object parameter (`style: deepObject` → `PetsFilter`), or an array, map or union of such, exactly as a response header's does; render it as that model and serialize it per `style`/`explode` - Output: One file per service/resource - Handle: diff --git a/src/differ/classify.ts b/src/differ/classify.ts index 7afe6441..12199262 100644 --- a/src/differ/classify.ts +++ b/src/differ/classify.ts @@ -282,13 +282,20 @@ export function classifyResponseHeaderChange( } } -export function classifyParamChange(kind: ParamChange['kind'], paramName: string, isRequired?: boolean): ParamChange { +/** + * Classify a change to an operation's parameter, from the caller's side. + * `flag` is read by three kinds: `param-added` and `param-required-changed` + * take the new side's `required`, and `param-content-type-changed` takes + * whether both sides are read from a JSON media type + * (`jsonEncodedOnBothSides` in `params.ts`). + */ +export function classifyParamChange(kind: ParamChange['kind'], paramName: string, flag?: boolean): ParamChange { switch (kind) { case 'param-added': return { kind, paramName, - classification: isRequired ? 'breaking' : 'additive', + classification: flag ? 'breaking' : 'additive', }; case 'param-removed': return { kind, paramName, classification: 'breaking' }; @@ -302,7 +309,7 @@ export function classifyParamChange(kind: ParamChange['kind'], paramName: string return { kind, paramName, - classification: isRequired ? 'breaking' : 'additive', + classification: flag ? 'breaking' : 'additive', }; case 'param-default-changed': // Removing or changing a default that callers may have implicitly relied on @@ -322,9 +329,11 @@ export function classifyParamChange(kind: ParamChange['kind'], paramName: string case 'param-content-type-changed': // See `parameterContentTypeShift`: the media type decides how the value // is serialized (JSON-encoded under `application/json`, by style and - // explode under `schema`), so a caller encoding it the old way sends a - // value the API no longer reads — breaking in both directions, like a - // type change. - return { kind, paramName, classification: 'breaking' }; + // explode under `schema`, as written under a map the parser does not + // read), so a caller encoding it the old way sends a value the API no + // longer reads — breaking in both directions, like a type change. + // Between two JSON media types the parser reads (`flag`), the value is + // JSON-encoded either way and nothing a caller sends changes: additive. + return { kind, paramName, classification: flag ? 'additive' : 'breaking' }; } } diff --git a/src/differ/operations.ts b/src/differ/operations.ts index e18ba96b..dfe28a24 100644 --- a/src/differ/operations.ts +++ b/src/differ/operations.ts @@ -32,7 +32,7 @@ import type { ResponseHeaderChange, ServerChange, } from './types.js'; -import { locatedParams, paramKey, parameterContentTypeShift } from './params.js'; +import { jsonEncodedOnBothSides, locatedParams, paramKey, parameterContentTypeShift } from './params.js'; import { classifyParamChange, classifyResponseHeaderChange, @@ -938,11 +938,17 @@ function diffParams(oldOp: Operation, newOp: Operation): ParamChange[] { } } - // The media type the value is serialized in (`Parameter.contentType`) - // moved: between `schema` and `content`, or between two essences. + // The media type the value is serialized in (`Parameter.contentType`, + // or the one an unread `content` map declares) moved: between `schema` + // and `content`, or between two essences. Additive only between two JSON + // media types the parser reads, which encode the value alike. const reencoded = parameterContentTypeShift(oldEntry.p, newEntry.p); if (reencoded) { - changes.push({ ...at(classifyParamChange('param-content-type-changed', name), loc), ...reencoded }); + const jsonOnBothSides = jsonEncodedOnBothSides(oldEntry.p, newEntry.p); + changes.push({ + ...at(classifyParamChange('param-content-type-changed', name, jsonOnBothSides), loc), + ...reencoded, + }); } if (oldEntry.p.required !== newEntry.p.required) { diff --git a/src/differ/params.ts b/src/differ/params.ts index 6d0a7416..a9b7f857 100644 --- a/src/differ/params.ts +++ b/src/differ/params.ts @@ -1,6 +1,6 @@ import type { Parameter } from '../ir/types.js'; import { parameterKey } from '../ir/parameter-key.js'; -import { mediaTypeEssence } from '../utils/media-type.js'; +import { isJsonMediaType, mediaTypeEssence } from '../utils/media-type.js'; import type { ParamLocation } from './types.js'; /** A parameter tagged with where it lives, as `locatedParams` yields it. */ @@ -59,24 +59,48 @@ export function paramKey(loc: ParamLocation, name: string): string { return parameterKey(loc, name); } -/** The two sides of a parameter's `contentType`, carried on the `param-content-type-changed` entry that reports them. */ +/** The two sides of a parameter's media type, carried on the `param-content-type-changed` entry that reports them. */ export interface ParameterContentTypeShift { - /** The old spec's `content` key as written, or `null` for a parameter typed by `schema`. */ + /** The old spec's `content` key as written (see `declaredMediaType`), or `null` for a parameter typed by `schema`. */ oldContentType: string | null; - /** The new spec's `content` key as written, or `null` for a parameter typed by `schema`. */ + /** The new spec's `content` key as written (see `declaredMediaType`), or `null` for a parameter typed by `schema`. */ newContentType: string | null; - /** `content type → application/json`, each side as written. */ + /** `content type → application/json`, each side as written, a side the parser does not read followed by `(unread)`. */ details: string; } +/** The media type a parameter is declared in, and whether the parser read the value's type from it. */ +interface DeclaredMediaType { + contentType: string; + read: boolean; +} + +/** + * `Parameter.contentType` when the `content` map is read; else, for a map + * none of whose media types is read, the first of `Parameter.contentTypes`, + * whose value is a string sent as written; `null` for a parameter typed by + * `schema`. + */ +function declaredMediaType(param: Parameter): DeclaredMediaType | null { + if (param.contentType !== undefined) return { contentType: param.contentType, read: true }; + const declared = param.contentTypes?.[0]; + return declared !== undefined ? { contentType: declared, read: false } : null; +} + /** - * How a paired parameter's `Parameter.contentType` differs between two - * specs, or `undefined` when it does not. The media type decides how the - * value is serialized — a `content` parameter's value is sent in that media - * type, a `schema` parameter's by `style`/`explode` — so a parameter that - * moves between `schema` and `content`, or between two media types that - * differ in essence (`application/json` → `text/plain`), is a change even - * when its type did not move. The two keys are compared by essence + * How a paired parameter's media type differs between two specs, or + * `undefined` when it does not. The media type decides how the value is + * serialized — a `content` parameter's value is sent in that media type, a + * `schema` parameter's by `style`/`explode` — so a parameter that moves + * between `schema` and `content`, or between two media types that differ in + * essence (`application/json` → `text/plain`), is a change even when its + * type did not move. A `content` map none of whose media types is read + * carries no `contentType` and is compared by the first media type it + * declares (`contentTypes`), not taken for `schema` (#410). Its string + * value is sent as written rather than encoded in that media type, so a + * map read on one side only is a change even when the essence is kept (a + * JSON entry that gained or lost its schema); `details` marks an unread + * side `(unread)`. The two keys are compared by essence * (`mediaTypeEssence`: `type/subtype`, case folded, parameters stripped), * since neither case nor a parameter such as `charset` changes how the * value is encoded; both are carried as written. `diffParams` (operations) @@ -86,16 +110,33 @@ export function parameterContentTypeShift( oldParam: Parameter, newParam: Parameter, ): ParameterContentTypeShift | undefined { - const oldContentType = oldParam.contentType ?? null; - const newContentType = newParam.contentType ?? null; + const oldSide = declaredMediaType(oldParam); + const newSide = declaredMediaType(newParam); const same = - oldContentType === null || newContentType === null - ? oldContentType === newContentType - : mediaTypeEssence(oldContentType) === mediaTypeEssence(newContentType); + oldSide === null || newSide === null + ? oldSide === newSide + : oldSide.read === newSide.read && + mediaTypeEssence(oldSide.contentType) === mediaTypeEssence(newSide.contentType); if (same) return undefined; + const label = (side: DeclaredMediaType | null): string => + side === null ? '' : side.read ? side.contentType : `${side.contentType} (unread)`; return { - oldContentType, - newContentType, - details: `content type ${oldContentType ?? ''} → ${newContentType ?? ''}`, + oldContentType: oldSide?.contentType ?? null, + newContentType: newSide?.contentType ?? null, + details: `content type ${label(oldSide)} → ${label(newSide)}`, }; } + +/** + * Whether both parameters are read from a JSON media type — + * `application/json` or a `+json` one (`isJsonMediaType`, the rule the + * parser reads a `content` map by) — so the value is JSON-encoded on both + * sides and a move between the two (`application/json` → + * `application/vnd.api+json`) changes nothing a caller sends or a handler + * decodes: the one `param-content-type-changed` that is additive (#410). + * A JSON entry without a schema is not read, and its value is sent as + * written, so it does not count. + */ +export function jsonEncodedOnBothSides(oldParam: Parameter, newParam: Parameter): boolean { + return [oldParam, newParam].every((p) => p.contentType !== undefined && isJsonMediaType(p.contentType)); +} diff --git a/src/differ/types.ts b/src/differ/types.ts index 4c552e81..e75e7607 100644 --- a/src/differ/types.ts +++ b/src/differ/types.ts @@ -953,8 +953,9 @@ export interface ParamChange { newDeprecated?: boolean; /** * For `param-content-type-changed`: `Parameter.contentType` on each side as - * written, or `null` for a parameter typed by `schema` (see - * `parameterContentTypeShift` in `params.ts`). + * written — for a `content` map none of whose media types is read, the + * first of its `Parameter.contentTypes` — or `null` for a parameter typed + * by `schema` (see `parameterContentTypeShift` in `params.ts`). */ oldContentType?: string | null; newContentType?: string | null; @@ -968,7 +969,9 @@ export interface ParamChange { /** * For `param-deprecated-changed`: `live → deprecated` or `deprecated → live`. * For `param-name-changed`: `name X-Pet-Token → x-pet-token`. - * For `param-content-type-changed`: `content type → application/json`. + * For `param-content-type-changed`: `content type → application/json`, + * a side whose map the parser does not read followed by `(unread)` + * (`content type → application/xml (unread)`). */ details?: string; } diff --git a/src/differ/webhooks.ts b/src/differ/webhooks.ts index cc8d09f8..455124d6 100644 --- a/src/differ/webhooks.ts +++ b/src/differ/webhooks.ts @@ -1,6 +1,6 @@ import type { Webhook } from '../ir/types.js'; import type { Change, ParamChange, RequestBodyContentTypesChange } from './types.js'; -import { locatedParams, paramKey, parameterContentTypeShift } from './params.js'; +import { jsonEncodedOnBothSides, locatedParams, paramKey, parameterContentTypeShift } from './params.js'; import { deprecationDetails, deprecationFlip, @@ -288,15 +288,17 @@ function diffWebhookParams(oldWebhook: Webhook, newWebhook: Webhook): ParamChang } } - // The media type the value is serialized in (`Parameter.contentType`) - // moved: the handler decodes the value the sender now encodes another - // way, so breaking from the receiver's side too, like a type change. + // The media type the value is serialized in (`Parameter.contentType`, + // or the one an unread `content` map declares) moved: the handler + // decodes the value the sender now encodes another way, so breaking from + // the receiver's side too, like a type change — unless both sides are + // read from a JSON media type, which the handler decodes alike. const reencoded = parameterContentTypeShift(oldParam, newParam); if (reencoded) { changes.push({ kind: 'param-content-type-changed', paramName: name, - classification: 'breaking', + classification: jsonEncodedOnBothSides(oldParam, newParam) ? 'additive' : 'breaking', paramLocation: loc, ...reencoded, }); diff --git a/src/ir/types.ts b/src/ir/types.ts index 2a30ac86..08a066cf 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -874,13 +874,28 @@ export interface Parameter { * `content` and the map is read (see `type`) — a schema-less `text/plain` * entry included; absent for a `schema` parameter, and for a `content` * map none of whose media types is read, whose string value is sent as - * written. An emitter that serializes parameters by `style` and `explode` - * must instead serialize such a value in this media type: JSON-encode an - * `application/json` or `+json` value, send a `text/plain` value as text. - * `oagen diff` compares it by essence and reports a change as a breaking - * `param-content-type-changed`. + * written (`contentTypes` keeps that map's media types). An emitter that + * serializes parameters by `style` and `explode` must instead serialize + * such a value in this media type: JSON-encode an `application/json` or + * `+json` value, send a `text/plain` value as text. `oagen diff` compares + * it by essence — an unread map by the first of its `contentTypes` — and + * reports a change as a `param-content-type-changed`, breaking unless both + * sides are read from a JSON media type. */ contentType?: string; + /** + * The media types the parameter's `content` map declares: its keys, in + * document order and exactly as written. Present when the parameter is + * declared with `content` and the map declares at least one media type, + * whether or not one of them is read (`contentType` is the one that is), + * as `Operation.requestBodyContentTypes` is for a request body; absent for + * a `schema` parameter, a `schema` beside the map included. OpenAPI allows + * the map one entry. For a map none of whose media types is read, this is + * where the media type the document declares the value in is kept: the + * value is typed as a string and sent as written, so an emitter has + * nothing to encode. + */ + contentTypes?: string[]; /** * Validation keywords from the parameter's schema. When that schema is a * `$ref`, the target's keywords are merged underneath the parameter's own diff --git a/src/parser/operations.ts b/src/parser/operations.ts index 7d31b6cb..cd4e41e8 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -3099,6 +3099,8 @@ export interface ParameterTyping { media?: ParameterMediaTypeObject; /** The `content` key `media` sits under, as written; set exactly when `media` is. */ contentType?: string; + /** Every `content` key, as written and in document order, read or not; set when the declaration has no `schema` and the map declares at least one. */ + contentTypes?: string[]; } /** @@ -3108,6 +3110,8 @@ export interface ParameterTyping { * beside it for the examples it may carry. `schema` is absent — and the * value a string — when the declaration has neither, when the picked * `text/plain` entry declares no schema, or when nothing in the map is read. + * Every key the map declares comes back as `contentTypes`, read or not, so + * an unread map keeps the media type it declares the value in. * A `schema` beside a `content` map wins and the map is not consulted. * Every pass that asks what a parameter's schema is reads it through this * one resolution: the typing (`extractParams`), the model deferral @@ -3117,8 +3121,12 @@ export interface ParameterTyping { */ export function parameterTyping(declaration: SchemaOrContent): ParameterTyping { if (declaration.schema) return { schema: declaration.schema }; + const contentTypes = Object.keys(declaration.content ?? {}); + const declared = contentTypes.length > 0 ? { contentTypes } : {}; const picked = selectParameterContent(declaration.content); - return picked ? { schema: picked.media.schema, media: picked.media, contentType: picked.contentType } : {}; + return picked + ? { schema: picked.media.schema, media: picked.media, contentType: picked.contentType, ...declared } + : declared; } /** The schema `parameterTyping` resolves, alone. */ @@ -3166,7 +3174,7 @@ export function extractParams( // `content` entry's (`parameterTyping`) — and that entry's Media Type // Object for the examples it may carry. Every read below goes through // `schema`, so a parameter declared either way is typed by one rule. - const { schema, media, contentType } = parameterTyping(p); + const { schema, media, contentType, contentTypes } = parameterTyping(p); const refTarget = resolveComponentSchemaRef(schema, componentSchemas); const exampleContext = `parameter "${p.name}" in ${opLabel ?? location}`; // Each level is resolved on its own — the parameter's declaration, the @@ -3227,6 +3235,9 @@ export function extractParams( // The media type the value is serialized in, for a parameter read // from `content`; a `schema` parameter is serialized by style/explode. ...(contentType !== undefined ? { contentType } : {}), + // Every media type the `content` map declares, read or not, so a map + // none of whose media types is read keeps the one it declares. + ...(contentTypes !== undefined ? { contentTypes } : {}), ...(constraints ? { constraints } : {}), }; }); diff --git a/test/differ/param-content-type.test.ts b/test/differ/param-content-type.test.ts index ece934b4..b51bfb5b 100644 --- a/test/differ/param-content-type.test.ts +++ b/test/differ/param-content-type.test.ts @@ -7,16 +7,19 @@ * between two media types that differ in essence, changes what a caller must * send even when the type did not move, so `diffParams` and * `diffWebhookParams` report it as a breaking `param-content-type-changed` — - * beside, not instead of, whatever else changed about the parameter. + * beside, not instead of, whatever else changed about the parameter. A + * `content` map none of whose media types is read is compared by the media + * type it declares (`Parameter.contentTypes`), not taken for `schema`, and a + * move between two JSON media types the parser reads is additive (#410). */ -import { describe, it, expect, vi, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterAll, afterEach, beforeEach } from 'vitest'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { diffSpecs } from '../../src/differ/diff.js'; import { diffOperations } from '../../src/differ/operations.js'; import { diffWebhooks } from '../../src/differ/webhooks.js'; -import { parameterContentTypeShift } from '../../src/differ/params.js'; +import { jsonEncodedOnBothSides, parameterContentTypeShift } from '../../src/differ/params.js'; import { classifyParamChange } from '../../src/differ/classify.js'; import { diffCommand } from '../../src/cli/diff.js'; import { parseSpec } from '../../src/parser/parse.js'; @@ -119,6 +122,64 @@ describe('parameterContentTypeShift', () => { classification: 'breaking', }); }); + + it('reads a content map none of whose media types is read as the media type it declares, not as `schema` (#410)', () => { + // Typed as a string on every side: only the declaration moves. + const schema = filter({ type: string }); + const xml = filter({ type: string, contentTypes: ['application/xml'] }); + const json = filter({ type: string, contentType: 'application/json', contentTypes: ['application/json'] }); + expect(parameterContentTypeShift(schema, xml)).toEqual({ + oldContentType: null, + newContentType: 'application/xml', + details: 'content type → application/xml (unread)', + }); + expect(parameterContentTypeShift(xml, schema)).toEqual({ + oldContentType: 'application/xml', + newContentType: null, + details: 'content type application/xml (unread) → ', + }); + expect(parameterContentTypeShift(json, xml)).toEqual({ + oldContentType: 'application/json', + newContentType: 'application/xml', + details: 'content type application/json → application/xml (unread)', + }); + // A JSON entry that loses its schema keeps its media type but is no + // longer read, so its value is sent as written rather than JSON-encoded. + expect(parameterContentTypeShift(json, filter({ type: string, contentTypes: ['application/json'] }))).toEqual({ + oldContentType: 'application/json', + newContentType: 'application/json', + details: 'content type application/json → application/json (unread)', + }); + // Compared by essence, as a media type that is read is. + expect( + parameterContentTypeShift(xml, filter({ type: string, contentTypes: ['APPLICATION/XML; charset=utf-8'] })), + ).toBeUndefined(); + expect(parameterContentTypeShift(xml, xml)).toBeUndefined(); + }); + + it('tells a move between two JSON media types the parser reads, which encode the value alike, additive (#410)', () => { + const json = filter({ contentType: 'application/json' }); + const vnd = filter({ contentType: 'application/vnd.notes+json' }); + expect(jsonEncodedOnBothSides(json, vnd)).toBe(true); + expect(jsonEncodedOnBothSides(vnd, json)).toBe(true); + expect(jsonEncodedOnBothSides(vnd, filter({ contentType: 'application/merge-patch+json; charset=utf-8' }))).toBe( + true, + ); + expect(jsonEncodedOnBothSides(json, filter({ contentType: 'text/plain' }))).toBe(false); + expect(jsonEncodedOnBothSides(filter(), json)).toBe(false); + // A `+json` entry without a schema is not read: its value is sent as written. + expect(jsonEncodedOnBothSides(json, filter({ type: string, contentTypes: ['application/vnd.notes+json'] }))).toBe( + false, + ); + expect(classifyParamChange('param-content-type-changed', 'filter', true)).toEqual({ + kind: 'param-content-type-changed', + paramName: 'filter', + classification: 'additive', + }); + expect(classifyParamChange('param-content-type-changed', 'filter', false)).toMatchObject({ + classification: 'breaking', + }); + }); }); describe('diffOperations / diffWebhooks – param-content-type-changed', () => { @@ -192,6 +253,71 @@ describe('diffOperations / diffWebhooks – param-content-type-changed', () => { ).toEqual([]); }); + it('reports a move between two JSON media types as additive, on an operation and on a webhook (#410)', () => { + const json = filter({ contentType: 'application/json' }); + const vnd = filter({ contentType: 'application/vnd.notes+json' }); + const api = filter({ contentType: 'application/vnd.api+json' }); + const additive = (oldContentType: string, newContentType: string) => ({ + ...reencoded(oldContentType, newContentType), + classification: 'additive', + }); + + const changes = diffOperations('Notes', [listNotes(json)], [listNotes(vnd)]); + expect(paramChangesOf(changes)).toEqual([additive('application/json', 'application/vnd.notes+json')]); + expect(changes[0]).toMatchObject({ kind: 'operation-modified', classification: 'additive' }); + expect(paramChangesOf(diffOperations('Notes', [listNotes(vnd)], [listNotes(json)]))).toEqual([ + additive('application/vnd.notes+json', 'application/json'), + ]); + expect(paramChangesOf(diffOperations('Notes', [listNotes(vnd)], [listNotes(api)]))).toEqual([ + additive('application/vnd.notes+json', 'application/vnd.api+json'), + ]); + + const webhookChanges = diffWebhooks([noteAdded(json)], [noteAdded(vnd)]); + expect(webhookParamChangesOf(webhookChanges)).toEqual([additive('application/json', 'application/vnd.notes+json')]); + expect(webhookChanges[0]).toMatchObject({ kind: 'webhook-modified', classification: 'additive' }); + }); + + it('keeps a move from a JSON media type to one that is not, or one the parser does not read, breaking (#410)', () => { + const json = filter({ contentType: 'application/json' }); + // A `+json` entry without a schema: not read, so typed as a string and sent as written. + const unreadVnd = filter({ type: string, contentTypes: ['application/vnd.notes+json'] }); + expect( + paramChangesOf(diffOperations('Notes', [listNotes(json)], [listNotes(filter({ contentType: 'text/plain' }))])), + ).toEqual([reencoded('application/json', 'text/plain')]); + expect(paramChangesOf(diffOperations('Notes', [listNotes(json)], [listNotes(unreadVnd)]))).toEqual([ + { kind: 'param-type-changed', paramName: 'filter', classification: 'breaking', paramLocation: 'query' }, + { + ...reencoded('application/json', 'application/vnd.notes+json'), + details: 'content type application/json → application/vnd.notes+json (unread)', + }, + ]); + expect(webhookParamChangesOf(diffWebhooks([noteAdded(json)], [noteAdded(unreadVnd)]))).toContainEqual({ + ...reencoded('application/json', 'application/vnd.notes+json'), + details: 'content type application/json → application/vnd.notes+json (unread)', + }); + }); + + it('reports a move to or from a content map none of whose media types is read, breaking, naming the media type (#410)', () => { + const schema = filter({ type: string }); + const xml = filter({ type: string, contentTypes: ['application/xml'] }); + const toXml = { + ...reencoded(null, 'application/xml'), + details: 'content type → application/xml (unread)', + }; + const fromXml = { + ...reencoded('application/xml', null), + details: 'content type application/xml (unread) → ', + }; + + const changes = diffOperations('Notes', [listNotes(schema)], [listNotes(xml)]); + expect(paramChangesOf(changes)).toEqual([toXml]); + expect(changes[0]).toMatchObject({ kind: 'operation-modified', classification: 'breaking' }); + expect(paramChangesOf(diffOperations('Notes', [listNotes(xml)], [listNotes(schema)]))).toEqual([fromXml]); + expect(webhookParamChangesOf(diffWebhooks([noteAdded(schema)], [noteAdded(xml)]))).toEqual([toXml]); + expect(webhookParamChangesOf(diffWebhooks([noteAdded(xml)], [noteAdded(schema)]))).toEqual([fromXml]); + expect(diffOperations('Notes', [listNotes(xml)], [listNotes(xml)])).toEqual([]); + }); + it('is not mirrored into behaviorChanges, as param-type-changed is not', () => { const diff = diffSpecs(specOf(listNotes(filter())), specOf(listNotes(filter({ contentType: 'application/json' })))); expect(diff.summary.breaking).toBe(1); @@ -304,4 +430,110 @@ describe('diffSpecs – end to end (issue #297 review, point 3)', () => { expect(await exitCodeOf(before, respelled)).toBe(0); expect(await exitCodeOf(before, before)).toBe(0); }); + + describe('a content map the parser does not read, and a move between JSON media types (issue #410)', () => { + /** `GET /notes` (`listNotes`) with the query parameter `filter`, declared by `declaration`. */ + const notes = (declaration: Record) => ({ + openapi: '3.1.0', + info: { title: 'repro', version: '1' }, + paths: { + '/notes': { + get: { + operationId: 'listNotes', + parameters: [{ name: 'filter', in: 'query', ...declaration }], + responses: { + '200': { description: 'OK', content: { 'application/json': { schema: { type: 'string' } } } }, + }, + }, + }, + }, + }); + const text = { type: 'string' }; + const owner = { type: 'object', properties: { owner: { type: 'string' } } }; + // The issue's `schema.json`, `json.json` and `xml.json`. + const documents = { + schema: notes({ schema: text }), + json: notes(byContent('application/json')(text)), + xml: notes(byContent('application/xml')(text)), + }; + const paramChangesBetween = async (from: keyof typeof documents, to: keyof typeof documents) => + diffSpecs(await parse(`${from}.json`, documents[from]), await parse(`${to}.json`, documents[to])).changes.flatMap( + (change) => ('paramChanges' in change ? change.paramChanges : []), + ); + + beforeEach(() => { + // `application/xml` is not read; the parser says so on every parse. + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('reports schema → xml as a breaking move to content, naming application/xml, exit 2', async () => { + expect(await paramChangesBetween('schema', 'xml')).toEqual([ + { + kind: 'param-content-type-changed', + paramName: 'filter', + classification: 'breaking', + paramLocation: 'query', + oldContentType: null, + newContentType: 'application/xml', + details: 'content type → application/xml (unread)', + }, + ]); + expect(await paramChangesBetween('xml', 'schema')).toEqual([ + { + kind: 'param-content-type-changed', + paramName: 'filter', + classification: 'breaking', + paramLocation: 'query', + oldContentType: 'application/xml', + newContentType: null, + details: 'content type application/xml (unread) → ', + }, + ]); + expect(await exitCodeOf(documents.schema, documents.xml)).toBe(2); + expect(await exitCodeOf(documents.xml, documents.schema)).toBe(2); + }); + + it('names application/xml as the new side of json → xml, not ', async () => { + expect(await paramChangesBetween('json', 'xml')).toEqual([ + { + kind: 'param-content-type-changed', + paramName: 'filter', + classification: 'breaking', + paramLocation: 'query', + oldContentType: 'application/json', + newContentType: 'application/xml', + details: 'content type application/json → application/xml (unread)', + }, + ]); + expect(await paramChangesBetween('xml', 'xml')).toEqual([]); + }); + + it('reports application/json → application/vnd.notes+json as additive, exit 1, and the same between two +json types', async () => { + const before = notes(byContent('application/json')(owner)); + const after = notes(byContent('application/vnd.notes+json')(owner)); + const diff = diffSpecs(await parse('before.json', before), await parse('after.json', after)); + // The inline object keeps its model on both sides: the operation is the only change. + expect(diff.changes.map((c) => c.kind)).toEqual(['operation-modified']); + expect(paramChangesOf(diff.changes as ReturnType)).toEqual([ + { ...reencoded('application/json', 'application/vnd.notes+json'), classification: 'additive' }, + ]); + expect(diff.summary.breaking).toBe(0); + expect(await exitCodeOf(before, after)).toBe(1); + + const api = notes(byContent('application/vnd.api+json; charset=utf-8')(owner)); + expect( + paramChangesOf( + diffSpecs(await parse('after.json', after), await parse('api.json', api)).changes as ReturnType< + typeof diffOperations + >, + ), + ).toEqual([ + { + ...reencoded('application/vnd.notes+json', 'application/vnd.api+json; charset=utf-8'), + classification: 'additive', + }, + ]); + expect(await exitCodeOf(after, api)).toBe(1); + }); + }); }); diff --git a/test/parser/parameter-content.test.ts b/test/parser/parameter-content.test.ts index 4c3f393a..1eeca137 100644 --- a/test/parser/parameter-content.test.ts +++ b/test/parser/parameter-content.test.ts @@ -73,9 +73,13 @@ const paramsOf = (op: Operation): Parameter[] => [ ...(op.cookieParams ?? []), ...(op.bodyOwnedQueryParams ?? []), ]; -/** A parameter without the media type it is serialized in, to compare a `content` twin with its `schema` twin. */ +/** + * A parameter without the media types only a `content` declaration carries — + * the one its value is serialized in and the ones its map declares — to + * compare a `content` twin with its `schema` twin. + */ const withoutContentType = (p: Parameter): Partial => - Object.fromEntries(Object.entries(p).filter(([key]) => key !== 'contentType')); + Object.fromEntries(Object.entries(p).filter(([key]) => key !== 'contentType' && key !== 'contentTypes')); const namesIn = (ref: TypeRef, kind: 'model' | 'enum'): string[] => { const names: string[] = []; walkTypeRef(ref, { [kind]: (node: { name: string }) => names.push(node.name) }); @@ -216,7 +220,8 @@ describe('a parameter declared with `content` instead of `schema` (issue #297)', // only the `content` twin carries. expect(paramsOf(createNote).map(withoutContentType)).toEqual(paramsOf(copyNote)); expect(paramsOf(createNote).map((p) => p.contentType)).toEqual(Array(5).fill('application/json')); - expect(paramsOf(copyNote).some((p) => 'contentType' in p)).toBe(false); + expect(paramsOf(createNote).map((p) => p.contentTypes)).toEqual(Array(5).fill(['application/json'])); + expect(paramsOf(copyNote).some((p) => 'contentType' in p || 'contentTypes' in p)).toBe(false); expect(paramsOf(createNote).map((p) => typeLabel(p.type))).toEqual([ 'primitive integer', 'enum NotesMood', @@ -261,6 +266,7 @@ describe('a parameter declared with `content` instead of `schema` (issue #297)', const { listNotes, listRecentNotes } = opsOf(spec); expect(paramsOf(listNotes).map(withoutContentType)).toEqual(paramsOf(listRecentNotes)); expect(paramsOf(listNotes).map((p) => p.contentType)).toEqual(Array(3).fill('application/json')); + expect(paramsOf(listNotes).map((p) => p.contentTypes)).toEqual(Array(3).fill(['application/json'])); // `prefs` is singularized by the model naming, as for a `schema` parameter. expect(paramsOf(listNotes).map((p) => typeLabel(p.type))).toEqual([ 'model NotesFilter', @@ -350,6 +356,77 @@ describe('a parameter declared with `content` instead of `schema` (issue #297)', expectSound(spec); }); + it('carries every media type the content map declares, as written, whether or not one is read (#410)', async () => { + const xml = content('application/xml', { schema: { type: 'string' } }); + const spec = await parseDocument( + document( + { + '/notes': getOp('listNotes', [ + { $ref: '#/components/parameters/Filter' }, + { name: 'mood', in: 'query', ...content('application/json; charset=utf-8', { schema: mood }) }, + { + name: 'ordered', + in: 'query', + content: { + 'text/plain': { schema: { type: 'integer' } }, + 'application/json': { schema: { type: 'boolean' } }, + }, + }, + // Not read: typed as a string, under the media type it declares. + { name: 'xml', in: 'query', ...xml }, + { name: 'schemaless', in: 'header', ...content('application/json') }, + // A `schema` wins over a map beside it, and an empty map declares nothing. + { name: 'both', in: 'query', schema: { type: 'integer' }, ...xml }, + { name: 'empty', in: 'query', content: {} }, + { name: 'plain', in: 'query', schema: { type: 'string' } }, + ]), + }, + { + components: { + parameters: { + Filter: { name: 'filter', in: 'query', ...content('APPLICATION/XML', { schema: { type: 'string' } }) }, + }, + }, + webhooks: { + noteAdded: { + post: { + parameters: [{ name: 'X-Note', in: 'header', ...xml }], + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }, + ), + ); + + const { listNotes } = opsOf(spec); + expect(paramsOf(listNotes).map((p) => [p.name, typeLabel(p.type), p.contentType, p.contentTypes])).toEqual([ + ['filter', 'primitive string', undefined, ['APPLICATION/XML']], + ['mood', 'enum NotesMood', 'application/json; charset=utf-8', ['application/json; charset=utf-8']], + ['ordered', 'primitive boolean', 'application/json', ['text/plain', 'application/json']], + ['xml', 'primitive string', undefined, ['application/xml']], + ['both', 'primitive integer', undefined, undefined], + ['empty', 'primitive string', undefined, undefined], + ['plain', 'primitive string', undefined, undefined], + ['schemaless', 'primitive string', undefined, ['application/json']], + ]); + // Absent, not set to `undefined`, where nothing is declared under `content`. + for (const name of ['both', 'empty', 'plain']) { + expect(paramsOf(listNotes).find((p) => p.name === name)).not.toHaveProperty('contentTypes'); + } + const [noteAdded] = spec.webhooks!; + expect(noteAdded.headerParams[0]).toMatchObject({ name: 'X-Note', contentTypes: ['application/xml'] }); + expect(noteAdded.headerParams[0]).not.toHaveProperty('contentType'); + // The unread maps are still reported, once per site. + expect(warnings()).toEqual([ + unreadWarning('Parameter "filter" (in: query) on listNotes', 'APPLICATION/XML'), + unreadWarning('Parameter "xml" (in: query) on listNotes', 'application/xml'), + unreadWarning('Parameter "schemaless" (in: header) on listNotes', 'application/json'), + unreadWarning('Parameter "X-Note" (in: header) on webhooks.noteAdded.post', 'application/xml'), + ]); + expectSound(spec); + }); + it('reads the entry by media type essence: parameters and case aside, a +json type, and text/plain with or without a schema', () => { const { services } = extractOperations( document({ @@ -564,8 +641,8 @@ describe('a webhook parameter declared with `content` (the `mintParameter` twin) }); /** * A webhook's parameter types and models with its stem replaced, so two - * stems can be compared; the media type only a `content` twin carries is - * left out and asserted on its own. + * stems can be compared; the media types only a `content` twin carries are + * left out and asserted on their own. */ const shape = (hook: Webhook, models: Model[], stem: string) => JSON.parse( @@ -587,9 +664,10 @@ describe('a webhook parameter declared with `content` (the `mintParameter` twin) // The key as written travels with each `content` parameter; the `schema` twin carries none. for (const list of [adopted.headerParams, adopted.queryParams, adopted.cookieParams!]) { expect(list.map((p) => p.contentType)).toEqual(['application/json; charset=utf-8']); + expect(list.map((p) => p.contentTypes)).toEqual([['application/json; charset=utf-8']]); } for (const list of [returned.headerParams, returned.queryParams, returned.cookieParams!]) { - expect(list.some((p) => 'contentType' in p)).toBe(false); + expect(list.some((p) => 'contentType' in p || 'contentTypes' in p)).toBe(false); } expect(adopted.headerParams[0].type).toEqual({ kind: 'model', name: 'PetAdoptedXMeta' }); expect(adopted.queryParams[0].type).toEqual({ kind: 'enum', name: 'PetAdoptedMood', values: ['calm', 'busy'] }); From c1fca644dba66830556bc79fe7cf177caf7aa769 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 16:05:28 -0400 Subject: [PATCH 12/59] fix(parser)!: split a path segment only at a custom method's colon Since #322, `readPath` kept a parameter's braces whole only when they opened the segment, and split every other colon. So `file-{file_path:path}` was cut inside its braces (`Files.listPath`, `list_path}`) and the literal `urn:pet:1` lost all but its last part (`Things.list1`), though neither path declares a custom method. `readPath` now blanks out the colons inside every pair of braces and splits a segment only at its one remaining colon. AIP-136 appends a single verb, so a segment with several colons outside braces is kept whole. `/pets/{petId}:adopt`, `/pets:batchGet` and `file-{file_path:path}:download` still read their custom method. The JSDoc, `docs/architecture/pipeline.md` and `docs/architecture/ir-types.md` say which colon starts one. Closes #378 BREAKING CHANGE: a name read from a path segment with a colon inside braces that do not open it, or with several colons outside braces, is read from the whole segment again, as before #322: `GET /v1/files/file-{file_path:path}` is `listFileFilePathPath` (was `listPath`) and `GET /things/urn:pet:1` is `listUrnPet1` (was `list1`). Service names, inline model names and `deriveMethodName` read such a segment whole as well. --- docs/architecture/ir-types.md | 2 +- docs/architecture/pipeline.md | 2 +- src/ir/operation-hints.ts | 20 ++++++----- test/ir/operation-hints.test.ts | 38 ++++++++++++++++++++ test/parser/fallback-operation-names.test.ts | 24 +++++++++++++ 5 files changed, 76 insertions(+), 10 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 3a7f0bb5..c50c6638 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -338,7 +338,7 @@ An override that cannot be applied is reported as an `[oagen] Warning:` and igno ## Service & Operation -Service names are derived from OpenAPI tags when present — the first usable tag on an operation (`Operation.tags[0]`: the first entry that is a non-empty string, so `['', 'notes']` and `[42, 'notes']` both name `Notes`) is converted to PascalCase (e.g., `tags: ["multi-factor-auth"]` → service `MultiFactorAuth`). When no usable tag is present, the parser falls back to a path segment (e.g., `/organizations/{id}` → `Organizations`), skipping the API root the document's untagged operations sit under. Each untagged path contributes its leading literal segments through the last API marker — `api`, `rest`, or a version such as `v1`, `2.0` or `2010-04-01` — and the root is the longest marker-terminated prefix shared (compared verbatim) by every contribution that no other contribution extends, so a document whose paths all start with `/api/v1/` groups `/api/v1/notes` under `Notes` rather than collapsing the whole API into one `Api` service, and `/wiki/rest/api/content` lands in `Content`. A path that stops short of that root — `/api/openapi.json` or `/api/admin/users` beside `/api/v1/notes` — skips only its own `api` (`OpenapiJson`, `Admin`) and leaves the root at `api/v1` for the rest, so `/api/v1/notes` stays `Notes`; only a second version, `/api/v2/…` beside `/api/v1/…`, shortens the root to `api`, which keeps the versions apart as `V1` and `V2`. The service is named from the first literal segment after the root, so `/v1/notes` and `/v1/{teamId}/notes` share `Notes`, and a custom method written after a colon (`/v1/pets:batchGet`, `/v1/pets/{petId}:adopt`) is read as its segment followed by the verb, so both land in `Pets`, while the verb itself never names a service, so `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}` (the slash-written `/v1/{name}/cancel` names `Cancel`); a path with nothing literal after the root (`/api` itself, `/api/{id}`, `/api/v1:reset`) keeps its first segment, `Api`. A path with no marker at all (`/health`, `/openapi.json`) sits outside the root, keeps its own first segment, and does not switch the root off for the rest of the document. A shared prefix that carries no marker is kept whole, since a module prefix every path shares (`/user_management/users`, `/user_management/invitations` → `UserManagement`) is the service, not noise; and `/v1/…` beside `/v2/…` has no shared root, so the versions stay separate services. Tagged operations and path items without an operation take no part. The path-prefix split that carves a tagged service into one per first segment (`/organizations` and `/organization_domains` both tagged `organizations` → `Organizations` and `OrganizationDomains`) is decided over the tag's own operations alone, from the grouping before any service is split: a tag whose operations all sit under one first segment keeps them together even when that segment is not the tag, so `/api/v1/notes` tagged `notes` stays in `Notes`, not `Api`, whatever untagged operations `Notes` picked up by path. An untagged operation keeps the service its path named — it is never moved by a split, and it neither causes nor prevents one — so `/notes` and `/api/v1/notes` meet in `Notes` instead of the latter moving to `Api`, and the collision rules below tell their names apart. A split-off part named like an existing service — another tag's, or a path-named one — joins it, and the operations it brings do not re-split that service. Fallback operation names skip the root and the service segment the same way, so `/api/v1/notes` is `Notes.list`, never `Notes.listV1Notes`; when two path-named operations still share a name in one service and their paths differ only by parameters, the parameters qualify them (`/v1/notes` → `Notes.list`, `/v1/{teamId}/notes` → `Notes.listByTeamId`; see the collision rules under Operation below). Without a root — a root-less document, or a path outside the root — a path-named service is the path's first segment (`Default` when that segment is a parameter) and the fallback name skips the service segment alone, up to those collision rules: an untagged `/{tenant}/users` is `Default.listUsers`, and `/{id}` is `Default.get`. A tagged operation's service is its tag whatever the root, subject only to the path-prefix split above: `/api/notes` tagged `notes` is `Notes.listNotes`. Emitters can further remap service names via an overlay (e.g., `MultiFactorAuth` → `Mfa`) to match existing SDK class names. +Service names are derived from OpenAPI tags when present — the first usable tag on an operation (`Operation.tags[0]`: the first entry that is a non-empty string, so `['', 'notes']` and `[42, 'notes']` both name `Notes`) is converted to PascalCase (e.g., `tags: ["multi-factor-auth"]` → service `MultiFactorAuth`). When no usable tag is present, the parser falls back to a path segment (e.g., `/organizations/{id}` → `Organizations`), skipping the API root the document's untagged operations sit under. Each untagged path contributes its leading literal segments through the last API marker — `api`, `rest`, or a version such as `v1`, `2.0` or `2010-04-01` — and the root is the longest marker-terminated prefix shared (compared verbatim) by every contribution that no other contribution extends, so a document whose paths all start with `/api/v1/` groups `/api/v1/notes` under `Notes` rather than collapsing the whole API into one `Api` service, and `/wiki/rest/api/content` lands in `Content`. A path that stops short of that root — `/api/openapi.json` or `/api/admin/users` beside `/api/v1/notes` — skips only its own `api` (`OpenapiJson`, `Admin`) and leaves the root at `api/v1` for the rest, so `/api/v1/notes` stays `Notes`; only a second version, `/api/v2/…` beside `/api/v1/…`, shortens the root to `api`, which keeps the versions apart as `V1` and `V2`. The service is named from the first literal segment after the root, so `/v1/notes` and `/v1/{teamId}/notes` share `Notes`, and a custom method written after a colon (`/v1/pets:batchGet`, `/v1/pets/{petId}:adopt`; only a segment's one colon outside a parameter's braces starts one, so `file-{file_path:path}` and `urn:pet:1` are one segment each) is read as its segment followed by the verb, so both land in `Pets`, while the verb itself never names a service, so `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}` (the slash-written `/v1/{name}/cancel` names `Cancel`); a path with nothing literal after the root (`/api` itself, `/api/{id}`, `/api/v1:reset`) keeps its first segment, `Api`. A path with no marker at all (`/health`, `/openapi.json`) sits outside the root, keeps its own first segment, and does not switch the root off for the rest of the document. A shared prefix that carries no marker is kept whole, since a module prefix every path shares (`/user_management/users`, `/user_management/invitations` → `UserManagement`) is the service, not noise; and `/v1/…` beside `/v2/…` has no shared root, so the versions stay separate services. Tagged operations and path items without an operation take no part. The path-prefix split that carves a tagged service into one per first segment (`/organizations` and `/organization_domains` both tagged `organizations` → `Organizations` and `OrganizationDomains`) is decided over the tag's own operations alone, from the grouping before any service is split: a tag whose operations all sit under one first segment keeps them together even when that segment is not the tag, so `/api/v1/notes` tagged `notes` stays in `Notes`, not `Api`, whatever untagged operations `Notes` picked up by path. An untagged operation keeps the service its path named — it is never moved by a split, and it neither causes nor prevents one — so `/notes` and `/api/v1/notes` meet in `Notes` instead of the latter moving to `Api`, and the collision rules below tell their names apart. A split-off part named like an existing service — another tag's, or a path-named one — joins it, and the operations it brings do not re-split that service. Fallback operation names skip the root and the service segment the same way, so `/api/v1/notes` is `Notes.list`, never `Notes.listV1Notes`; when two path-named operations still share a name in one service and their paths differ only by parameters, the parameters qualify them (`/v1/notes` → `Notes.list`, `/v1/{teamId}/notes` → `Notes.listByTeamId`; see the collision rules under Operation below). Without a root — a root-less document, or a path outside the root — a path-named service is the path's first segment (`Default` when that segment is a parameter) and the fallback name skips the service segment alone, up to those collision rules: an untagged `/{tenant}/users` is `Default.listUsers`, and `/{id}` is `Default.get`. A tagged operation's service is its tag whatever the root, subject only to the path-prefix split above: `/api/notes` tagged `notes` is `Notes.listNotes`. Emitters can further remap service names via an overlay (e.g., `MultiFactorAuth` → `Mfa`) to match existing SDK class names. `Service.description` comes from the document's top-level `tags` list, matched by name: a tag entry applies to the service whose PascalCase name its `name` converts to, so `user-management`, `User Management`, and `user_management` all map to `UserManagement`. A service whose own name is a declared tag is described by that tag alone — its description when it has one, nothing otherwise — so another tag's wording is never borrowed for it (operations tagged `api_keys` that the path-prefix split merges into `UserManagement` do not label it "API keys"). A service whose own name is not declared, which is what the split produces when it carves `OrganizationDomains` out of operations tagged `organizations`, inherits the description of the first described tag its operations carry. The key is omitted when neither rule yields a description, including for path-named services whose operations have no tag. Malformed `tags` values (a non-array, `null` entries, non-string names) are skipped rather than failing the parse. diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index d99c118b..76254d06 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -63,7 +63,7 @@ The collection vs. instance distinction is determined by whether the path ends w A literal after a `{param}` that does not look plural is a singleton of that item: one thing about one pet, read with the item verb (`get`, not `list`) and named after the method's verb, the singular parent and the segment — `GET /store/pets/{id}/status` → `getPetStatus`, `DELETE /users/{id}/avatar` → `deleteUserAvatar`, `PUT` → `updateUserAvatar`, `HEAD` → `checkUserAvatar`, and `POST /orders/{id}/status` → `createOrderStatus`. The parent is kept even when it is the first segment, as it is for an action: `GET /pets/{id}/status` in `Pets` is `getPetStatus`. A plural literal after a `{param}` (`/users/{id}/photos`, `/orgs/{id}/api_keys`) is a nested collection: `listPhotos`, `createApiKeys`. An irregular plural that `singularizePathSegment` does not recognise (`children`, `people`, `data`) does not look plural, so `GET /pets/{id}/children` is the singleton `getPetChildren`; give it an `operationId`. -An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /pets/list-all` → `listAll`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. A literal carrying another method's verb is not an action: after a `{param}` it is a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`). And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/bulk-delete` → `bulkDelete`, `POST /users/bulk-update` → `bulkUpdate`, `POST /v1/pets:batchGet` → `batchGet`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. +An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /pets/list-all` → `listAll`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. A literal carrying another method's verb is not an action: after a `{param}` it is a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`). And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/bulk-delete` → `bulkDelete`, `POST /users/bulk-update` → `bulkUpdate`, `POST /v1/pets:batchGet` → `batchGet`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. Only a segment's one colon outside a parameter's braces starts a custom method. A colon inside braces is the parameter's wherever they sit, so `GET /v1/files/file-{file_path:path}` reads one segment and is `listFileFilePathPath`, while `POST /v1/files/file-{file_path:path}:download` is `createDownload`; and AIP-136 appends a single verb, so a literal with several colons is one literal: `GET /things/urn:pet:1` is `listUrnPet1`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2`). An action that shares its name with a path-named CRUD or singleton operation yields first: `update` stays with `PUT /pets/{id}`, and `POST /pets/update` takes back the collection it dropped, `updatePets` (`createPets` for `POST /pets/create` beside `POST /pets`, `listAllPets` for `GET /pets/list-all` beside `GET /pets/all`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`, `createUpdate` for `POST /store/pets/update` beside `PUT /store/pets`). Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`). A path-named operation of another verb that would share an action name takes the name it has without the action rule: the verb the segment carries keeps the action, so `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` beside it is `createBulkDelete`; with no method carrying the verb, POST keeps it and `PUT /pets/bulk-delete` beside it is `updateBulkDelete`; `PUT /users/{id}/update-password` keeps `updatePasswordUser` and a POST beside it is the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). diff --git a/src/ir/operation-hints.ts b/src/ir/operation-hints.ts index 63bce4e9..cfdeec96 100644 --- a/src/ir/operation-hints.ts +++ b/src/ir/operation-hints.ts @@ -30,19 +30,23 @@ export interface PathSegment { * from a slash-written literal: it never names a service, and it counts * against a path's plainness when inline model names collide (see the * parser's serviceSegmentIndex and pathDeviation). A colon inside a - * parameter's braces is part of its name and does not split it. Empty - * segments — a leading, trailing or doubled slash, or an empty side of a - * colon — are dropped. The operation's `path` itself is never rewritten: it + * parameter's braces, wherever they sit in the segment, is part of its name + * and does not split it: `file-{file_path:path}` is one segment. AIP-136 + * appends a single verb, so a custom method starts only at a segment's one + * colon outside braces: a segment with several, such as the literal + * `urn:pet:1`, is kept whole. + * Empty segments — a leading, trailing or doubled slash, or an empty side of + * a colon — are dropped. The operation's `path` itself is never rewritten: it * is the URL the SDK requests. */ export function readPath(path: string): PathSegment[] { const segments: PathSegment[] = []; for (const segment of path.split('/')) { - // A parameter's braces are kept whole; only what follows them can carry - // the colon of a custom method. - const braces = segment.startsWith('{') ? segment.indexOf('}') + 1 : 0; - const parts = segment.slice(braces).split(':'); - parts[0] = segment.slice(0, braces) + parts[0]; + // Blank out the colons inside braces, keeping every index in place. + const outside = segment.replace(/\{[^}]*\}/g, (param) => param.replaceAll(':', ' ')); + const colon = outside.indexOf(':'); + const single = colon !== -1 && colon === outside.lastIndexOf(':'); + const parts = single ? [segment.slice(0, colon), segment.slice(colon + 1)] : [segment]; parts.forEach((part, i) => { if (part) segments.push({ text: part, customMethod: i > 0 }); }); diff --git a/test/ir/operation-hints.test.ts b/test/ir/operation-hints.test.ts index 62ae964c..aadae4b1 100644 --- a/test/ir/operation-hints.test.ts +++ b/test/ir/operation-hints.test.ts @@ -77,6 +77,44 @@ describe('pathSegments (issue #269)', () => { }); }); +describe('readPath: a colon that is not a custom method (issue #378)', () => { + const service = svc('Files', []); + + it('keeps a colon inside any braces of a segment, not only braces that start it', () => { + expect(readPath('/v1/files/file-{file_path:path}')).toEqual([ + { text: 'v1', customMethod: false }, + { text: 'files', customMethod: false }, + { text: 'file-{file_path:path}', customMethod: false }, + ]); + expect(readPath('/v1/files/file-{file_path:path}:download')).toEqual([ + { text: 'v1', customMethod: false }, + { text: 'files', customMethod: false }, + { text: 'file-{file_path:path}', customMethod: false }, + { text: 'download', customMethod: true }, + ]); + expect(pathSegments('/files/{a}.{b:c}')).toEqual(['files', '{a}.{b:c}']); + expect(pathSegments('/files/{a:b}:{c:d}')).toEqual(['files', '{a:b}', '{c:d}']); + }); + + it('keeps a segment with several colons whole: AIP-136 appends a single custom method', () => { + expect(readPath('/things/urn:pet:1')).toEqual([ + { text: 'things', customMethod: false }, + { text: 'urn:pet:1', customMethod: false }, + ]); + expect(pathSegments('/things/{id}:a:b')).toEqual(['things', '{id}:a:b']); + expect(readPath('/things/urn:pet').map((s) => s.customMethod)).toEqual([false, false, true]); + }); + + it('names the issue repro as the segments read before #322, the custom method still split off', () => { + // A literal segment is snake-cased as written, so the segment kept whole + // names the GET as `file-{file_path}` names it, never the cut `list_path}`. + expect(deriveMethodName(op('get', '/v1/files/file-{file_path}'), service)).toBe('list_file_{file_path}'); + expect(deriveMethodName(op('get', '/v1/files/file-{file_path:path}'), service)).toBe('list_file_{file_path:path}'); + expect(deriveMethodName(op('post', '/v1/files/file-{file_path:path}:download'), service)).toBe('create_download'); + expect(deriveMethodName(op('get', '/things/urn:pet:1'), service)).toBe('list_urn:pet:1'); + }); +}); + // --------------------------------------------------------------------------- // deriveMethodName — algorithm unit tests // --------------------------------------------------------------------------- diff --git a/test/parser/fallback-operation-names.test.ts b/test/parser/fallback-operation-names.test.ts index cb215f68..e674401c 100644 --- a/test/parser/fallback-operation-names.test.ts +++ b/test/parser/fallback-operation-names.test.ts @@ -950,4 +950,28 @@ describe('custom-method paths (issue #269)', () => { { name: 'Notes', operations: ['list: GET /api/v1/notes'] }, ]); }); + + it('splits a segment only at the colon of a custom method (issue #378)', () => { + // A colon inside braces is the parameter's wherever they sit, so the GET + // reads `file-{file_path:path}` whole, as `/v1/files/file-{file_path}` is + // read (`listFileFilePath`), and the POST still reads `download` as its + // custom method; a literal with several colons is one literal, as AIP-136 + // appends a single verb. + expect( + services({ + '/v1/files/file-{file_path:path}': { get: jsonItem('file_path:path') }, + '/v1/files/file-{file_path:path}:download': { post: jsonItem('file_path:path') }, + '/things/urn:pet:1': { get: { ...json, tags: ['things'] } }, + }), + ).toEqual([ + { + name: 'Files', + operations: [ + 'listFileFilePathPath: GET /v1/files/file-{file_path:path}', + 'createDownload: POST /v1/files/file-{file_path:path}:download', + ], + }, + { name: 'Things', operations: ['listUrnPet1: GET /things/urn:pet:1'] }, + ]); + }); }); From efa5fd846dc0e2d76e28d0f07dbc655ee3cbf589 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 09:41:41 -0400 Subject: [PATCH 13/59] fix(parser)!: read `{petId}:adopt:now` as one literal segment, not as `{petId}` `readPath` keeps a segment with several colons outside braces whole, but every parser check was `startsWith('{')`, so one that a parameter opens (`{petId}:adopt:now`, `{a:b}:x:y`) was read as that bare parameter. Its tail dropped out of every name, and paths without such a segment were renamed: `GET /v1/pets/{petId}` beside `GET /v1/pets/{petId}:adopt:now` became `getByPetId`, and `POST /v1/pets/{id}/adopt/now` lost `V1PetsByIdAdoptNowPostRequest` to a hashed name, since `pathDeviation` scored both paths [0, 0, 0]. `isParameterSegment`, next to `readPath`, now says what a parameter segment is: it starts with `{` and has no colon outside braces. The parser reads every segment through it, so such a segment is one literal, as `urn:pet:1` is: `listPetIdAdoptNow`, `V1PetsIdAdoptNowPostRequest`, and irregular in `pathDeviation`. Its neighbours keep the names they have on the base (`Pets.get`, `V1PetsByIdAdoptNowPostRequest`). `{tag}.json` is still a parameter segment, so a path without a colon reads as before. `deriveMethodName` already read such a segment as a literal and is unchanged. Addresses the PR review's finding 1 and its thread PRRT_kwDOReoxH86w7lWG. The review's other option, keeping the parameter reading from before custom methods were read and widening the footer, was not taken: it leaves the neighbour renames. BREAKING CHANGE: a path segment that a parameter opens and that has several colons outside braces is one literal, not that parameter (and no longer, as on the base, a parameter followed by two custom methods): `GET /v1/pets/{petId}:adopt:now` is `listPetIdAdoptNow` (was `listNow`), the inline models of `POST /v1/pets/{id}:adopt:now` start `V1PetsIdAdoptNow` (was `V1PetsByIdAdoptNow`), and `/v1/{name}:a:b` lands in `NameAB` (was `V1`). Operations and models on paths without such a segment keep their names. --- docs/architecture/ir-types.md | 4 +- docs/architecture/pipeline.md | 2 +- src/ir/operation-hints.ts | 23 +++++++++-- src/parser/operations.ts | 40 ++++++++++---------- test/ir/operation-hints.test.ts | 8 ++++ test/parser/fallback-operation-names.test.ts | 22 +++++++++++ test/parser/inline-model-naming.test.ts | 29 ++++++++++++++ 7 files changed, 103 insertions(+), 25 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index c50c6638..93bf1cbe 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -338,7 +338,7 @@ An override that cannot be applied is reported as an `[oagen] Warning:` and igno ## Service & Operation -Service names are derived from OpenAPI tags when present — the first usable tag on an operation (`Operation.tags[0]`: the first entry that is a non-empty string, so `['', 'notes']` and `[42, 'notes']` both name `Notes`) is converted to PascalCase (e.g., `tags: ["multi-factor-auth"]` → service `MultiFactorAuth`). When no usable tag is present, the parser falls back to a path segment (e.g., `/organizations/{id}` → `Organizations`), skipping the API root the document's untagged operations sit under. Each untagged path contributes its leading literal segments through the last API marker — `api`, `rest`, or a version such as `v1`, `2.0` or `2010-04-01` — and the root is the longest marker-terminated prefix shared (compared verbatim) by every contribution that no other contribution extends, so a document whose paths all start with `/api/v1/` groups `/api/v1/notes` under `Notes` rather than collapsing the whole API into one `Api` service, and `/wiki/rest/api/content` lands in `Content`. A path that stops short of that root — `/api/openapi.json` or `/api/admin/users` beside `/api/v1/notes` — skips only its own `api` (`OpenapiJson`, `Admin`) and leaves the root at `api/v1` for the rest, so `/api/v1/notes` stays `Notes`; only a second version, `/api/v2/…` beside `/api/v1/…`, shortens the root to `api`, which keeps the versions apart as `V1` and `V2`. The service is named from the first literal segment after the root, so `/v1/notes` and `/v1/{teamId}/notes` share `Notes`, and a custom method written after a colon (`/v1/pets:batchGet`, `/v1/pets/{petId}:adopt`; only a segment's one colon outside a parameter's braces starts one, so `file-{file_path:path}` and `urn:pet:1` are one segment each) is read as its segment followed by the verb, so both land in `Pets`, while the verb itself never names a service, so `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}` (the slash-written `/v1/{name}/cancel` names `Cancel`); a path with nothing literal after the root (`/api` itself, `/api/{id}`, `/api/v1:reset`) keeps its first segment, `Api`. A path with no marker at all (`/health`, `/openapi.json`) sits outside the root, keeps its own first segment, and does not switch the root off for the rest of the document. A shared prefix that carries no marker is kept whole, since a module prefix every path shares (`/user_management/users`, `/user_management/invitations` → `UserManagement`) is the service, not noise; and `/v1/…` beside `/v2/…` has no shared root, so the versions stay separate services. Tagged operations and path items without an operation take no part. The path-prefix split that carves a tagged service into one per first segment (`/organizations` and `/organization_domains` both tagged `organizations` → `Organizations` and `OrganizationDomains`) is decided over the tag's own operations alone, from the grouping before any service is split: a tag whose operations all sit under one first segment keeps them together even when that segment is not the tag, so `/api/v1/notes` tagged `notes` stays in `Notes`, not `Api`, whatever untagged operations `Notes` picked up by path. An untagged operation keeps the service its path named — it is never moved by a split, and it neither causes nor prevents one — so `/notes` and `/api/v1/notes` meet in `Notes` instead of the latter moving to `Api`, and the collision rules below tell their names apart. A split-off part named like an existing service — another tag's, or a path-named one — joins it, and the operations it brings do not re-split that service. Fallback operation names skip the root and the service segment the same way, so `/api/v1/notes` is `Notes.list`, never `Notes.listV1Notes`; when two path-named operations still share a name in one service and their paths differ only by parameters, the parameters qualify them (`/v1/notes` → `Notes.list`, `/v1/{teamId}/notes` → `Notes.listByTeamId`; see the collision rules under Operation below). Without a root — a root-less document, or a path outside the root — a path-named service is the path's first segment (`Default` when that segment is a parameter) and the fallback name skips the service segment alone, up to those collision rules: an untagged `/{tenant}/users` is `Default.listUsers`, and `/{id}` is `Default.get`. A tagged operation's service is its tag whatever the root, subject only to the path-prefix split above: `/api/notes` tagged `notes` is `Notes.listNotes`. Emitters can further remap service names via an overlay (e.g., `MultiFactorAuth` → `Mfa`) to match existing SDK class names. +Service names are derived from OpenAPI tags when present — the first usable tag on an operation (`Operation.tags[0]`: the first entry that is a non-empty string, so `['', 'notes']` and `[42, 'notes']` both name `Notes`) is converted to PascalCase (e.g., `tags: ["multi-factor-auth"]` → service `MultiFactorAuth`). When no usable tag is present, the parser falls back to a path segment (e.g., `/organizations/{id}` → `Organizations`), skipping the API root the document's untagged operations sit under. Each untagged path contributes its leading literal segments through the last API marker — `api`, `rest`, or a version such as `v1`, `2.0` or `2010-04-01` — and the root is the longest marker-terminated prefix shared (compared verbatim) by every contribution that no other contribution extends, so a document whose paths all start with `/api/v1/` groups `/api/v1/notes` under `Notes` rather than collapsing the whole API into one `Api` service, and `/wiki/rest/api/content` lands in `Content`. A path that stops short of that root — `/api/openapi.json` or `/api/admin/users` beside `/api/v1/notes` — skips only its own `api` (`OpenapiJson`, `Admin`) and leaves the root at `api/v1` for the rest, so `/api/v1/notes` stays `Notes`; only a second version, `/api/v2/…` beside `/api/v1/…`, shortens the root to `api`, which keeps the versions apart as `V1` and `V2`. The service is named from the first literal segment after the root, so `/v1/notes` and `/v1/{teamId}/notes` share `Notes`, and a custom method written after a colon (`/v1/pets:batchGet`, `/v1/pets/{petId}:adopt`; only a segment's one colon outside a parameter's braces starts one, so `file-{file_path:path}` and `urn:pet:1` are one segment each, and `{name}:a:b` is one literal segment rather than a parameter, so `/v1/{name}:a:b` names `NameAB`) is read as its segment followed by the verb, so both land in `Pets`, while the verb itself never names a service, so `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}` (the slash-written `/v1/{name}/cancel` names `Cancel`); a path with nothing literal after the root (`/api` itself, `/api/{id}`, `/api/v1:reset`) keeps its first segment, `Api`. A path with no marker at all (`/health`, `/openapi.json`) sits outside the root, keeps its own first segment, and does not switch the root off for the rest of the document. A shared prefix that carries no marker is kept whole, since a module prefix every path shares (`/user_management/users`, `/user_management/invitations` → `UserManagement`) is the service, not noise; and `/v1/…` beside `/v2/…` has no shared root, so the versions stay separate services. Tagged operations and path items without an operation take no part. The path-prefix split that carves a tagged service into one per first segment (`/organizations` and `/organization_domains` both tagged `organizations` → `Organizations` and `OrganizationDomains`) is decided over the tag's own operations alone, from the grouping before any service is split: a tag whose operations all sit under one first segment keeps them together even when that segment is not the tag, so `/api/v1/notes` tagged `notes` stays in `Notes`, not `Api`, whatever untagged operations `Notes` picked up by path. An untagged operation keeps the service its path named — it is never moved by a split, and it neither causes nor prevents one — so `/notes` and `/api/v1/notes` meet in `Notes` instead of the latter moving to `Api`, and the collision rules below tell their names apart. A split-off part named like an existing service — another tag's, or a path-named one — joins it, and the operations it brings do not re-split that service. Fallback operation names skip the root and the service segment the same way, so `/api/v1/notes` is `Notes.list`, never `Notes.listV1Notes`; when two path-named operations still share a name in one service and their paths differ only by parameters, the parameters qualify them (`/v1/notes` → `Notes.list`, `/v1/{teamId}/notes` → `Notes.listByTeamId`; see the collision rules under Operation below). Without a root — a root-less document, or a path outside the root — a path-named service is the path's first segment (`Default` when that segment is a parameter) and the fallback name skips the service segment alone, up to those collision rules: an untagged `/{tenant}/users` is `Default.listUsers`, and `/{id}` is `Default.get`. A tagged operation's service is its tag whatever the root, subject only to the path-prefix split above: `/api/notes` tagged `notes` is `Notes.listNotes`. Emitters can further remap service names via an overlay (e.g., `MultiFactorAuth` → `Mfa`) to match existing SDK class names. `Service.description` comes from the document's top-level `tags` list, matched by name: a tag entry applies to the service whose PascalCase name its `name` converts to, so `user-management`, `User Management`, and `user_management` all map to `UserManagement`. A service whose own name is a declared tag is described by that tag alone — its description when it has one, nothing otherwise — so another tag's wording is never borrowed for it (operations tagged `api_keys` that the path-prefix split merges into `UserManagement` do not label it "API keys"). A service whose own name is not declared, which is what the split produces when it carves `OrganizationDomains` out of operations tagged `organizations`, inherits the description of the first described tag its operations carry. The key is omitted when neither rule yields a description, including for path-named services whose operations have no tag. Malformed `tags` values (a non-array, `null` entries, non-string names) are skipped rather than failing the parse. @@ -748,7 +748,7 @@ Two shapes are not array components. A `type: 'array'` with no `items` still bec Models come from `components/schemas` (named as written, after `cleanSchemaName` and any `schemaNameTransform`; an enum or array component yields none — see Enum and Array components) and from inline object schemas in operations, which the parser materializes as models so emitters have something to reference. Two component keys that resolve to one name (`Note` and `note`) keep the definition with more fields, the first on a tie; when the two differ in fields, types or limits (`modelFingerprint`), the parser prints `[oagen] Warning: Component schemas "Note" and "note" both resolve to "Note" with different fields or field types` before dropping the other, and folds identical definitions silently. An inline request body or response is named from its operation: - **With an `operationId`:** `PascalCase(operationId)` + `Request` / `Response`, passed through `cleanSchemaName`. `createNote` → `CreateNoteRequest` / `CreateNoteResponse`. -- **Without an `operationId`:** from the full path and method. Each non-parameter segment is PascalCased in order, each `{param}` segment becomes `By` + `PascalCase(param)`, a custom method written after a colon is read as its segment followed by the verb (`POST /pets/{id}:adopt` → `PetsByIdAdoptPostRequest`, the prefix of `/pets/{id}/adopt`), then the method and `Request` / `Response` are appended, and the whole name is normalized with `toPascalCase` so an operation's body and response share a prefix (`POST /a/b` → `AbPostRequest` / `AbPostResponse`). `POST /notes` → `NotesPostRequest` / `NotesPostResponse`; `POST /notes/{id}/pin` → `NotesByIdPinPostResponse`; `GET /users/{user_id}/sessions` → `UsersByUserIdSessionsGetResponse`. This tells the common path shapes apart (list vs. item, sibling and nested sub-resources) but is not unique by construction: `/foo-bar` and `/foo/bar`, `/notes/by-id` and `/notes/{id}`, `/notes` and `/notes/`, or `/pets/{id}:adopt` and `/pets/{id}/adopt` flatten to the same words. Names already taken are reserved first: every component schema name and, for every operation with an `operationId`, the stem all of its models start with (`PascalCase(operationId)` after `cleanSchemaName`). A path-based prefix is treated as colliding when any reserved name starts with it, because one prefix is the stem of several emitted names (`…Request`, `…Response`, a list's `…Item`, nested inline models such as `…ResponseOwner`, envelope companions such as `…ListMetadata`). Names derived from a path-based prefix use it verbatim — body `oneOf` variants included (`NotesPostRequest`, `NotesPostRequest2`, `EmailNotesPostRequest`) — and are never re-cleaned, so no later step can move them out from under the reservation; as a second line of defence the check also runs on the cleaned spellings of the prefix (`cleanSchemaName`, the project's `schemaNameTransform`, `ListItem`-marker stripping), so an unnamed `GET /notes` also yields to a component `NoteGetItem`. Names that embed the prefix behind a const value are covered as well: a body `oneOf` variant pinning `kind: email` is `EmailTagsPostRequest` and a nested `oneOf`/`anyOf` variant is ``, so every string `const` (or single-value `enum`) found in the operation's inline body and 2xx response variants, top level and nested, is prepended to every spelling before the check, and a hashed prefix lands inside those names too (`EmailTags151Dd5PostRequest`). Names that do not embed the prefix at all — a response `oneOf` variant named from its bare const (`Email`) or a `title`-mapped variant — are outside what a disambiguator can separate and are left as they are. This is deliberately a superset: a component `AuditEventsGetter`, or an operationId `auditEventsGetSummary`, also pushes an unnamed `GET /audit/events` onto a hashed name although no emitted name would coincide; determinism and never sharing a name wrongly matter more than the rare extra hash. The reservation is unconditional, so an inline body is never merged into a same-named component even when their shapes agree. When two distinct paths with the same method would collide, the path spelled most plainly keeps the plain name (no trailing slash, `{id}` rather than a literal `by-id`; precisely, the lowest `[trailing slash, custom methods written after a colon, literal segments with a character outside a-z0-9]`, so `/pets/{id}/adopt` keeps the plain name over `/pets/{id}:adopt` and `/pets/{id}/batchAdopt` over `/pets/{id}:batchAdopt`, while a parameter segment is never irregular and `/tags/{tag}.json` beside `/tags/{tag}/json` ties), and every other path in the group gets a six-character disambiguator derived from its raw path (the start of its SHA-1, inserted before the method): `POST /foo/bar` next to `POST /foo-bar` → `FooBarPostResponse` and `FooBar546F12PostResponse`; `GET /notes/{id}` next to `GET /notes/by-id` → `NotesByIdGetResponse` and `NotesByIdBa41B5GetResponse`. When two paths tie for plainest (`/foo-bar` and `/foo_bar`), or the path-based name matches a reserved one (an unnamed `POST /audit/events` next to `operationId: auditEventsPost` or next to `components/schemas/AuditEventsPostRequest`), nobody keeps the plain name and every path in the group is disambiguated. The result depends only on the document's paths, `operationId`s and component names and never on their order; `operationId`-derived and component names never change, and paths that do not collide keep the plain name. +- **Without an `operationId`:** from the full path and method. Each non-parameter segment is PascalCased in order, each `{param}` segment becomes `By` + `PascalCase(param)`, a custom method written after a colon is read as its segment followed by the verb (`POST /pets/{id}:adopt` → `PetsByIdAdoptPostRequest`, the prefix of `/pets/{id}/adopt`), a segment with several colons outside braces is one literal even when a parameter opens it (`POST /v1/pets/{id}:adopt:now` → `V1PetsIdAdoptNowPostRequest`), then the method and `Request` / `Response` are appended, and the whole name is normalized with `toPascalCase` so an operation's body and response share a prefix (`POST /a/b` → `AbPostRequest` / `AbPostResponse`). `POST /notes` → `NotesPostRequest` / `NotesPostResponse`; `POST /notes/{id}/pin` → `NotesByIdPinPostResponse`; `GET /users/{user_id}/sessions` → `UsersByUserIdSessionsGetResponse`. This tells the common path shapes apart (list vs. item, sibling and nested sub-resources) but is not unique by construction: `/foo-bar` and `/foo/bar`, `/notes/by-id` and `/notes/{id}`, `/notes` and `/notes/`, or `/pets/{id}:adopt` and `/pets/{id}/adopt` flatten to the same words. Names already taken are reserved first: every component schema name and, for every operation with an `operationId`, the stem all of its models start with (`PascalCase(operationId)` after `cleanSchemaName`). A path-based prefix is treated as colliding when any reserved name starts with it, because one prefix is the stem of several emitted names (`…Request`, `…Response`, a list's `…Item`, nested inline models such as `…ResponseOwner`, envelope companions such as `…ListMetadata`). Names derived from a path-based prefix use it verbatim — body `oneOf` variants included (`NotesPostRequest`, `NotesPostRequest2`, `EmailNotesPostRequest`) — and are never re-cleaned, so no later step can move them out from under the reservation; as a second line of defence the check also runs on the cleaned spellings of the prefix (`cleanSchemaName`, the project's `schemaNameTransform`, `ListItem`-marker stripping), so an unnamed `GET /notes` also yields to a component `NoteGetItem`. Names that embed the prefix behind a const value are covered as well: a body `oneOf` variant pinning `kind: email` is `EmailTagsPostRequest` and a nested `oneOf`/`anyOf` variant is ``, so every string `const` (or single-value `enum`) found in the operation's inline body and 2xx response variants, top level and nested, is prepended to every spelling before the check, and a hashed prefix lands inside those names too (`EmailTags151Dd5PostRequest`). Names that do not embed the prefix at all — a response `oneOf` variant named from its bare const (`Email`) or a `title`-mapped variant — are outside what a disambiguator can separate and are left as they are. This is deliberately a superset: a component `AuditEventsGetter`, or an operationId `auditEventsGetSummary`, also pushes an unnamed `GET /audit/events` onto a hashed name although no emitted name would coincide; determinism and never sharing a name wrongly matter more than the rare extra hash. The reservation is unconditional, so an inline body is never merged into a same-named component even when their shapes agree. When two distinct paths with the same method would collide, the path spelled most plainly keeps the plain name (no trailing slash, `{id}` rather than a literal `by-id`; precisely, the lowest `[trailing slash, custom methods written after a colon, literal segments with a character outside a-z0-9]`, so `/pets/{id}/adopt` keeps the plain name over `/pets/{id}:adopt` and `/pets/{id}/batchAdopt` over `/pets/{id}:batchAdopt`, while a parameter segment is never irregular and `/tags/{tag}.json` beside `/tags/{tag}/json` ties), and every other path in the group gets a six-character disambiguator derived from its raw path (the start of its SHA-1, inserted before the method): `POST /foo/bar` next to `POST /foo-bar` → `FooBarPostResponse` and `FooBar546F12PostResponse`; `GET /notes/{id}` next to `GET /notes/by-id` → `NotesByIdGetResponse` and `NotesByIdBa41B5GetResponse`. When two paths tie for plainest (`/foo-bar` and `/foo_bar`), or the path-based name matches a reserved one (an unnamed `POST /audit/events` next to `operationId: auditEventsPost` or next to `components/schemas/AuditEventsPostRequest`), nobody keeps the plain name and every path in the group is disambiguated. The result depends only on the document's paths, `operationId`s and component names and never on their order; `operationId`-derived and component names never change, and paths that do not collide keep the plain name. - **Shared through `components/responses`:** a response written as `{ $ref: '#/components/responses/PetFound' }` is resolved before it is read (chains are followed; a circular chain fails the parse; a `$ref` that points outside `components/responses` — into another section such as `#/components/schemas/Pet`, or a bundler-written pointer such as `#/paths/~1pets/get/responses/200` — is warned about (`Unresolved response $ref "#/components/schemas/Pet" in 404 response of getPet: no matching entry under components/responses — reading the response as written, without a body or headers`) and read as written, with no body or headers, while a `$ref` into `components/responses` that names no entry never reaches the parser: the bundler rejects it, and `parseSpec` throws `Failed to parse spec: Can't resolve $ref`; a sibling `description` beside the `$ref` wins in an OAS 3.1 document, outermost first, and is ignored in an OAS 3.0 one). Its inline body is named after the component, not the operation — `PascalCase(componentName)` + `Response`, passed through `cleanSchemaName`, the suffix not doubled when the component is already named `…Response` (`PetFound` → `PetFoundResponse`, `ErrorResponse` → `ErrorResponse`) — so every operation that returns it shares one model instead of minting a `Response` copy each — whether it returns it at an explicit status code, as `default` or in a `1XX`–`5XX` range: a `default` and a `4XX` both written as `$ref: '#/components/responses/Problem'` are one `ProblemResponse`, shared with the `404` of another operation that points at it; a chain is named after the ref the operation wrote. Everything else the component declares that mints a model is named from the stem of that shared body name — `Response` less its `Response` (`Card`), or `CardResponse2` when the body stepped to it — too: an inline object under another media type is `Response` (`Card` with an `application/yaml` body → `CardApplicationYamlResponse`) and an object-shaped header is `
` (`Card` with `X-Meta` → `CardXMeta`), nested objects under either qualified by it. These names do not depend on the operation, its service, or the status code or fallback key (`404`, `302`, `default`, `4XX`) the operation mounts the component at, so every operation that returns the component shares one model of each, as it shares the body; a name a component schema or another site already owns with a shape it does not share steps by number (`CardXMeta2`, `CardApplicationYamlResponse2`), as the component's body steps to `CardResponse2`, never by the operation or the status code — those qualifiers belong to an inline response's sites — so a stepped name depends on the component alone too, and removing one of the operations that return the component leaves the others' names unchanged; an inline enum such a site carries — a header's, a secondary body's, the body's — steps by number the same way when the collectors promote it (`CardXMode2`), never by the status code or fallback label (see Response Headers and Response Content Types above). The component's stem is reserved like an `operationId`'s, so an unnamed `GET /owner` beside a component response `OwnerGet` takes a hashed name. The names a body mints are reserved under every reading an operation can give it — a list envelope unwrapped for an iterator (`NotePage: { data: { title }[], has_more }` → `NotePageItem`) and kept as the wire shape (`NotePageResponse`, `NotePageResponseData`) — since which reading an operation takes depends on its own query parameters and override, and two operations returning one component can differ (`GET /notes` keeps it, `GET /notes/recent?after` unwraps it): beside a schema `NotePageResponseData` or `NotePageItem` the stem steps to `NotePageResponse2` for every operation, so a kept body's item model is never renamed late and both readings move together. The component's headers resolve through `components/headers` as inline ones do. A response object with a string `const` discriminant (`object: "note"`) is named from that value instead (`Note`), except a list envelope kept as a plain model: its const (`object: "list"`) names the kind every envelope shares, not what it lists, so it keeps the operation's name (`ListPetsResponse`, never `List`); nested inline objects are qualified with their parent model's name. The items of an array primary success body (`Operation.response`: `type: 'array'` or bare `items`, nullable in any spelling or not; a secondary media type's, fallback or error body keeps its own name, `ListPetsApplicationVndPetJsonResponse`, `ListPetsError409`) and of a list envelope's data array are typed as a field named `Item` — the `Response` suffix replaced with `Item` — under no parent, whatever their shape, so every model they reference is created under the name the reference uses: a plain object is `ListPetsItem`; a nullable object is `nullable`; the inline object variants of a `oneOf`/`anyOf` take the union's names from it (`ListPetsItem`, `ListPetsItemWithSide`, `CircleListPetsItem`); an inline enum is `ListPetsItem`; the objects of a nested array or map, and anything under any of these, are qualified under it (`ListPetsItemOwner`); a `$ref` stays the reference and mints nothing. The names are settled against the spec-wide registry of model shapes as the operation is read (see Response Headers): when a name the items mint — `Item` itself, a union variant, a nested object — already belongs to another shape, a component model or enum (`ListShapesItem { id }`), a component's field model or an earlier operation's model, the items are retried as `ResponseItem` (`ListShapesResponseItem`, `ListShapesResponseItemWithSide`), then `Item2`, `Item3`, …, the first candidate that clashes with nothing; items identical to a name's owner share it and mint nothing, so an array of `{ name }` beside a component `ListPetsItem { name }` references the component. A request body or parameter that is an array is typed as the body or parameter itself, so its inline object items are `CreatePetsRequest` and `PetsFilter`, not `…Item`. diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index 76254d06..f7248aa8 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -63,7 +63,7 @@ The collection vs. instance distinction is determined by whether the path ends w A literal after a `{param}` that does not look plural is a singleton of that item: one thing about one pet, read with the item verb (`get`, not `list`) and named after the method's verb, the singular parent and the segment — `GET /store/pets/{id}/status` → `getPetStatus`, `DELETE /users/{id}/avatar` → `deleteUserAvatar`, `PUT` → `updateUserAvatar`, `HEAD` → `checkUserAvatar`, and `POST /orders/{id}/status` → `createOrderStatus`. The parent is kept even when it is the first segment, as it is for an action: `GET /pets/{id}/status` in `Pets` is `getPetStatus`. A plural literal after a `{param}` (`/users/{id}/photos`, `/orgs/{id}/api_keys`) is a nested collection: `listPhotos`, `createApiKeys`. An irregular plural that `singularizePathSegment` does not recognise (`children`, `people`, `data`) does not look plural, so `GET /pets/{id}/children` is the singleton `getPetChildren`; give it an `operationId`. -An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /pets/list-all` → `listAll`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. A literal carrying another method's verb is not an action: after a `{param}` it is a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`). And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/bulk-delete` → `bulkDelete`, `POST /users/bulk-update` → `bulkUpdate`, `POST /v1/pets:batchGet` → `batchGet`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. Only a segment's one colon outside a parameter's braces starts a custom method. A colon inside braces is the parameter's wherever they sit, so `GET /v1/files/file-{file_path:path}` reads one segment and is `listFileFilePathPath`, while `POST /v1/files/file-{file_path:path}:download` is `createDownload`; and AIP-136 appends a single verb, so a literal with several colons is one literal: `GET /things/urn:pet:1` is `listUrnPet1`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. +An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /pets/list-all` → `listAll`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. A literal carrying another method's verb is not an action: after a `{param}` it is a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`). And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/bulk-delete` → `bulkDelete`, `POST /users/bulk-update` → `bulkUpdate`, `POST /v1/pets:batchGet` → `batchGet`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. Only a segment's one colon outside a parameter's braces starts a custom method. A colon inside braces is the parameter's wherever they sit, so `GET /v1/files/file-{file_path:path}` reads one segment and is `listFileFilePathPath`, while `POST /v1/files/file-{file_path:path}:download` is `createDownload`; and AIP-136 appends a single verb, so a segment with several colons outside braces is one literal, even when a parameter opens it: `GET /things/urn:pet:1` is `listUrnPet1`, and `GET /v1/pets/{petId}:adopt:now` is `listPetIdAdoptNow`, so `GET /v1/pets/{petId}` beside it stays `get`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2`). An action that shares its name with a path-named CRUD or singleton operation yields first: `update` stays with `PUT /pets/{id}`, and `POST /pets/update` takes back the collection it dropped, `updatePets` (`createPets` for `POST /pets/create` beside `POST /pets`, `listAllPets` for `GET /pets/list-all` beside `GET /pets/all`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`, `createUpdate` for `POST /store/pets/update` beside `PUT /store/pets`). Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`). A path-named operation of another verb that would share an action name takes the name it has without the action rule: the verb the segment carries keeps the action, so `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` beside it is `createBulkDelete`; with no method carrying the verb, POST keeps it and `PUT /pets/bulk-delete` beside it is `updateBulkDelete`; `PUT /users/{id}/update-password` keeps `updatePasswordUser` and a POST beside it is the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). diff --git a/src/ir/operation-hints.ts b/src/ir/operation-hints.ts index cfdeec96..f2c920b3 100644 --- a/src/ir/operation-hints.ts +++ b/src/ir/operation-hints.ts @@ -34,7 +34,8 @@ export interface PathSegment { * and does not split it: `file-{file_path:path}` is one segment. AIP-136 * appends a single verb, so a custom method starts only at a segment's one * colon outside braces: a segment with several, such as the literal - * `urn:pet:1`, is kept whole. + * `urn:pet:1`, is kept whole, and is a literal even when a parameter opens + * it (see isParameterSegment). * Empty segments — a leading, trailing or doubled slash, or an empty side of * a colon — are dropped. The operation's `path` itself is never rewritten: it * is the URL the SDK requests. @@ -42,8 +43,7 @@ export interface PathSegment { export function readPath(path: string): PathSegment[] { const segments: PathSegment[] = []; for (const segment of path.split('/')) { - // Blank out the colons inside braces, keeping every index in place. - const outside = segment.replace(/\{[^}]*\}/g, (param) => param.replaceAll(':', ' ')); + const outside = blankBracedColons(segment); const colon = outside.indexOf(':'); const single = colon !== -1 && colon === outside.lastIndexOf(':'); const parts = single ? [segment.slice(0, colon), segment.slice(colon + 1)] : [segment]; @@ -59,6 +59,23 @@ export function pathSegments(path: string): string[] { return readPath(path).map((segment) => segment.text); } +/** + * Whether a segment readPath reads is a path parameter: it starts with a + * parameter's braces (`{petId}`, `{pet:id}`, `{tag}.json`) and has no colon + * outside braces. A segment readPath keeps whole for its several colons is + * one literal even when a parameter opens it, as `urn:pet:1` is, so the + * parser reads `{petId}:adopt:now` as a literal and its words reach the + * names read from it, instead of a parameter whose tail drops out. + */ +export function isParameterSegment(segment: string): boolean { + return segment.startsWith('{') && !blankBracedColons(segment).includes(':'); +} + +/** A segment with the colons inside its braces blanked out, every index kept in place. */ +function blankBracedColons(segment: string): string { + return segment.replace(/\{[^}]*\}/g, (param) => param.replaceAll(':', ' ')); +} + // --------------------------------------------------------------------------- // Hint types — consumer-provided overrides for operation resolution // --------------------------------------------------------------------------- diff --git a/src/parser/operations.ts b/src/parser/operations.ts index cd4e41e8..9b7419bc 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -21,7 +21,7 @@ import type { ServerEntry, StatusRange, } from '../ir/types.js'; -import { pathSegments, readPath, type OperationHint } from '../ir/operation-hints.js'; +import { isParameterSegment, pathSegments, readPath, type OperationHint } from '../ir/operation-hints.js'; import { isDefaultServerList, serverListsEqual } from '../ir/server-url.js'; import { foldHeaderName, foldParameterName, parameterKey } from '../ir/parameter-key.js'; import { createHash } from 'node:crypto'; @@ -854,7 +854,7 @@ function isApiMarkerSegment(segment: string): boolean { /** Leading literal segments of a path through its last API marker; `0` without one. */ function pathRootLength(segments: readonly string[]): number { let length = 0; - for (let i = 0; i < segments.length && !segments[i].startsWith('{'); i++) { + for (let i = 0; i < segments.length && !isParameterSegment(segments[i]); i++) { if (isApiMarkerSegment(segments[i])) length = i + 1; } return length; @@ -969,7 +969,7 @@ function apiRootLengthOf(path: string, apiRoot: readonly string[]): number { function serviceSegmentIndex(path: string, apiRootLength: number): number { if (apiRootLength === 0) return 0; const index = readPath(path).findIndex( - (segment, i) => i >= apiRootLength && !segment.text.startsWith('{') && !segment.customMethod, + (segment, i) => i >= apiRootLength && !isParameterSegment(segment.text) && !segment.customMethod, ); return index === -1 ? 0 : index; } @@ -995,7 +995,7 @@ function inferServiceName(path: string, tag: string | undefined, apiRootLength: } const first = pathSegments(path)[serviceSegmentIndex(path, apiRootLength)] ?? 'Default'; // Skip path params - if (first.startsWith('{')) return 'Default'; + if (isParameterSegment(first)) return 'Default'; return toPascalCase(first); } @@ -1008,7 +1008,7 @@ function inferServiceName(path: string, tag: string | undefined, apiRootLength: */ function splitSegmentService(op: Operation, serviceName: string): string { const first = pathSegments(op.path)[0]; - return first === undefined || first.startsWith('{') ? serviceName : toPascalCase(first); + return first === undefined || isParameterSegment(first) ? serviceName : toPascalCase(first); } /** @@ -1258,7 +1258,7 @@ const LEADING_ONLY_VERBS: ReadonlySet = new Set(['list', 'check']); function inferVerb(method: HttpMethod, path: string): string { const segments = pathSegments(path); const last = segments[segments.length - 1]; - const item = last !== undefined && (last.startsWith('{') || isSingletonSegment(segments)); + const item = last !== undefined && (isParameterSegment(last) || isSingletonSegment(segments)); return item ? METHOD_VERBS[method].item : METHOD_VERBS[method].collection; } @@ -1281,7 +1281,7 @@ function methodVerbs(method: HttpMethod): string[] { function segmentCarriesVerb(method: HttpMethod, path: string): boolean { const segments = pathSegments(path); const last = segments[segments.length - 1]; - if (last === undefined || last.startsWith('{')) return false; + if (last === undefined || isParameterSegment(last)) return false; const words = splitWords(last).map((w) => w.toLowerCase()); return methodVerbs(method).some((verb) => (LEADING_ONLY_VERBS.has(verb) ? words[0] === verb : words.includes(verb))); } @@ -1301,7 +1301,7 @@ function looksPlural(segment: string): boolean { function isSingletonSegment(segments: readonly string[]): boolean { if (segments.length < 2) return false; const last = segments[segments.length - 1]; - return !last.startsWith('{') && segments[segments.length - 2].startsWith('{') && !looksPlural(last); + return !isParameterSegment(last) && isParameterSegment(segments[segments.length - 2]) && !looksPlural(last); } /** @@ -1319,7 +1319,7 @@ function isSingletonSegment(segments: readonly string[]): boolean { */ function pathSegmentNoun(segments: readonly string[], index: number): string { const segment = segments[index]; - return segments[index + 1]?.startsWith('{') ? singularizePathSegment(segment) : segment; + return isParameterSegment(segments[index + 1] ?? '') ? singularizePathSegment(segment) : segment; } /** @@ -1351,7 +1351,7 @@ function inferResourceFromPath(path: string, apiRootLength = 0, singularItem = t // root word is never the resource: /api/v1/{id} is Api.delete, not deleteV1. let resourceIndex = -1; for (let i = segments.length - 1; i >= apiRootLength; i--) { - if (!segments[i].startsWith('{')) { + if (!isParameterSegment(segments[i])) { resourceIndex = i; break; } @@ -1383,7 +1383,7 @@ function inferResourceFromPath(path: string, apiRootLength = 0, singularItem = t */ function itemParentIndex(segments: readonly string[], apiRootLength: number): number { for (let i = segments.length - 3; i >= apiRootLength; i--) { - if (!segments[i].startsWith('{') && !isApiMarkerSegment(segments[i])) return i; + if (!isParameterSegment(segments[i]) && !isApiMarkerSegment(segments[i])) return i; } return -1; } @@ -1468,8 +1468,8 @@ function inferAction( const segments = pathSegments(path); if (segments.length === 0) return null; const last = segments[segments.length - 1]; - if (last.startsWith('{')) return null; - const afterParam = segments.length >= 2 && segments[segments.length - 2].startsWith('{'); + if (isParameterSegment(last)) return null; + const afterParam = segments.length >= 2 && isParameterSegment(segments[segments.length - 2]); const words = splitWords(last).map((w) => w.toLowerCase()); const postAction = method === 'post' && afterParam && !last.toLowerCase().endsWith('s'); const ownVerb = segmentCarriesVerb(method, path); @@ -2257,7 +2257,7 @@ function qualifierPath(op: Operation, apiRootLength: number, actions = true): st */ function pathParamWords(path: string): string[] { return pathSegments(path) - .filter((segment) => segment.startsWith('{')) + .filter((segment) => isParameterSegment(segment)) .map((segment) => toPascalCase(segment.replace(/[{}]/g, ''))); } @@ -2293,7 +2293,7 @@ function inferByParamsSuffix(path: string, shared: ReadonlySet): string function inferServiceNoun(path: string, apiRootLength: number): string | null { const segments = pathSegments(path); let last = segments.length - 1; - while (last >= apiRootLength && segments[last].startsWith('{')) last--; + while (last >= apiRootLength && isParameterSegment(segments[last])) last--; if (last < apiRootLength || last !== serviceSegmentIndex(path, apiRootLength)) return null; return toPascalCase(pathSegmentNoun(segments, last)); } @@ -2329,7 +2329,7 @@ function inferDeeperContext(path: string, apiRootLength = 0, singularItem = true const segments = pathSegments(path); const nouns: string[] = []; for (let i = apiRootLength; i < segments.length; i++) { - if (!segments[i].startsWith('{')) nouns.push(singularItem ? pathSegmentNoun(segments, i) : segments[i]); + if (!isParameterSegment(segments[i])) nouns.push(singularItem ? pathSegmentNoun(segments, i) : segments[i]); } // Skip first (service) and last (already used) — use the middle ones if (nouns.length >= 3) { @@ -4792,7 +4792,7 @@ function extractResponseHeaders( export function derivePathModelBaseName(path: string, method: HttpMethod, disambiguator = ''): string { const segments = pathSegments(path); const parts = segments.map((segment) => - segment.startsWith('{') ? 'By' + toPascalCase(segment.replace(/[{}]/g, '')) : toPascalCase(segment), + isParameterSegment(segment) ? 'By' + toPascalCase(segment.replace(/[{}]/g, '')) : toPascalCase(segment), ); const resource = parts.length > 0 ? parts.join('') : 'Unknown'; // `_` separators guarantee a word boundary on each side of the disambiguator @@ -4972,14 +4972,16 @@ type PathDeviation = [trailingSlash: number, customMethods: number, irregularSeg * `/pets/{id}/batchAdopt` at [0, 0, 1]. A parameter segment is never * irregular, whatever follows its braces, so a path without a colon scores * as it did before custom methods were read: `/tags/{tag}.json` and - * `/tags/{tag}/json` still tie. + * `/tags/{tag}/json` still tie. A segment kept whole for its several colons + * is a literal even when a parameter opens it (isParameterSegment), so + * `/pets/{id}:adopt:now` scores [0, 0, 1]. */ function pathDeviation(rawPath: string): PathDeviation { const trailingSlash = rawPath.length > 1 && rawPath.endsWith('/') ? 1 : 0; const segments = readPath(rawPath); const customMethods = segments.filter((segment) => segment.customMethod).length; const irregularSegments = segments.filter( - (segment) => !segment.text.startsWith('{') && /[^a-z0-9]/.test(segment.text), + (segment) => !isParameterSegment(segment.text) && /[^a-z0-9]/.test(segment.text), ).length; return [trailingSlash, customMethods, irregularSegments]; } diff --git a/test/ir/operation-hints.test.ts b/test/ir/operation-hints.test.ts index aadae4b1..5ac155bf 100644 --- a/test/ir/operation-hints.test.ts +++ b/test/ir/operation-hints.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { deriveMethodName, findResolvedMethodCollisions, + isParameterSegment, pathSegments, readPath, resolveMountTarget, @@ -105,6 +106,13 @@ describe('readPath: a colon that is not a custom method (issue #378)', () => { expect(readPath('/things/urn:pet').map((s) => s.customMethod)).toEqual([false, false, true]); }); + it('reads a segment kept whole for its several colons as a literal, even when a parameter opens it', () => { + const params = ['{petId}', '{pet:id}', '{tag}.json', '{a}.{b:c}', '{x']; + const literals = ['{petId}:adopt:now', '{a:b}:x:y', 'urn:pet:1', 'file-{file_path:path}', 'pets']; + expect(params.filter((segment) => !isParameterSegment(segment))).toEqual([]); + expect(literals.filter((segment) => isParameterSegment(segment))).toEqual([]); + }); + it('names the issue repro as the segments read before #322, the custom method still split off', () => { // A literal segment is snake-cased as written, so the segment kept whole // names the GET as `file-{file_path}` names it, never the cut `list_path}`. diff --git a/test/parser/fallback-operation-names.test.ts b/test/parser/fallback-operation-names.test.ts index e674401c..cc73be19 100644 --- a/test/parser/fallback-operation-names.test.ts +++ b/test/parser/fallback-operation-names.test.ts @@ -974,4 +974,26 @@ describe('custom-method paths (issue #269)', () => { { name: 'Things', operations: ['listUrnPet1: GET /things/urn:pet:1'] }, ]); }); + + it('reads a segment that a parameter opens and several colons follow as one literal, so its neighbours keep their names (issue #378)', () => { + // `{petId}:adopt:now` carries no custom method, and read as the + // parameter `{petId}` it would drop `:adopt:now` and collide with + // `GET /v1/pets/{petId}` as `get`, renaming both. + expect( + services({ + '/v1/pets/{petId}': { get: jsonItem('petId') }, + '/v1/pets/{petId}:adopt:now': { get: jsonItem('petId') }, + }), + ).toEqual([ + { + name: 'Pets', + operations: ['get: GET /v1/pets/{petId}', 'listPetIdAdoptNow: GET /v1/pets/{petId}:adopt:now'], + }, + ]); + // A literal names a service, so it leaves `V1` to `/v1/{name}`. + expect(services({ '/v1/{name}': { get: jsonItem('name') }, '/v1/{name}:a:b': { get: jsonItem('name') } })).toEqual([ + { name: 'V1', operations: ['get: GET /v1/{name}'] }, + { name: 'NameAB', operations: ['list: GET /v1/{name}:a:b'] }, + ]); + }); }); diff --git a/test/parser/inline-model-naming.test.ts b/test/parser/inline-model-naming.test.ts index 97d0daf9..199ea407 100644 --- a/test/parser/inline-model-naming.test.ts +++ b/test/parser/inline-model-naming.test.ts @@ -475,6 +475,35 @@ describe('path-derived names that would otherwise collide', () => { expect(warnSpy).not.toHaveBeenCalled(); }); + it('keeps the plain name on POST /v1/pets/{id}/adopt/now beside POST /v1/pets/{id}:adopt:now, a literal segment (issue #378)', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const idParam = [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }]; + const colon = { post: { ...fooBar.post, parameters: idParam } }; + const slash = { post: { ...fooSlashBar.post, parameters: idParam } }; + + // `{id}:adopt:now` is kept whole for its several colons and is no + // parameter, so it does not flatten to the slash form's `ByIdAdoptNow`. + const names = await namesInBothOrders([ + ['/v1/pets/{id}:adopt:now', colon], + ['/v1/pets/{id}/adopt/now', slash], + ]); + expect(names).toEqual({ + '/v1/pets/{id}/adopt/now': ['V1PetsByIdAdoptNowPostRequest', 'V1PetsByIdAdoptNowPostResponse'], + '/v1/pets/{id}:adopt:now': ['V1PetsIdAdoptNowPostRequest', 'V1PetsIdAdoptNowPostResponse'], + }); + + // As a literal it is irregular (pathDeviation), so the slash form it does + // flatten to keeps the plain name. + const literal = await namesInBothOrders([ + ['/v1/pets/{id}:adopt:now', colon], + ['/v1/pets/id/adopt/now', fooSlashBar], + ]); + expect(literal['/v1/pets/id/adopt/now']).toEqual(['V1PetsIdAdoptNowPostRequest', 'V1PetsIdAdoptNowPostResponse']); + expect(literal['/v1/pets/{id}:adopt:now'][0]).not.toBe('V1PetsIdAdoptNowPostRequest'); + expect(literal['/v1/pets/{id}:adopt:now'][1]).not.toBe('V1PetsIdAdoptNowPostResponse'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + it('still hashes both GET /tags/{tag}.json and GET /tags/{tag}/json: a parameter segment is never irregular, so a path without a colon scores as before', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const tagParam = [{ name: 'tag', in: 'path', required: true, schema: { type: 'string' } }]; From c0f5764eb561b14cf57f307e2fa796ee567d43e0 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 09:49:05 -0400 Subject: [PATCH 14/59] fix(resolve): name `{id}:a:b{ext}` from its text in deriveMethodName `deriveMethodName` still dropped any segment that starts with `{` and ends with `}`, so a segment kept whole for its several colons that braces open and close was read as a parameter: `GET /pets/{id}` and `GET /pets/{id}:a:b{ext}` both resolved to `get_pet`, and `findResolvedMethodCollisions` reported the pair, where the base named the second `list_b{ext}`. Its parameter check now goes through `isParameterSegment` as well, so the segment is a literal and the GET is `list_{id}:a:b{ext}`, as the branch's first commit already says `deriveMethodName` reads such a segment whole. A segment that does not end in `}` (`{id}.json`) is a literal there, as before. Addresses Greptile's P1 "Distinct routes share a name" on src/ir/operation-hints.ts. --- src/ir/operation-hints.ts | 6 ++++-- test/ir/operation-hints.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ir/operation-hints.ts b/src/ir/operation-hints.ts index f2c920b3..65b9eaf0 100644 --- a/src/ir/operation-hints.ts +++ b/src/ir/operation-hints.ts @@ -281,7 +281,9 @@ function singularize(word: string): string { * 1. Strip path-param segments (`{id}`, `{slug}`, etc.); a custom method * written after a colon (`{id}:verify`, `factors:enroll`) is read as its * segment followed by the verb (see pathSegments), so it is named like - * `{id}/verify` and `factors/enroll` + * `{id}/verify` and `factors/enroll`, and a segment kept whole for its + * several colons is a literal (see isParameterSegment), even one braces + * open and close (`{id}:a:b{ext}`) * 2. If the terminal segment is an action verb, use it as the verb * 3. Otherwise, use the CRUD verb for the HTTP method * 4. For GET on a collection (no trailing {id}), use "list"; with {id}, use "get" @@ -295,7 +297,7 @@ export function deriveMethodName(op: Operation, _service: Service): string { const staticSegments: string[] = []; let trailingParam = false; for (const seg of segments) { - if (seg.startsWith('{') && seg.endsWith('}')) { + if (isParameterSegment(seg) && seg.endsWith('}')) { trailingParam = true; } else { staticSegments.push(seg); diff --git a/test/ir/operation-hints.test.ts b/test/ir/operation-hints.test.ts index 5ac155bf..b1a22bec 100644 --- a/test/ir/operation-hints.test.ts +++ b/test/ir/operation-hints.test.ts @@ -113,6 +113,12 @@ describe('readPath: a colon that is not a custom method (issue #378)', () => { expect(literals.filter((segment) => isParameterSegment(segment))).toEqual([]); }); + it('names a segment kept whole for its several colons from its text, even one that ends with a parameter', () => { + const pets = svc('Pets', [op('get', '/pets/{id}'), op('get', '/pets/{id}:a:b{ext}')]); + expect(pets.operations.map((o) => deriveMethodName(o, pets))).toEqual(['get_pet', 'list_{id}:a:b{ext}']); + expect(findResolvedMethodCollisions(resolveOperations(spec([pets])))).toEqual([]); + }); + it('names the issue repro as the segments read before #322, the custom method still split off', () => { // A literal segment is snake-cased as written, so the segment kept whole // names the GET as `file-{file_path}` names it, never the cut `list_path}`. From dbfbe8c6f6626de8b6dd8df9c794656848e42f44 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 16:20:49 -0400 Subject: [PATCH 15/59] fix(parser): read the stop field beside every object a nested dataPath passes through Since #360, `detectEndOfIteration` read the object a nested `dataPath`'s array sits in, then went straight to the top level and its metadata companions. An object between the two was skipped, so for `dataPath: 'result.page.items'` the `result.total_pages` beside `result.page` was never read and the iterator had no `endOfIteration`. It now reads every object on the path, nearest the array first (`result.page`, then `result`), then the top level and the metadata companions as before. At each level the property the path goes on through is left out, the top level included, so nothing on the path is taken for a stop field. The JSDoc and `docs/architecture/ir-types.md` name the new order. Closes #433 --- docs/architecture/ir-types.md | 4 +- src/ir/types.ts | 13 ++--- src/parser/pagination.ts | 42 +++++++++------- src/parser/responses.ts | 7 +-- test/parser/pagination-override.test.ts | 37 ++++++++++++++ test/parser/pagination-page.test.ts | 66 +++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 30 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 93bf1cbe..9e0aaf55 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -265,7 +265,7 @@ Every iterator stops on an empty page. `endOfIteration`, when present, lets it s - `totalPages` — an integer count of pages; stop once the pages fetched so far reach it. Under `page` that is `page - pageStart + 1 >= totalPages` (`page >= totalPages` for a 1-based API, `page + 1 >= totalPages` for a 0-based one); under `offset` it is `floor(offset / limit) + 1 >= totalPages`. - `totalCount` — an integer count of items; stop once the items received so far reach it. -It is detected from a recognized envelope — a boolean `has_more`/`hasMore`/`has_next`/`hasNext`/`has_next_page`/`hasNextPage`, an integer `total_pages`/`totalPages`/`page_count`/`pageCount`, or an integer `total`/`total_count`/`totalCount`/`total_items`/`totalItems`/`total_results`/`totalResults` — beside the array first, when the `dataPath` is nested: the other properties of the object the array sits in, under whatever name (`result.total_pages` beside `result.items`, `data.page.has_more` beside `data.page.items`), that object reached as the path itself is resolved — one property walk (`collectObjectProperties`) reads a wrapper's companions, its stop field, each metadata companion and every segment of a `dataPath`, through `$ref`s, `allOf` members and nullable wrappers, settling a name two members declare as the model does (`combineDeclarations`); then at the top level; then one level down inside each top-level companion object whose name says it is pagination metadata (`meta`, `pagination`, `page_info`, `cursor`, …, in any spelling: the name's snake_case form is matched, so `pageInfo`, `PageInfo` and `page-info` are read exactly as `page_info` is — `{ data, pageInfo: { hasNextPage } }` is `string[]` with a `PageInfo` model and the stop field `pageInfo.hasNextPage` — while a name whose snake_case form carries none of these words (`nameTag`, whose `name_tag` spells `meta` only across a word boundary) marks no envelope, so `{ data, nameTag }` is a plain `ListPetsResponse` model; inline, `$ref`, `allOf`, or a nullable wrapper) — a `stats.total` beside `pagination.page` is not the list's size, and the array itself is never a stop field — and the path follows the `dataPath` convention, each segment spelled as the document spells it (`pageInfo.hasNextPage`). The body an explicit `dataPath` keeps as a wrapper (below) is read the same way. When several candidates exist, `hasMore` beats `totalPages`, which beats `totalCount`. List-envelope recognition shares that list of names, so an envelope whose only companion is a stop field in any of these spellings — `{ data, has_more }`, `{ data, hasMore }`, `{ data, hasNext }`, `{ data, pageCount }` — is recognized like any other: unwrapped when a declared query parameter drives the iterator, kept as a plain response model when none does (see the strategy table above). +It is detected from a recognized envelope — a boolean `has_more`/`hasMore`/`has_next`/`hasNext`/`has_next_page`/`hasNextPage`, an integer `total_pages`/`totalPages`/`page_count`/`pageCount`, or an integer `total`/`total_count`/`totalCount`/`total_items`/`totalItems`/`total_results`/`totalResults` — beside the array first, when the `dataPath` is nested: the other properties of the object the array sits in, under whatever name (`result.total_pages` beside `result.items`, `data.page.has_more` beside `data.page.items`), then those of each object the path passes through on its way there, nearest the array first (`result.total_pages` beside `result.page` for `result.page.items`), every one reached as the path itself is resolved — one property walk (`collectObjectProperties`) reads a wrapper's companions, its stop field, each metadata companion and every segment of a `dataPath`, through `$ref`s, `allOf` members and nullable wrappers, settling a name two members declare as the model does (`combineDeclarations`); then at the top level; then one level down inside each top-level companion object whose name says it is pagination metadata (`meta`, `pagination`, `page_info`, `cursor`, …, in any spelling: the name's snake_case form is matched, so `pageInfo`, `PageInfo` and `page-info` are read exactly as `page_info` is — `{ data, pageInfo: { hasNextPage } }` is `string[]` with a `PageInfo` model and the stop field `pageInfo.hasNextPage` — while a name whose snake_case form carries none of these words (`nameTag`, whose `name_tag` spells `meta` only across a word boundary) marks no envelope, so `{ data, nameTag }` is a plain `ListPetsResponse` model; inline, `$ref`, `allOf`, or a nullable wrapper) — a `stats.total` beside `pagination.page` is not the list's size, and nothing the path passes through, the array or an object on the way to it, is ever a stop field — and the path follows the `dataPath` convention, each segment spelled as the document spells it (`pageInfo.hasNextPage`). The body an explicit `dataPath` keeps as a wrapper (below) is read the same way. When several candidates exist, `hasMore` beats `totalPages`, which beats `totalCount`. List-envelope recognition shares that list of names, so an envelope whose only companion is a stop field in any of these spellings — `{ data, has_more }`, `{ data, hasMore }`, `{ data, hasNext }`, `{ data, pageCount }` — is recognized like any other: unwrapped when a declared query parameter drives the iterator, kept as a plain response model when none does (see the strategy table above). The `dataPath` is dynamically detected from the response envelope — when `undefined`, emitters should treat the entire response as the data array or use their own default. @@ -330,7 +330,7 @@ An explicit `dataPath` is resolved against the primary response (the lowest 2xx `param` is resolved in this order: the override's own, else the first query parameter the operation declares under the strategy's own names (`page` for `page`, `cursor`/`after`/… for `cursor`, `offset`/`skip` for `offset`), else the detected one — so `{ strategy: 'page' }` on `GET /pets?page` drives `page`, which detection alone makes no iterator of for want of a page size, and `{ strategy: 'cursor', param: 'c' }` on `GET /pets?c` iterates on a parameter detection does not know. The resolved `param` and `limitParam`, not only explicit ones, must be query parameters the operation declares, and must be two different ones: `{ param: 'limit' }` beside a detected `limitParam: 'limit'` is rejected, since one parameter cannot carry both the page and the page size. In both cases the override is what creates the iterator, and it is built around the envelope detection unwrapped: `dataPath`, `dataDescription`, `itemType`, `envelopeFields` and the stop field come from the response exactly as they do for a detected iterator. -An explicit `dataPath` that names another array than the one the parser would unwrap (`{ data, result: { items }, total }` with `dataPath: 'result.items'`) means detection was wrong for the operation, so the envelope is kept as a wrapper, exactly as `strategy: 'none'` keeps it: `response` is the wrapper model (what the wire returns), `dataPath` says where the items are in it, `itemType` is their type, and `envelopeFields` is absent since the wrapper carries the companions; the wrapper's stop field is still read (`total` → `endOfIteration`), also when the override is what creates the iterator — no paging parameter recognized, say a custom `param: 'p'` — rather than correcting a detected one. The stop field is read from whatever wrapper the override iterates, not only from an envelope detection recognized: a `$ref` list response (`PetList: { data: Pet[], has_more }` returned as `$ref: PetList` with `dataPath: 'data'` → `has_more`) and an inline body detection does not recognize because no top-level array sits beside a stop field (`{ result: { items: Pet[] }, total_pages }` with `dataPath: 'result.items'` → `total_pages`) are read exactly as a recognized envelope is — through the `$ref`, every `allOf` member however deep, and a nullable wrapper around the body or around any member (`{ allOf: [{ oneOf: [{ $ref: Base }, { type: 'null' }] }, { properties: { data } }] }` reads `Base`'s `has_more`, as does a `$ref` chain ending on such a wrapper) — keyed by the full path, so the array the override names is never taken for a stop field. A nullable `allOf` member applies as its one non-null variant for the model too, as a component and as an inline body: the wrapper's model carries `Base`'s `has_more` beside `data`, and a recognized envelope's `envelopeFields` carry what such a member declares (`trace_id` from a nullable `Meta`), so the fields an iterator stops on are fields the model declares. The array may sit under a nullable wrapper of any form — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, `oneOf: [{ type: 'array' }, { type: 'null' }]`, a nullable `$ref` to an array component — and the document is read through the same wrappers the IR walk reads through, so whether the path reaches an array is decided once, the same way, before the body is classified and when its items are typed. A `dataPath` that restates the detected one changes nothing, and one that reaches no array leaves the envelope unwrapped and is ignored with a warning. Because a kept wrapper is not a recognized envelope, such an override needs a strategy from somewhere — its own, or the operation's query parameters. Whether an override can apply at all is decided before the response is read, against the pagination the query parameters alone yield (a parameter the operation does not declare never makes one usable), so an override that is rejected leaves the response exactly as detection reads it — kept as a plain response model when no declared parameter pages it, with the rejection as the one warning. When the override is the only possible source of an iterator (no query parameter yields one) and a 2xx body cannot satisfy it — its `dataPath` reaches no array in that body, or its `endOfIteration` resolves in it to no field of the fitting type (below) — that body's envelope is kept the same way rather than unwrapped for the iterator the dropped override would have made. `dataDescription` survives an override that keeps the detected array, and the stop field beside a nested array an override selects (`result.total_pages` next to `result.items`, `pagination.has_more` next to `pagination.items`) is read first, whatever the parent is called; a stop field wrapped in a nullable `oneOf` (`[{ type: 'boolean' }, { type: 'null' }]`), behind a `$ref`, or behind a lone `allOf` member that types it (`has_more: { allOf: [{ $ref: Flag }], description }`, or an inline `{ allOf: [{ type: 'boolean' }] }`, read as the member exactly as the IR types the field; an `allOf` of several members, of bare limits beside the field's own `type`, or of a lone `$ref` member with a `required` beside it that the component's properties can satisfy — the merged model the IR builds — is left as written), however the three alternate, is read like one declared with a type list, and a component that refers back to itself (`Flag: { oneOf: [{ $ref: Flag }, { type: 'null' }] }`) ends the walk rather than looping on it, as a stop field or as any other companion. +An explicit `dataPath` that names another array than the one the parser would unwrap (`{ data, result: { items }, total }` with `dataPath: 'result.items'`) means detection was wrong for the operation, so the envelope is kept as a wrapper, exactly as `strategy: 'none'` keeps it: `response` is the wrapper model (what the wire returns), `dataPath` says where the items are in it, `itemType` is their type, and `envelopeFields` is absent since the wrapper carries the companions; the wrapper's stop field is still read (`total` → `endOfIteration`), also when the override is what creates the iterator — no paging parameter recognized, say a custom `param: 'p'` — rather than correcting a detected one. The stop field is read from whatever wrapper the override iterates, not only from an envelope detection recognized: a `$ref` list response (`PetList: { data: Pet[], has_more }` returned as `$ref: PetList` with `dataPath: 'data'` → `has_more`) and an inline body detection does not recognize because no top-level array sits beside a stop field (`{ result: { items: Pet[] }, total_pages }` with `dataPath: 'result.items'` → `total_pages`) are read exactly as a recognized envelope is — through the `$ref`, every `allOf` member however deep, and a nullable wrapper around the body or around any member (`{ allOf: [{ oneOf: [{ $ref: Base }, { type: 'null' }] }, { properties: { data } }] }` reads `Base`'s `has_more`, as does a `$ref` chain ending on such a wrapper) — keyed by the full path, so the array the override names is never taken for a stop field. A nullable `allOf` member applies as its one non-null variant for the model too, as a component and as an inline body: the wrapper's model carries `Base`'s `has_more` beside `data`, and a recognized envelope's `envelopeFields` carry what such a member declares (`trace_id` from a nullable `Meta`), so the fields an iterator stops on are fields the model declares. The array may sit under a nullable wrapper of any form — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, `oneOf: [{ type: 'array' }, { type: 'null' }]`, a nullable `$ref` to an array component — and the document is read through the same wrappers the IR walk reads through, so whether the path reaches an array is decided once, the same way, before the body is classified and when its items are typed. A `dataPath` that restates the detected one changes nothing, and one that reaches no array leaves the envelope unwrapped and is ignored with a warning. Because a kept wrapper is not a recognized envelope, such an override needs a strategy from somewhere — its own, or the operation's query parameters. Whether an override can apply at all is decided before the response is read, against the pagination the query parameters alone yield (a parameter the operation does not declare never makes one usable), so an override that is rejected leaves the response exactly as detection reads it — kept as a plain response model when no declared parameter pages it, with the rejection as the one warning. When the override is the only possible source of an iterator (no query parameter yields one) and a 2xx body cannot satisfy it — its `dataPath` reaches no array in that body, or its `endOfIteration` resolves in it to no field of the fitting type (below) — that body's envelope is kept the same way rather than unwrapped for the iterator the dropped override would have made. `dataDescription` survives an override that keeps the detected array, and the stop field beside a nested array an override selects (`result.total_pages` next to `result.items`, `pagination.has_more` next to `pagination.items`) is read first, whatever the parent is called, then one beside each object further up the path, nearest first (`result.total_pages` next to `result.page` for `result.page.items`); a stop field wrapped in a nullable `oneOf` (`[{ type: 'boolean' }, { type: 'null' }]`), behind a `$ref`, or behind a lone `allOf` member that types it (`has_more: { allOf: [{ $ref: Flag }], description }`, or an inline `{ allOf: [{ type: 'boolean' }] }`, read as the member exactly as the IR types the field; an `allOf` of several members, of bare limits beside the field's own `type`, or of a lone `$ref` member with a `required` beside it that the component's properties can satisfy — the merged model the IR builds — is left as written), however the three alternate, is read like one declared with a type list, and a component that refers back to itself (`Flag: { oneOf: [{ $ref: Flag }, { type: 'null' }] }`) ends the walk rather than looping on it, as a stop field or as any other companion. An explicit `endOfIteration` is checked against the primary response as a detected one is read: its `path` starts from the property map a detected stop field is read from — the body's own properties and every `allOf` member's, through `$ref`s and nullable wrappers, for a recognized envelope and for a wrapper kept under an explicit `dataPath` alike — and is walked from there as an explicit `dataPath` is, through `$ref`s, `allOf` members and nullable wrappers at every further segment; the field it lands on must have the type the kind is tested against, a boolean for `hasMore` and an integer or number for `totalPages` and `totalCount`, under a type list (`['integer', 'null']`), a nullable wrapper or a lone `allOf` member too, read as a detected stop field is. A path that resolves to no field of the primary response (`nope.missing`, or any path when the primary declares no body), or to a field of another type (`{ kind: 'hasMore', path: 'total' }` on an integer `total`, or the data array itself), makes the override unusable; a field only another 2xx body declares does not count. The two fields that depend on the response — `dataPath` and `endOfIteration` — are decided together, once, against the primary response — the lowest 2xx with a typed body, the entry `Operation.response` describes, chosen by the one rule before any success body is classified, so a `200` whose schema describes nothing yields to a typed `201` for both — and every 2xx body is then read with that one decision: a rejected override is handed to none, so a secondary 2xx is read exactly as detection reads it rather than kept as the wrapper the override would have selected; an accepted one keeps the wrapper in each body its `dataPath` reaches an array in. An override a secondary 2xx could satisfy but the primary cannot is rejected, and so is one that asks of the response when there is no primary (no `responses`, no 2xx or 3xx) or the primary declares no body (a 204, or a 3xx when no 2xx is declared). diff --git a/src/ir/types.ts b/src/ir/types.ts index 08a066cf..4fdedffb 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -725,12 +725,13 @@ export interface PaginationMeta { * `PaginationEndOfIteration`). Detected from a recognized list envelope * when it carries an obvious field — a boolean `has_more`/`hasMore`, an * integer `total_pages`/`totalPages`, or an integer `total`/`total_count` — - * beside the array a nested `dataPath` names (`result.total_pages`), at - * the top level, or one level down inside a pagination companion object - * (`pagination.totalPages`), in that order, and the same way from the body - * an explicit `dataPath` keeps as a wrapper — a component the response is - * a `$ref` to, or an inline body detection did not recognize; a `hasMore` - * field is preferred over a + * beside the array a nested `dataPath` names (`result.total_pages`), beside + * each object the path passes through on its way there, nearest the array + * first, at the top level, or one level down inside a pagination companion + * object (`pagination.totalPages`), in that order, and the same way from + * the body an explicit `dataPath` keeps as a wrapper — a component the + * response is a `$ref` to, or an inline body detection did not recognize; + * a `hasMore` field is preferred over a * `totalPages` one, which is preferred over a count. The operation's * `x-pagination` extension or its `operationHints` entry can set it * explicitly, to a field of the primary response whose type fits the kind. diff --git a/src/parser/pagination.ts b/src/parser/pagination.ts index 63254d71..6ca82058 100644 --- a/src/parser/pagination.ts +++ b/src/parser/pagination.ts @@ -260,20 +260,23 @@ export function looksLikePaginationMetadata(name: string): boolean { * The fields beside the array are considered first: when the path is * nested, the other properties of the object the array sits in, under * whatever name that object has (`result.total_pages` beside - * `result.items`, `data.page.has_more` beside `data.page.items`), the - * parent reached exactly as the path itself is resolved - * (`resolveSchemaPath` from the merged properties), so one behind a `$ref`, - * an `allOf` or a nullable wrapper is read as the walk reads it. Then the - * top-level fields, then the fields one level down inside each top-level - * object companion whose name says it is pagination metadata + * `result.items`, `data.page.has_more` beside `data.page.items`), then + * those of each object the path passes through on its way there, nearest + * the array first (`result.total_pages` beside `result.page` for + * `result.page.items`), every one reached exactly as the path itself is + * resolved (`resolveSchemaPath` from the merged properties), so one behind + * a `$ref`, an `allOf` or a nullable wrapper is read as the walk reads it. + * Then the top-level fields, then the fields one level down inside each + * top-level object companion whose name says it is pagination metadata * (`looksLikePaginationMetadata`: `meta`, `pagination`, `page_info`, ..., in * any spelling — `pageInfo` and `PageInfo` as much as `page_info`; an * inline object, a `$ref` to a component, an `allOf` of those, or a nullable * wrapper around one), so a `stats.total` beside `pagination.page` is not - * taken for the list's size. The array itself is never a stop field, - * whatever it is called. The path follows the `dataPath` convention — - * `has_more`, `result.total_pages` or `pagination.totalPages` — and spells - * each segment as the document does (`pageInfo.hasNextPage`). When several + * taken for the list's size. Nothing the path passes through — the array, + * or an object on the way to it — is ever a stop field, whatever it is + * called. The path follows the `dataPath` convention — `has_more`, + * `result.total_pages` or `pagination.totalPages` — and spells each + * segment as the document does (`pageInfo.hasNextPage`). When several * candidates exist, a `hasMore` boolean wins over a `totalPages` integer, * which wins over a `totalCount` integer; within one kind the first in scan * order wins. A candidate whose type does not fit its kind (a string @@ -290,18 +293,19 @@ export function detectEndOfIteration( if (kind && !found.has(kind)) found.set(kind, path); }; + // Every object on the path, nearest the array first, without the property + // the path goes on through; the top level, the outermost, comes last. const segments = dataPath.split('.'); - const arrayName = segments[segments.length - 1]; - if (segments.length > 1) { - const parentPath = segments.slice(0, -1).join('.'); + for (let depth = segments.length - 1; depth > 0; depth--) { + const parentPath = segments.slice(0, depth).join('.'); const parent = resolveSchemaPath({ properties }, parentPath, componentSchemas); for (const [inner, innerSchema] of Object.entries(parent ? objectProperties(parent, componentSchemas) : {})) { - if (innerSchema && inner !== arrayName) record(inner, `${parentPath}.${inner}`, innerSchema); + if (innerSchema && inner !== segments[depth]) record(inner, `${parentPath}.${inner}`, innerSchema); } } const companions = Object.entries(properties).filter( - (entry): entry is [string, SchemaObject] => entry[0] !== dataPath && entry[1] !== undefined, + (entry): entry is [string, SchemaObject] => entry[0] !== segments[0] && entry[1] !== undefined, ); for (const [name, schema] of companions) record(name, name, schema); for (const [name, schema] of companions) { @@ -460,10 +464,10 @@ function objectProperties( * `allOf` members, with the `required` lists that go with them — the one * walk through a composite schema: a list envelope's companions * (`extractListResponse`), a kept wrapper's stop field - * (`bodyEndOfIteration`), a metadata companion's fields and the object - * beside a nested array (`detectEndOfIteration`) and every segment of a - * `dataPath` (`resolveSchemaPath`) are all read through it, so they see one - * shape at every level. + * (`bodyEndOfIteration`), a metadata companion's fields and each object + * on the path to a nested array (`detectEndOfIteration`) and every + * segment of a `dataPath` (`resolveSchemaPath`) are all read through it, + * so they see one shape at every level. * * Every node the walk meets — the body, then each `allOf` member, however * deep — is read through `resolveBareSchema`, the resolver the explicit diff --git a/src/parser/responses.ts b/src/parser/responses.ts index 8fb04050..fd103ba1 100644 --- a/src/parser/responses.ts +++ b/src/parser/responses.ts @@ -251,9 +251,10 @@ export function classifyAndExtractResponse( * the path walked), and `detectEndOfIteration` reads them as it reads an * unwrapped envelope's: the fields beside the array the path names first, * under a parent of any name (`result.total_pages` next to `result.items`), - * then the top level, then the metadata companions, and never the array - * itself. `undefined` when nothing recognizable is declared, or the body - * resolves to no object at all. + * then those beside each object the path passes through, nearest the array + * first, then the top level, then the metadata companions, and never the + * array or an object on the path itself. `undefined` when nothing + * recognizable is declared, or the body resolves to no object at all. */ function bodyEndOfIteration( schema: SchemaObject, diff --git a/test/parser/pagination-override.test.ts b/test/parser/pagination-override.test.ts index 8095ddf6..180eb7e2 100644 --- a/test/parser/pagination-override.test.ts +++ b/test/parser/pagination-override.test.ts @@ -1922,6 +1922,43 @@ describe('parseSpec — page pagination and x-pagination overrides', () => { expect(warn).not.toHaveBeenCalled(); }); + it('reads the stop field beside every object the path passes through, nearest the array first (#433)', async () => { + // The issue's three bodies, written inline and as a `$ref` to a + // component of the same shape. + const object = (properties: Record) => ({ type: 'object', properties }); + const total_pages = { type: 'integer' }; + const bodies: Array<[string, string, Record, string]> = [ + [ + 'grandparent', + 'result.page.items', + { result: object({ page: object({ items: petArray }), total_pages }) }, + 'result.total_pages', + ], + ['parent', 'result.items', { result: object({ items: petArray, total_pages }) }, 'result.total_pages'], + [ + 'top', + 'result.page.items', + { result: object({ page: object({ items: petArray }) }), total_pages }, + 'total_pages', + ], + ]; + for (const [label, dataPath, properties, path] of bodies) { + const expected = { + dataPath, + itemType: { kind: 'model', name: 'Pet' }, + endOfIteration: { kind: 'totalPages', path }, + }; + const inline = onlyOperation(await parseJson(listOp(object(properties), { dataPath }))); + expect(inline.pagination, `${label} (inline)`).toMatchObject(expected); + const Envelope = object(properties); + const referenced = onlyOperation( + await parseJson(listOp({ $ref: '#/components/schemas/Envelope' }, { dataPath }, { Envelope })), + ); + expect(referenced.pagination, `${label} ($ref)`).toMatchObject(expected); + } + expect(warn).not.toHaveBeenCalled(); + }); + it("ends the walk where a component reaches itself through a nullable allOf member (Greptile's cycle)", async () => { // `Base.allOf` holds a nullable reference back to `Base`; beside it // `{ data, has_more }` makes the body a recognized envelope, so the diff --git a/test/parser/pagination-page.test.ts b/test/parser/pagination-page.test.ts index b65562ef..b903a27f 100644 --- a/test/parser/pagination-page.test.ts +++ b/test/parser/pagination-page.test.ts @@ -389,6 +389,72 @@ describe('detectEndOfIteration', () => { description: 'The pets', }); }); + it('reads every object the path passes through, nearest the array first, before the top level (#433)', () => { + const object = (properties: Record): SchemaObject => ({ type: 'object', properties }); + /** `{ result: { page: { items, ...page }, ...result }, ...top }`, listed at `result.page.items`. */ + const detect = ( + page: Record, + result: Record, + top: Record = {}, + ) => + detectEndOfIteration( + { result: object({ page: object({ items: data, ...page }), ...result }), ...top }, + 'result.page.items', + ); + // The issue: the stop field beside the array's grandparent is read, as + // one beside its parent and one at the top level are. + expect(detect({}, { total_pages: integer })).toEqual({ kind: 'totalPages', path: 'result.total_pages' }); + expect(detect({ total_pages: integer }, {})).toEqual({ kind: 'totalPages', path: 'result.page.total_pages' }); + expect(detect({}, {}, { total_pages: integer })).toEqual({ kind: 'totalPages', path: 'total_pages' }); + // Nearest the array first within one kind; the kind order still decides + // across levels. + expect(detect({ has_more: boolean }, { has_more: boolean }, { has_more: boolean })).toEqual({ + kind: 'hasMore', + path: 'result.page.has_more', + }); + expect(detect({}, { has_more: boolean }, { has_more: boolean })).toEqual({ + kind: 'hasMore', + path: 'result.has_more', + }); + expect(detect({ total: integer }, { has_more: boolean })).toEqual({ kind: 'hasMore', path: 'result.has_more' }); + expect(detect({}, { total: integer }, { has_more: boolean })).toEqual({ kind: 'hasMore', path: 'has_more' }); + // Every object between the top level and the array, at any depth. + expect( + detectEndOfIteration( + { a: object({ b: object({ c: object({ items: data }) }), totalPages: integer }) }, + 'a.b.c.items', + ), + ).toEqual({ kind: 'totalPages', path: 'a.totalPages' }); + // Each object is reached as the path is resolved: through a $ref under a + // nullable wrapper, and through an allOf. + const components: Record = { + Result: object({ page: object({ items: data }), total_pages: integer }), + }; + const wrapped: SchemaObject = { oneOf: [{ $ref: '#/components/schemas/Result' }, { type: 'null' }] }; + expect(detectEndOfIteration({ result: wrapped }, 'result.page.items', components)).toEqual({ + kind: 'totalPages', + path: 'result.total_pages', + }); + const composed: SchemaObject = { + allOf: [object({ page: object({ items: data }) }), object({ has_more: boolean })], + }; + expect(detectEndOfIteration({ result: composed }, 'result.page.items')).toEqual({ + kind: 'hasMore', + path: 'result.has_more', + }); + // Nothing the path passes through is a stop field, whatever it is called: + // not even an object whose type list also admits a count. + const countOrObject = (properties: Record): SchemaObject => ({ + type: ['object', 'integer'], + properties, + }); + expect( + detectEndOfIteration({ result: object({ total: countOrObject({ items: data }) }) }, 'result.total.items'), + ).toBeUndefined(); + expect( + detectEndOfIteration({ total: countOrObject({ page: object({ items: data }) }) }, 'total.page.items'), + ).toBeUndefined(); + }); }); describe('resolveSchemaPath', () => { From 4148bd649fbffaf99097d71f79ea7069e9a497c4 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 16:34:34 -0400 Subject: [PATCH 16/59] fix(parser): accept an x-pagination stop field every oneOf variant declares An override's endOfIteration path was walked by resolveSchemaPath, which reads each segment from an object's own properties: a multi-variant oneOf/anyOf has none, so `meta.has_more` was rejected as resolving to no field even when every variant of `meta` declares it as a boolean, and the iterator lost its stop (regressed in #362). endOfIterationProblem now walks the path with fieldsAtPath: a segment the object does not declare itself is looked up in each non-null variant of its oneOf/anyOf, and the path goes on in every one. The override is kept only when every variant declares the field and each fits the kind; a field only some variants declare still resolves to no field, and one a variant types otherwise still fails the type check. The dataPath walk and detection are unchanged. Closes #437 --- docs/architecture/ir-types.md | 2 +- src/parser/pagination.ts | 45 +++++++++++++-- test/parser/pagination-override.test.ts | 74 +++++++++++++++++++++++++ test/parser/pagination-page.test.ts | 60 ++++++++++++++++++++ 4 files changed, 176 insertions(+), 5 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 9e0aaf55..c705089e 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -332,7 +332,7 @@ An explicit `dataPath` is resolved against the primary response (the lowest 2xx An explicit `dataPath` that names another array than the one the parser would unwrap (`{ data, result: { items }, total }` with `dataPath: 'result.items'`) means detection was wrong for the operation, so the envelope is kept as a wrapper, exactly as `strategy: 'none'` keeps it: `response` is the wrapper model (what the wire returns), `dataPath` says where the items are in it, `itemType` is their type, and `envelopeFields` is absent since the wrapper carries the companions; the wrapper's stop field is still read (`total` → `endOfIteration`), also when the override is what creates the iterator — no paging parameter recognized, say a custom `param: 'p'` — rather than correcting a detected one. The stop field is read from whatever wrapper the override iterates, not only from an envelope detection recognized: a `$ref` list response (`PetList: { data: Pet[], has_more }` returned as `$ref: PetList` with `dataPath: 'data'` → `has_more`) and an inline body detection does not recognize because no top-level array sits beside a stop field (`{ result: { items: Pet[] }, total_pages }` with `dataPath: 'result.items'` → `total_pages`) are read exactly as a recognized envelope is — through the `$ref`, every `allOf` member however deep, and a nullable wrapper around the body or around any member (`{ allOf: [{ oneOf: [{ $ref: Base }, { type: 'null' }] }, { properties: { data } }] }` reads `Base`'s `has_more`, as does a `$ref` chain ending on such a wrapper) — keyed by the full path, so the array the override names is never taken for a stop field. A nullable `allOf` member applies as its one non-null variant for the model too, as a component and as an inline body: the wrapper's model carries `Base`'s `has_more` beside `data`, and a recognized envelope's `envelopeFields` carry what such a member declares (`trace_id` from a nullable `Meta`), so the fields an iterator stops on are fields the model declares. The array may sit under a nullable wrapper of any form — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, `oneOf: [{ type: 'array' }, { type: 'null' }]`, a nullable `$ref` to an array component — and the document is read through the same wrappers the IR walk reads through, so whether the path reaches an array is decided once, the same way, before the body is classified and when its items are typed. A `dataPath` that restates the detected one changes nothing, and one that reaches no array leaves the envelope unwrapped and is ignored with a warning. Because a kept wrapper is not a recognized envelope, such an override needs a strategy from somewhere — its own, or the operation's query parameters. Whether an override can apply at all is decided before the response is read, against the pagination the query parameters alone yield (a parameter the operation does not declare never makes one usable), so an override that is rejected leaves the response exactly as detection reads it — kept as a plain response model when no declared parameter pages it, with the rejection as the one warning. When the override is the only possible source of an iterator (no query parameter yields one) and a 2xx body cannot satisfy it — its `dataPath` reaches no array in that body, or its `endOfIteration` resolves in it to no field of the fitting type (below) — that body's envelope is kept the same way rather than unwrapped for the iterator the dropped override would have made. `dataDescription` survives an override that keeps the detected array, and the stop field beside a nested array an override selects (`result.total_pages` next to `result.items`, `pagination.has_more` next to `pagination.items`) is read first, whatever the parent is called, then one beside each object further up the path, nearest first (`result.total_pages` next to `result.page` for `result.page.items`); a stop field wrapped in a nullable `oneOf` (`[{ type: 'boolean' }, { type: 'null' }]`), behind a `$ref`, or behind a lone `allOf` member that types it (`has_more: { allOf: [{ $ref: Flag }], description }`, or an inline `{ allOf: [{ type: 'boolean' }] }`, read as the member exactly as the IR types the field; an `allOf` of several members, of bare limits beside the field's own `type`, or of a lone `$ref` member with a `required` beside it that the component's properties can satisfy — the merged model the IR builds — is left as written), however the three alternate, is read like one declared with a type list, and a component that refers back to itself (`Flag: { oneOf: [{ $ref: Flag }, { type: 'null' }] }`) ends the walk rather than looping on it, as a stop field or as any other companion. -An explicit `endOfIteration` is checked against the primary response as a detected one is read: its `path` starts from the property map a detected stop field is read from — the body's own properties and every `allOf` member's, through `$ref`s and nullable wrappers, for a recognized envelope and for a wrapper kept under an explicit `dataPath` alike — and is walked from there as an explicit `dataPath` is, through `$ref`s, `allOf` members and nullable wrappers at every further segment; the field it lands on must have the type the kind is tested against, a boolean for `hasMore` and an integer or number for `totalPages` and `totalCount`, under a type list (`['integer', 'null']`), a nullable wrapper or a lone `allOf` member too, read as a detected stop field is. A path that resolves to no field of the primary response (`nope.missing`, or any path when the primary declares no body), or to a field of another type (`{ kind: 'hasMore', path: 'total' }` on an integer `total`, or the data array itself), makes the override unusable; a field only another 2xx body declares does not count. The two fields that depend on the response — `dataPath` and `endOfIteration` — are decided together, once, against the primary response — the lowest 2xx with a typed body, the entry `Operation.response` describes, chosen by the one rule before any success body is classified, so a `200` whose schema describes nothing yields to a typed `201` for both — and every 2xx body is then read with that one decision: a rejected override is handed to none, so a secondary 2xx is read exactly as detection reads it rather than kept as the wrapper the override would have selected; an accepted one keeps the wrapper in each body its `dataPath` reaches an array in. An override a secondary 2xx could satisfy but the primary cannot is rejected, and so is one that asks of the response when there is no primary (no `responses`, no 2xx or 3xx) or the primary declares no body (a 204, or a 3xx when no 2xx is declared). +An explicit `endOfIteration` is checked against the primary response as a detected one is read: its `path` starts from the property map a detected stop field is read from — the body's own properties and every `allOf` member's, through `$ref`s and nullable wrappers, for a recognized envelope and for a wrapper kept under an explicit `dataPath` alike — and is walked from there as an explicit `dataPath` is, through `$ref`s, `allOf` members and nullable wrappers at every further segment, and through each non-null variant of a `oneOf`/`anyOf` on the way that does not declare the next segment itself, since whichever variant the wire carries the field must be there: `meta.has_more` resolves when every variant of a `oneOf` `meta` declares `has_more` (a field detection does not read, so the override is how an iterator stops on it); the field it lands on, in every variant, must have the type the kind is tested against, a boolean for `hasMore` and an integer or number for `totalPages` and `totalCount`, under a type list (`['integer', 'null']`), a nullable wrapper or a lone `allOf` member too, read as a detected stop field is. A path that resolves to no field of the primary response (`nope.missing`, a field only some variants declare, or any path when the primary declares no body), or to a field of another type (`{ kind: 'hasMore', path: 'total' }` on an integer `total`, a field one variant types otherwise, or the data array itself), makes the override unusable; a field only another 2xx body declares does not count. The two fields that depend on the response — `dataPath` and `endOfIteration` — are decided together, once, against the primary response — the lowest 2xx with a typed body, the entry `Operation.response` describes, chosen by the one rule before any success body is classified, so a `200` whose schema describes nothing yields to a typed `201` for both — and every 2xx body is then read with that one decision: a rejected override is handed to none, so a secondary 2xx is read exactly as detection reads it rather than kept as the wrapper the override would have selected; an accepted one keeps the wrapper in each body its `dataPath` reaches an array in. An override a secondary 2xx could satisfy but the primary cannot is rejected, and so is one that asks of the response when there is no primary (no `responses`, no 2xx or 3xx) or the primary declares no body (a 204, or a 3xx when no 2xx is declared). An override that cannot be applied is reported as an `[oagen] Warning:` and ignored, leaving the detected metadata in place. In the order checked: a malformed field or an unknown strategy, per source, so the other source still applies; then, on the merged override, no strategy from any source, a `pageStart` under a strategy other than `page`, no `param` from any source, a resolved `param` or `limitParam` the operation does not declare as a query parameter, a `param` and `limitParam` resolved to one parameter, a `dataPath` that does not reach an array in the response, and an `endOfIteration` that fails the check above. One warning names the override's source, the operation and the first of these met; an unknown key is skipped with a warning of its own. Under `strategy: 'none'` only the shape is checked: there is no iterator for the other fields to serve. `envelopeFields` always comes from the response. diff --git a/src/parser/pagination.ts b/src/parser/pagination.ts index 6ca82058..6ebab60b 100644 --- a/src/parser/pagination.ts +++ b/src/parser/pagination.ts @@ -360,7 +360,9 @@ function endFieldFits(kind: PaginationEndOfIteration['kind'], schema: SchemaObje * field it would not see is not there for an override either. `end.path` is * walked over that map as an explicit `dataPath` is (`resolveSchemaPath`: * through `$ref`s, `allOf` members and nullable wrappers at every further - * segment), and the field it lands on must fit `end.kind` by the test + * segment), and through each non-null variant of a `oneOf`/`anyOf` on the + * way that does not declare the next segment itself (`fieldsAtPath`), so it + * lands on one field per variant; each must fit `end.kind` by the test * detection applies to a field it names (`endFieldFits`), under * `resolveBareSchema` as a detected one is. With no `properties` — an * operation whose primary response declares no body — the path resolves to @@ -374,13 +376,48 @@ export function endOfIterationProblem( properties: Record | undefined, componentSchemas?: Record, ): string | undefined { - const target = properties && resolveSchemaPath({ properties }, end.path, componentSchemas); - if (!target) return `endOfIteration path "${end.path}" does not resolve to a field in the response`; - if (endFieldFits(end.kind, resolveBareSchema(target, componentSchemas))) return undefined; + const fields = properties && fieldsAtPath({ properties }, end.path.split('.'), componentSchemas); + if (!fields) return `endOfIteration path "${end.path}" does not resolve to a field in the response`; + if (fields.every((field) => endFieldFits(end.kind, resolveBareSchema(field, componentSchemas)))) return undefined; const expected = end.kind === 'hasMore' ? 'a boolean' : 'an integer or number'; return `endOfIteration path "${end.path}" is not ${expected} field, as kind "${end.kind}" requires`; } +/** + * Every field `segments` lands on below `schema`. Each segment is looked up + * among the properties of the object the path has reached, as + * `resolveSchemaPath` looks it up — through `$ref`s, `allOf` members and + * nullable wrappers — and, when that object does not declare it but is a + * `oneOf`/`anyOf`, in each of its non-null variants instead: whichever + * variant the wire carries, the field must be there, so the path goes on in + * every one and lands on one field per variant (`meta.has_more` with `meta` + * a `oneOf` of two objects that each declare `has_more`). `undefined` when + * the object, or any of its variants, lacks the segment. A variant that is + * itself such a union declares no properties of its own + * (`objectProperties`), so it lacks every segment. + */ +function fieldsAtPath( + schema: SchemaObject, + segments: readonly string[], + componentSchemas?: Record, +): SchemaObject[] | undefined { + if (segments.length === 0) return [schema]; + const [segment, ...rest] = segments; + const own = objectProperties(schema, componentSchemas)[segment]; + if (own) return fieldsAtPath(own, rest, componentSchemas); + const bare = resolveBareSchema(schema, componentSchemas); + const variants = (bare.oneOf ?? bare.anyOf ?? []).filter((variant) => !isNullSchema(variant)); + if (variants.length === 0) return undefined; + const fields: SchemaObject[] = []; + for (const variant of variants) { + const field = objectProperties(variant, componentSchemas)[segment]; + const reached = field && fieldsAtPath(field, rest, componentSchemas); + if (!reached) return undefined; + fields.push(...reached); + } + return fields; +} + function hasType(schema: SchemaObject, type: string): boolean { return Array.isArray(schema.type) ? schema.type.includes(type) : schema.type === type; } diff --git a/test/parser/pagination-override.test.ts b/test/parser/pagination-override.test.ts index 180eb7e2..1c25b16f 100644 --- a/test/parser/pagination-override.test.ts +++ b/test/parser/pagination-override.test.ts @@ -1091,6 +1091,80 @@ describe('parseSpec — page pagination and x-pagination overrides', () => { } }); + describe('a stop field every variant of a oneOf companion declares (#437)', () => { + // The issue's operation: `GET /pets?after` returning `{ data: string[], meta }`, + // `meta` a `oneOf` of two objects that each declare a boolean `has_more`. + const object = (properties: Record) => ({ type: 'object', properties }); + const boolean = { type: 'boolean' }; + const cursorSpec = (meta: unknown, extension?: unknown, components?: Record) => + petsSpec( + { + parameters: [query('after', { type: 'string' })], + responses: jsonResponse(object({ data: { type: 'array', items: { type: 'string' } }, meta })), + ...(extension !== undefined && { 'x-pagination': extension }), + }, + components, + ); + const endOfIteration = { kind: 'hasMore', path: 'meta.has_more' }; + const issueMeta = { + oneOf: [ + object({ has_more: boolean, next_after: { type: 'string' } }), + object({ has_more: boolean, next_page: { type: 'integer' } }), + ], + }; + + it('keeps the override: whichever variant is sent, the field is a boolean (the issue)', async () => { + // Detection reads nothing inside the union, so the override is the only way to name it. + expect(onlyOperation(await parseJson(cursorSpec(issueMeta))).pagination?.endOfIteration).toBeUndefined(); + const ir = await parseJson(cursorSpec(issueMeta, { endOfIteration })); + expect(onlyOperation(ir).pagination).toMatchObject({ strategy: 'cursor', param: 'after', endOfIteration }); + expect(warn).not.toHaveBeenCalled(); + expectSound(ir, []); + }); + + it('keeps it through $ref variants of an anyOf beside a null variant', async () => { + const components = { + schemas: { + AfterMeta: object({ has_more: boolean, next_after: { type: 'string' } }), + PageMeta: object({ has_more: { $ref: '#/components/schemas/Flag' }, next_page: { type: 'integer' } }), + Flag: { type: ['boolean', 'null'] }, + }, + }; + const meta = { + anyOf: [ + { $ref: '#/components/schemas/AfterMeta' }, + { $ref: '#/components/schemas/PageMeta' }, + { type: 'null' }, + ], + }; + const ir = await parseJson(cursorSpec(meta, { endOfIteration }, components)); + expect(onlyOperation(ir).pagination?.endOfIteration).toEqual(endOfIteration); + expect(warn).not.toHaveBeenCalled(); + expectSound(ir, Object.keys(components.schemas)); + }); + + it('rejects a field only some variants declare, or one a variant types otherwise', async () => { + const cases: Array<[unknown, string]> = [ + [ + { oneOf: [object({ has_more: boolean }), object({ next_page: { type: 'integer' } })] }, + 'endOfIteration path "meta.has_more" does not resolve to a field in the response', + ], + [ + { oneOf: [object({ has_more: boolean }), object({ has_more: { type: 'string' } })] }, + 'endOfIteration path "meta.has_more" is not a boolean field, as kind "hasMore" requires', + ], + ]; + for (const [meta, problem] of cases) { + const bare = onlyOperation(await parseJson(cursorSpec(meta))); + expect(warn).not.toHaveBeenCalled(); + const overridden = onlyOperation(await parseJson(cursorSpec(meta, { endOfIteration }))); + rejected(problem); + expect(overridden.pagination).toEqual(bare.pagination); + warn.mockClear(); + } + }); + }); + it('checks the stop field against the primary response, not another 2xx body', async () => { const hasMore = { ...totalEnvelope, properties: { ...totalEnvelope.properties, more: { type: 'boolean' } } }; const ok = (schema: unknown) => ({ description: 'ok', content: { 'application/json': { schema } } }); diff --git a/test/parser/pagination-page.test.ts b/test/parser/pagination-page.test.ts index b903a27f..8381ca89 100644 --- a/test/parser/pagination-page.test.ts +++ b/test/parser/pagination-page.test.ts @@ -1059,4 +1059,64 @@ describe('bodyEndOfIterationProblem — an explicit stop field, read where a det ); expect(bodyEndOfIterationProblem({ kind: 'totalCount', path: 'total' }, schema)).toBeUndefined(); }); + + it('walks every variant of a oneOf/anyOf on the path, each of which must declare a field of the fitting type (#437)', () => { + const object = (properties: Record): SchemaObject => ({ type: 'object', properties }); + const boolean: SchemaObject = { type: 'boolean' }; + const integer: SchemaObject = { type: 'integer' }; + const schemas: Record = { + PageMeta: object({ has_more: boolean, page: object({ last: boolean }), total: { type: 'number' } }), + }; + const pageMeta: SchemaObject = { $ref: '#/components/schemas/PageMeta' }; + const problem = (meta: SchemaObject, kind: 'hasMore' | 'totalCount', path: string) => + bodyEndOfIterationProblem( + { kind, path }, + object({ data: { type: 'array', items: { type: 'string' } }, meta }), + schemas, + ); + const missing = (path: string) => `endOfIteration path "${path}" does not resolve to a field in the response`; + + // Every variant declares it: a `$ref` or inline, beside a `null` variant, + // under `anyOf`, a union again one segment down, an integer beside a + // number for a count, and `properties` written beside the `oneOf`, which + // every inline variant carries. + expect(problem({ oneOf: [pageMeta, object({ has_more: boolean })] }, 'hasMore', 'meta.has_more')).toBeUndefined(); + expect( + problem( + { anyOf: [object({ has_more: { type: ['boolean', 'null'] } }), pageMeta, { type: 'null' }] }, + 'hasMore', + 'meta.has_more', + ), + ).toBeUndefined(); + const pages = (...variants: SchemaObject[]) => ({ oneOf: [pageMeta, object({ page: { oneOf: variants } })] }); + expect( + problem(pages(object({ last: boolean }), object({ last: boolean, size: integer })), 'hasMore', 'meta.page.last'), + ).toBeUndefined(); + expect(problem({ oneOf: [pageMeta, object({ total: integer })] }, 'totalCount', 'meta.total')).toBeUndefined(); + expect( + problem( + { properties: { has_more: boolean }, oneOf: [object({ a: integer }), object({ b: integer })] }, + 'hasMore', + 'meta.has_more', + ), + ).toBeUndefined(); + + // A field only some variants declare resolves to no field; one a variant + // types otherwise is not of the kind's type; a union of nulls declares none. + expect(problem({ oneOf: [pageMeta, object({ next: integer })] }, 'hasMore', 'meta.has_more')).toBe( + missing('meta.has_more'), + ); + expect(problem(pages(object({ last: boolean }), object({ size: integer })), 'hasMore', 'meta.page.last')).toBe( + missing('meta.page.last'), + ); + expect(problem({ oneOf: [{ type: 'null' }, { type: 'null' }] }, 'hasMore', 'meta.has_more')).toBe( + missing('meta.has_more'), + ); + expect(problem({ oneOf: [pageMeta, object({ has_more: { type: 'string' } })] }, 'hasMore', 'meta.has_more')).toBe( + 'endOfIteration path "meta.has_more" is not a boolean field, as kind "hasMore" requires', + ); + expect(problem({ oneOf: [pageMeta, object({ total: { type: 'string' } })] }, 'totalCount', 'meta.total')).toBe( + 'endOfIteration path "meta.total" is not an integer or number field, as kind "totalCount" requires', + ); + }); }); From 315394f9f443ccaa0e87a9a87304f433d867f3a2 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 16:42:23 -0400 Subject: [PATCH 17/59] fix(parser): walk each schema a stop field path reaches once per segment fieldsAtPath recursed once per variant and concatenated every result, so a path through a component that several variants reach doubled the work and the results at each segment: `node.child...child.has_more` through a `Node: { oneOf: [{ child: Node, ... }, { child: Node, ... }] }` threw a RangeError at 40 segments. The walk now goes one segment at a time over the schemas reached so far, each counted once by its `$ref` (else by the schema itself), so it is linear in the path. Raised by Greptile on #463. --- src/parser/pagination.ts | 54 +++++++++++++++++++---------- test/parser/pagination-page.test.ts | 21 +++++++++++ 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/parser/pagination.ts b/src/parser/pagination.ts index 6ebab60b..026b19d9 100644 --- a/src/parser/pagination.ts +++ b/src/parser/pagination.ts @@ -376,7 +376,7 @@ export function endOfIterationProblem( properties: Record | undefined, componentSchemas?: Record, ): string | undefined { - const fields = properties && fieldsAtPath({ properties }, end.path.split('.'), componentSchemas); + const fields = properties && fieldsAtPath({ properties }, end.path, componentSchemas); if (!fields) return `endOfIteration path "${end.path}" does not resolve to a field in the response`; if (fields.every((field) => endFieldFits(end.kind, resolveBareSchema(field, componentSchemas)))) return undefined; const expected = end.kind === 'hasMore' ? 'a boolean' : 'an integer or number'; @@ -384,7 +384,7 @@ export function endOfIterationProblem( } /** - * Every field `segments` lands on below `schema`. Each segment is looked up + * Every field `path` lands on below `schema`. Each segment is looked up * among the properties of the object the path has reached, as * `resolveSchemaPath` looks it up — through `$ref`s, `allOf` members and * nullable wrappers — and, when that object does not declare it but is a @@ -394,28 +394,46 @@ export function endOfIterationProblem( * a `oneOf` of two objects that each declare `has_more`). `undefined` when * the object, or any of its variants, lacks the segment. A variant that is * itself such a union declares no properties of its own - * (`objectProperties`), so it lacks every segment. + * (`objectProperties`), so it lacks every segment. The walk goes one + * segment at a time over every schema reached so far, each counted once — + * by its `$ref`, else by the schema itself — so a component several + * variants reach is walked on once, however many variants reach it and + * however long the path. */ function fieldsAtPath( schema: SchemaObject, - segments: readonly string[], + path: string, componentSchemas?: Record, ): SchemaObject[] | undefined { - if (segments.length === 0) return [schema]; - const [segment, ...rest] = segments; - const own = objectProperties(schema, componentSchemas)[segment]; - if (own) return fieldsAtPath(own, rest, componentSchemas); - const bare = resolveBareSchema(schema, componentSchemas); - const variants = (bare.oneOf ?? bare.anyOf ?? []).filter((variant) => !isNullSchema(variant)); - if (variants.length === 0) return undefined; - const fields: SchemaObject[] = []; - for (const variant of variants) { - const field = objectProperties(variant, componentSchemas)[segment]; - const reached = field && fieldsAtPath(field, rest, componentSchemas); - if (!reached) return undefined; - fields.push(...reached); + let reached = [schema]; + for (const segment of path.split('.')) { + const next = new Map(); + for (const node of reached) { + const fields = fieldsNamed(node, segment, componentSchemas); + if (!fields) return undefined; + for (const field of fields) next.set(field.$ref ?? field, field); + } + reached = [...next.values()]; } - return fields; + return reached; +} + +/** + * The fields `segment` names in `node` (`fieldsAtPath`): its own property of + * that name, else that property in each non-null variant of the + * `oneOf`/`anyOf` `node` is. `undefined` when `node`, or any variant, lacks it. + */ +function fieldsNamed( + node: SchemaObject, + segment: string, + componentSchemas?: Record, +): SchemaObject[] | undefined { + const own = objectProperties(node, componentSchemas)[segment]; + if (own) return [own]; + const bare = resolveBareSchema(node, componentSchemas); + const variants = (bare.oneOf ?? bare.anyOf ?? []).filter((variant) => !isNullSchema(variant)); + const fields = variants.map((variant) => objectProperties(variant, componentSchemas)[segment]); + return fields.length > 0 && fields.every((field) => field !== undefined) ? fields : undefined; } function hasType(schema: SchemaObject, type: string): boolean { diff --git a/test/parser/pagination-page.test.ts b/test/parser/pagination-page.test.ts index 8381ca89..47ec26a7 100644 --- a/test/parser/pagination-page.test.ts +++ b/test/parser/pagination-page.test.ts @@ -1119,4 +1119,25 @@ describe('bodyEndOfIterationProblem — an explicit stop field, read where a det 'endOfIteration path "meta.total" is not an integer or number field, as kind "totalCount" requires', ); }); + + it('walks a component several variants reach once per segment, however long the path (Greptile)', () => { + // `Node` is a `oneOf` of two objects that each declare `child: Node`, so + // a walk per variant would double at every `child`. + const variant = (extra: Record): SchemaObject => ({ + type: 'object', + properties: { child: { $ref: '#/components/schemas/Node' }, has_more: { type: 'boolean' }, ...extra }, + }); + const schemas: Record = { + Node: { oneOf: [variant({}), variant({ size: { type: 'integer' } })] }, + }; + const properties: Record = { + data: { type: 'array', items: { type: 'string' } }, + node: { $ref: '#/components/schemas/Node' }, + }; + const path = ['node', ...Array(40).fill('child'), 'has_more'].join('.'); + expect(endOfIterationProblem({ kind: 'hasMore', path }, properties, schemas)).toBeUndefined(); + expect(endOfIterationProblem({ kind: 'totalCount', path }, properties, schemas)).toBe( + `endOfIteration path "${path}" is not an integer or number field, as kind "totalCount" requires`, + ); + }); }); From 9b1c7b245d52bc1e25a09e269862d900892c604f Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 10:03:08 -0400 Subject: [PATCH 18/59] fix(parser): walk a oneOf that sits among allOf members for a stop field fieldsNamed read variants only from a oneOf/anyOf written on the object itself. A union that is one `allOf` member beside another (`meta: { allOf: [{ oneOf: [A, B] }, { properties: { trace_id } }] }`, where A and B each declare a boolean `has_more`) gave no variants, so the `meta.has_more` override was dropped as resolving to no field, though the IR's `Meta` declares `has_more: boolean` and every instance carries every member. Without the `trace_id` member it was accepted, because resolveBareSchema unwraps a lone member. collectObjectProperties, the one walk through an object's `allOf` members, now also lists every oneOf/anyOf it meets (the object itself first, then the members in the order walked), and fieldsNamed reads the first of them whose every non-null variant declares the segment. Every member applies whichever variant is sent, so one union that declares the field in every variant is enough. A field only some variants of each union declare still resolves to no field, and one a variant types otherwise still fails the type check. The object's own union is read first, so every path accepted before lands on the same fields. A variant's own unions are still not walked. Addresses MichaelDeBoey's review on #463: finding 1 and the inline thread on src/parser/pagination.ts:434. The ir-types.md paragraph and the JSDoc name the allOf case. --- docs/architecture/ir-types.md | 2 +- src/parser/pagination.ts | 56 +++++++++++++++---------- test/parser/pagination-override.test.ts | 12 ++++++ test/parser/pagination-page.test.ts | 42 +++++++++++++++++++ 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index c705089e..0ecb89fa 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -332,7 +332,7 @@ An explicit `dataPath` is resolved against the primary response (the lowest 2xx An explicit `dataPath` that names another array than the one the parser would unwrap (`{ data, result: { items }, total }` with `dataPath: 'result.items'`) means detection was wrong for the operation, so the envelope is kept as a wrapper, exactly as `strategy: 'none'` keeps it: `response` is the wrapper model (what the wire returns), `dataPath` says where the items are in it, `itemType` is their type, and `envelopeFields` is absent since the wrapper carries the companions; the wrapper's stop field is still read (`total` → `endOfIteration`), also when the override is what creates the iterator — no paging parameter recognized, say a custom `param: 'p'` — rather than correcting a detected one. The stop field is read from whatever wrapper the override iterates, not only from an envelope detection recognized: a `$ref` list response (`PetList: { data: Pet[], has_more }` returned as `$ref: PetList` with `dataPath: 'data'` → `has_more`) and an inline body detection does not recognize because no top-level array sits beside a stop field (`{ result: { items: Pet[] }, total_pages }` with `dataPath: 'result.items'` → `total_pages`) are read exactly as a recognized envelope is — through the `$ref`, every `allOf` member however deep, and a nullable wrapper around the body or around any member (`{ allOf: [{ oneOf: [{ $ref: Base }, { type: 'null' }] }, { properties: { data } }] }` reads `Base`'s `has_more`, as does a `$ref` chain ending on such a wrapper) — keyed by the full path, so the array the override names is never taken for a stop field. A nullable `allOf` member applies as its one non-null variant for the model too, as a component and as an inline body: the wrapper's model carries `Base`'s `has_more` beside `data`, and a recognized envelope's `envelopeFields` carry what such a member declares (`trace_id` from a nullable `Meta`), so the fields an iterator stops on are fields the model declares. The array may sit under a nullable wrapper of any form — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, `oneOf: [{ type: 'array' }, { type: 'null' }]`, a nullable `$ref` to an array component — and the document is read through the same wrappers the IR walk reads through, so whether the path reaches an array is decided once, the same way, before the body is classified and when its items are typed. A `dataPath` that restates the detected one changes nothing, and one that reaches no array leaves the envelope unwrapped and is ignored with a warning. Because a kept wrapper is not a recognized envelope, such an override needs a strategy from somewhere — its own, or the operation's query parameters. Whether an override can apply at all is decided before the response is read, against the pagination the query parameters alone yield (a parameter the operation does not declare never makes one usable), so an override that is rejected leaves the response exactly as detection reads it — kept as a plain response model when no declared parameter pages it, with the rejection as the one warning. When the override is the only possible source of an iterator (no query parameter yields one) and a 2xx body cannot satisfy it — its `dataPath` reaches no array in that body, or its `endOfIteration` resolves in it to no field of the fitting type (below) — that body's envelope is kept the same way rather than unwrapped for the iterator the dropped override would have made. `dataDescription` survives an override that keeps the detected array, and the stop field beside a nested array an override selects (`result.total_pages` next to `result.items`, `pagination.has_more` next to `pagination.items`) is read first, whatever the parent is called, then one beside each object further up the path, nearest first (`result.total_pages` next to `result.page` for `result.page.items`); a stop field wrapped in a nullable `oneOf` (`[{ type: 'boolean' }, { type: 'null' }]`), behind a `$ref`, or behind a lone `allOf` member that types it (`has_more: { allOf: [{ $ref: Flag }], description }`, or an inline `{ allOf: [{ type: 'boolean' }] }`, read as the member exactly as the IR types the field; an `allOf` of several members, of bare limits beside the field's own `type`, or of a lone `$ref` member with a `required` beside it that the component's properties can satisfy — the merged model the IR builds — is left as written), however the three alternate, is read like one declared with a type list, and a component that refers back to itself (`Flag: { oneOf: [{ $ref: Flag }, { type: 'null' }] }`) ends the walk rather than looping on it, as a stop field or as any other companion. -An explicit `endOfIteration` is checked against the primary response as a detected one is read: its `path` starts from the property map a detected stop field is read from — the body's own properties and every `allOf` member's, through `$ref`s and nullable wrappers, for a recognized envelope and for a wrapper kept under an explicit `dataPath` alike — and is walked from there as an explicit `dataPath` is, through `$ref`s, `allOf` members and nullable wrappers at every further segment, and through each non-null variant of a `oneOf`/`anyOf` on the way that does not declare the next segment itself, since whichever variant the wire carries the field must be there: `meta.has_more` resolves when every variant of a `oneOf` `meta` declares `has_more` (a field detection does not read, so the override is how an iterator stops on it); the field it lands on, in every variant, must have the type the kind is tested against, a boolean for `hasMore` and an integer or number for `totalPages` and `totalCount`, under a type list (`['integer', 'null']`), a nullable wrapper or a lone `allOf` member too, read as a detected stop field is. A path that resolves to no field of the primary response (`nope.missing`, a field only some variants declare, or any path when the primary declares no body), or to a field of another type (`{ kind: 'hasMore', path: 'total' }` on an integer `total`, a field one variant types otherwise, or the data array itself), makes the override unusable; a field only another 2xx body declares does not count. The two fields that depend on the response — `dataPath` and `endOfIteration` — are decided together, once, against the primary response — the lowest 2xx with a typed body, the entry `Operation.response` describes, chosen by the one rule before any success body is classified, so a `200` whose schema describes nothing yields to a typed `201` for both — and every 2xx body is then read with that one decision: a rejected override is handed to none, so a secondary 2xx is read exactly as detection reads it rather than kept as the wrapper the override would have selected; an accepted one keeps the wrapper in each body its `dataPath` reaches an array in. An override a secondary 2xx could satisfy but the primary cannot is rejected, and so is one that asks of the response when there is no primary (no `responses`, no 2xx or 3xx) or the primary declares no body (a 204, or a 3xx when no 2xx is declared). +An explicit `endOfIteration` is checked against the primary response as a detected one is read: its `path` starts from the property map a detected stop field is read from — the body's own properties and every `allOf` member's, through `$ref`s and nullable wrappers, for a recognized envelope and for a wrapper kept under an explicit `dataPath` alike — and is walked from there as an explicit `dataPath` is, through `$ref`s, `allOf` members and nullable wrappers at every further segment, and, where an object on the way does not declare the next segment itself, through each non-null variant of a `oneOf`/`anyOf` that object is or has among its `allOf` members, since whichever variant the wire carries the field must be there: `meta.has_more` resolves when every variant of a `oneOf` `meta` declares `has_more` (a field detection does not read, so the override is how an iterator stops on it), also when that `oneOf` is one `allOf` member of `meta` beside others (`{ allOf: [{ oneOf: [A, B] }, { properties: { trace_id } }] }`), since an instance carries every member whichever variant it is; the field it lands on, in every variant, must have the type the kind is tested against, a boolean for `hasMore` and an integer or number for `totalPages` and `totalCount`, under a type list (`['integer', 'null']`), a nullable wrapper or a lone `allOf` member too, read as a detected stop field is. A path that resolves to no field of the primary response (`nope.missing`, a field only some variants declare, or any path when the primary declares no body), or to a field of another type (`{ kind: 'hasMore', path: 'total' }` on an integer `total`, a field one variant types otherwise, or the data array itself), makes the override unusable; a field only another 2xx body declares does not count. The two fields that depend on the response — `dataPath` and `endOfIteration` — are decided together, once, against the primary response — the lowest 2xx with a typed body, the entry `Operation.response` describes, chosen by the one rule before any success body is classified, so a `200` whose schema describes nothing yields to a typed `201` for both — and every 2xx body is then read with that one decision: a rejected override is handed to none, so a secondary 2xx is read exactly as detection reads it rather than kept as the wrapper the override would have selected; an accepted one keeps the wrapper in each body its `dataPath` reaches an array in. An override a secondary 2xx could satisfy but the primary cannot is rejected, and so is one that asks of the response when there is no primary (no `responses`, no 2xx or 3xx) or the primary declares no body (a 204, or a 3xx when no 2xx is declared). An override that cannot be applied is reported as an `[oagen] Warning:` and ignored, leaving the detected metadata in place. In the order checked: a malformed field or an unknown strategy, per source, so the other source still applies; then, on the merged override, no strategy from any source, a `pageStart` under a strategy other than `page`, no `param` from any source, a resolved `param` or `limitParam` the operation does not declare as a query parameter, a `param` and `limitParam` resolved to one parameter, a `dataPath` that does not reach an array in the response, and an `endOfIteration` that fails the check above. One warning names the override's source, the operation and the first of these met; an unknown key is skipped with a warning of its own. Under `strategy: 'none'` only the shape is checked: there is no iterator for the other fields to serve. `envelopeFields` always comes from the response. diff --git a/src/parser/pagination.ts b/src/parser/pagination.ts index 026b19d9..3c7968b1 100644 --- a/src/parser/pagination.ts +++ b/src/parser/pagination.ts @@ -360,9 +360,10 @@ function endFieldFits(kind: PaginationEndOfIteration['kind'], schema: SchemaObje * field it would not see is not there for an override either. `end.path` is * walked over that map as an explicit `dataPath` is (`resolveSchemaPath`: * through `$ref`s, `allOf` members and nullable wrappers at every further - * segment), and through each non-null variant of a `oneOf`/`anyOf` on the - * way that does not declare the next segment itself (`fieldsAtPath`), so it - * lands on one field per variant; each must fit `end.kind` by the test + * segment), and, where an object on the way does not declare the next + * segment itself, through each non-null variant of a `oneOf`/`anyOf` that + * object is or has among its `allOf` members (`fieldsAtPath`), so it lands + * on one field per variant; each must fit `end.kind` by the test * detection applies to a field it names (`endFieldFits`), under * `resolveBareSchema` as a detected one is. With no `properties` — an * operation whose primary response declares no body — the path resolves to @@ -388,12 +389,15 @@ export function endOfIterationProblem( * among the properties of the object the path has reached, as * `resolveSchemaPath` looks it up — through `$ref`s, `allOf` members and * nullable wrappers — and, when that object does not declare it but is a - * `oneOf`/`anyOf`, in each of its non-null variants instead: whichever - * variant the wire carries, the field must be there, so the path goes on in - * every one and lands on one field per variant (`meta.has_more` with `meta` - * a `oneOf` of two objects that each declare `has_more`). `undefined` when - * the object, or any of its variants, lacks the segment. A variant that is - * itself such a union declares no properties of its own + * `oneOf`/`anyOf` or has one among its `allOf` members (`fieldsNamed`), in + * each non-null variant of that union instead: whichever variant the wire + * carries, the field must be there, so the path goes on in every one and + * lands on one field per variant (`meta.has_more` with `meta` a `oneOf` of + * two objects that each declare `has_more`, or `meta` an `allOf` of that + * `oneOf` and a `trace_id` member, every member applying whichever variant + * is sent). `undefined` when the object does not declare the segment and no + * union it has declares it in every variant. A variant's own unions are not + * walked: one that is itself such a union declares no properties of its own * (`objectProperties`), so it lacks every segment. The walk goes one * segment at a time over every schema reached so far, each counted once — * by its `$ref`, else by the schema itself — so a component several @@ -420,20 +424,25 @@ function fieldsAtPath( /** * The fields `segment` names in `node` (`fieldsAtPath`): its own property of - * that name, else that property in each non-null variant of the - * `oneOf`/`anyOf` `node` is. `undefined` when `node`, or any variant, lacks it. + * that name, else that property in each non-null variant of the first + * `oneOf`/`anyOf` `node` is composed of — `node` itself or an `allOf` + * member, however deep, as `collectObjectProperties` walks them — whose + * every such variant declares it. `undefined` when there is none. */ function fieldsNamed( node: SchemaObject, segment: string, componentSchemas?: Record, ): SchemaObject[] | undefined { - const own = objectProperties(node, componentSchemas)[segment]; + const { properties, unions } = collectObjectProperties(node, componentSchemas); + const own = properties[segment]; if (own) return [own]; - const bare = resolveBareSchema(node, componentSchemas); - const variants = (bare.oneOf ?? bare.anyOf ?? []).filter((variant) => !isNullSchema(variant)); - const fields = variants.map((variant) => objectProperties(variant, componentSchemas)[segment]); - return fields.length > 0 && fields.every((field) => field !== undefined) ? fields : undefined; + for (const union of unions) { + const variants = (union.oneOf ?? union.anyOf ?? []).filter((variant) => !isNullSchema(variant)); + const fields = variants.map((variant) => objectProperties(variant, componentSchemas)[segment]); + if (fields.length > 0 && fields.every((field) => field !== undefined)) return fields; + } + return undefined; } function hasType(schema: SchemaObject, type: string): boolean { @@ -556,16 +565,20 @@ function objectProperties( * and otherwise the first declaration that declares a type types it, a * typeless one only annotating it. (Envelope detection reads the * members' own `properties`; a `oneOf`/`anyOf` member with more than one - * non-null variant declares none, so it contributes nothing here. A body - * that is not an envelope is merged by `mergeAllOfMembers`, which reads such - * a member's variants.) + * non-null variant declares none, so it contributes no property here; it is + * listed in `unions`, with every other `oneOf`/`anyOf` the walk meets — the + * body itself first, then the members in the order walked — for the + * override check, which reads a field every variant of one declares + * (`fieldsNamed`). A body that is not an envelope is merged by + * `mergeAllOfMembers`, which reads such a member's variants.) */ export function collectObjectProperties( schema: SchemaObject, componentSchemas?: Record, -): { properties: Record; required: string[] } { +): { properties: Record; required: string[]; unions: SchemaObject[] } { const properties: Record = {}; const required: string[] = []; + const unions: SchemaObject[] = []; const seenRefs = new Set(); const seenNodes = new WeakSet(); @@ -576,6 +589,7 @@ export function collectObjectProperties( // A reference the walk has followed already, or one nothing resolves: // the walk ends on it, as the resolver did. if (bare.$ref) return; + if (bare.oneOf ?? bare.anyOf) unions.push(bare); for (const sub of bare.allOf ?? []) visit(sub); for (const [key, prop] of Object.entries(bare.properties ?? {})) { if (!prop) continue; @@ -586,7 +600,7 @@ export function collectObjectProperties( }; visit(schema); - return { properties, required }; + return { properties, required, unions }; } // --------------------------------------------------------------------------- diff --git a/test/parser/pagination-override.test.ts b/test/parser/pagination-override.test.ts index 1c25b16f..0b17e9f6 100644 --- a/test/parser/pagination-override.test.ts +++ b/test/parser/pagination-override.test.ts @@ -1143,6 +1143,18 @@ describe('parseSpec — page pagination and x-pagination overrides', () => { expectSound(ir, Object.keys(components.schemas)); }); + it('keeps it when the oneOf is one of several allOf members, as the IR model declares the field (MichaelDeBoey)', async () => { + // Every instance carries every member, so whichever variant is sent, `has_more` is a boolean. + const meta = { allOf: [issueMeta, object({ trace_id: { type: 'string' } })] }; + expect(onlyOperation(await parseJson(cursorSpec(meta))).pagination?.endOfIteration).toBeUndefined(); + const ir = await parseJson(cursorSpec(meta, { endOfIteration })); + expect(onlyOperation(ir).pagination).toMatchObject({ strategy: 'cursor', param: 'after', endOfIteration }); + expect(warn).not.toHaveBeenCalled(); + expectSound(ir, []); + const hasMore = ir.models.find((m) => m.name === 'Meta')?.fields.find((f) => f.name === 'has_more'); + expect(hasMore?.type).toEqual({ kind: 'primitive', type: 'boolean' }); + }); + it('rejects a field only some variants declare, or one a variant types otherwise', async () => { const cases: Array<[unknown, string]> = [ [ diff --git a/test/parser/pagination-page.test.ts b/test/parser/pagination-page.test.ts index 47ec26a7..8074d8b2 100644 --- a/test/parser/pagination-page.test.ts +++ b/test/parser/pagination-page.test.ts @@ -1140,4 +1140,46 @@ describe('bodyEndOfIterationProblem — an explicit stop field, read where a det `endOfIteration path "${path}" is not an integer or number field, as kind "totalCount" requires`, ); }); + + it('walks a oneOf/anyOf that is one of several allOf members, which every instance carries beside the others (MichaelDeBoey)', () => { + const object = (properties: Record): SchemaObject => ({ type: 'object', properties }); + const boolean: SchemaObject = { type: 'boolean' }; + const after = object({ has_more: boolean, next_after: { type: 'string' }, page: object({ last: boolean }) }); + const numbered = object({ has_more: boolean, next_page: { type: 'integer' }, page: object({ last: boolean }) }); + const trace = object({ trace_id: { type: 'string' } }); + const beside = (union: SchemaObject): SchemaObject => ({ allOf: [union, trace] }); + const schemas: Record = { + MetaUnion: { anyOf: [after, numbered] }, + Meta: beside({ oneOf: [after, numbered] }), + }; + const problem = (meta: SchemaObject, path = 'meta.has_more') => + bodyEndOfIterationProblem( + { kind: 'hasMore', path }, + object({ data: { type: 'array', items: { type: 'string' } }, meta }), + schemas, + ); + const missing = (path: string) => `endOfIteration path "${path}" does not resolve to a field in the response`; + + // The review's `meta`; the union as a `$ref` to an `anyOf`, beside a + // `null` variant, one `allOf` deeper, or the whole `meta` a component; a + // path one segment further; and a second union that declares the field + // in every variant where the first does not. + expect(problem(beside({ oneOf: [after, numbered] }))).toBeUndefined(); + expect(problem(beside({ $ref: '#/components/schemas/MetaUnion' }))).toBeUndefined(); + expect(problem(beside({ oneOf: [after, numbered, { type: 'null' }] }))).toBeUndefined(); + expect(problem({ allOf: [beside({ oneOf: [after, numbered] }), object({ x: boolean })] })).toBeUndefined(); + expect(problem({ $ref: '#/components/schemas/Meta' })).toBeUndefined(); + expect(problem(beside({ oneOf: [after, numbered] }), 'meta.page.last')).toBeUndefined(); + expect(problem({ allOf: [{ oneOf: [after, trace] }, { oneOf: [after, numbered] }] })).toBeUndefined(); + + // A field only some variants of each union declare resolves to no field; + // one a variant types otherwise is not a boolean. + expect(problem(beside({ oneOf: [after, trace] }))).toBe(missing('meta.has_more')); + expect(problem({ allOf: [{ oneOf: [after, trace] }, { oneOf: [numbered, trace] }] })).toBe( + missing('meta.has_more'), + ); + expect(problem(beside({ oneOf: [after, object({ has_more: { type: 'string' } })] }))).toBe( + 'endOfIteration path "meta.has_more" is not a boolean field, as kind "hasMore" requires', + ); + }); }); From 35f12ef46c9967a893efdca0a78b6d699aa68706 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 17:00:48 -0400 Subject: [PATCH 19/59] fix(parser)!: read a stop field in any spelling Since #363, a list envelope's companion is matched by its name's snake_case form, so `PageInfo`, `page-info` and a top-level `HasMore` or `has-more` mark an envelope. The stop field was still matched against exact names (`has_more`, `hasMore`, `hasNextPage`, ...), so in those envelopes nothing was read as the stop: the envelope was unwrapped and the iterator had no endOfIteration. spellsOneOf now matches a name against a stop-field list by snake_case form, as looksLikePaginationMetadata reads a companion's name. classifyEndField reads each kind's list through it, and list-envelope detection reads END_OF_ITERATION_FIELDS through it, so the two still agree on which companion is a stop field. `HasMore`, `has-more`, `HAS_MORE`, `PageInfo.HasNextPage` and `page-info.has-next-page` are stop fields, each path kept as the document spells it. A name that runs its words together (`pageinfo`, `PAGEINFO`, `hasmore`) is one word in snake_case and stays unread, as before. The docs now say a name has to separate its words. docs/architecture/ir-types.md, docs/architecture/pipeline.md and the JSDoc state both rules. Closes #438 BREAKING CHANGE: a list body whose only pagination companion is a `has_next` or `page_count` stop field in another spelling (`HasNext`, `has-next`, `PageCount`, `page-count`, `HAS_NEXT`) is now recognized as a list envelope. With a paging query parameter, `ListPetsResponse` becomes `string[]` with `dataPath: 'data'` and that field as the stop. Without one, the body stays a plain model and the unpaged-envelope warning is printed. Where an envelope already had a lower-ranked stop, a higher-ranked one in another spelling now wins: `{ data, total, HasMore }` stops on `HasMore` (hasMore), not `total` (totalCount). --- docs/architecture/ir-types.md | 4 +- docs/architecture/pipeline.md | 2 +- src/ir/types.ts | 3 +- src/parser/pagination.ts | 46 +++- src/parser/responses.ts | 19 +- .../pagination-companion-spelling.test.ts | 214 ++++++++++++++++++ 6 files changed, 265 insertions(+), 23 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 0ecb89fa..6ee5d570 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -265,11 +265,11 @@ Every iterator stops on an empty page. `endOfIteration`, when present, lets it s - `totalPages` — an integer count of pages; stop once the pages fetched so far reach it. Under `page` that is `page - pageStart + 1 >= totalPages` (`page >= totalPages` for a 1-based API, `page + 1 >= totalPages` for a 0-based one); under `offset` it is `floor(offset / limit) + 1 >= totalPages`. - `totalCount` — an integer count of items; stop once the items received so far reach it. -It is detected from a recognized envelope — a boolean `has_more`/`hasMore`/`has_next`/`hasNext`/`has_next_page`/`hasNextPage`, an integer `total_pages`/`totalPages`/`page_count`/`pageCount`, or an integer `total`/`total_count`/`totalCount`/`total_items`/`totalItems`/`total_results`/`totalResults` — beside the array first, when the `dataPath` is nested: the other properties of the object the array sits in, under whatever name (`result.total_pages` beside `result.items`, `data.page.has_more` beside `data.page.items`), then those of each object the path passes through on its way there, nearest the array first (`result.total_pages` beside `result.page` for `result.page.items`), every one reached as the path itself is resolved — one property walk (`collectObjectProperties`) reads a wrapper's companions, its stop field, each metadata companion and every segment of a `dataPath`, through `$ref`s, `allOf` members and nullable wrappers, settling a name two members declare as the model does (`combineDeclarations`); then at the top level; then one level down inside each top-level companion object whose name says it is pagination metadata (`meta`, `pagination`, `page_info`, `cursor`, …, in any spelling: the name's snake_case form is matched, so `pageInfo`, `PageInfo` and `page-info` are read exactly as `page_info` is — `{ data, pageInfo: { hasNextPage } }` is `string[]` with a `PageInfo` model and the stop field `pageInfo.hasNextPage` — while a name whose snake_case form carries none of these words (`nameTag`, whose `name_tag` spells `meta` only across a word boundary) marks no envelope, so `{ data, nameTag }` is a plain `ListPetsResponse` model; inline, `$ref`, `allOf`, or a nullable wrapper) — a `stats.total` beside `pagination.page` is not the list's size, and nothing the path passes through, the array or an object on the way to it, is ever a stop field — and the path follows the `dataPath` convention, each segment spelled as the document spells it (`pageInfo.hasNextPage`). The body an explicit `dataPath` keeps as a wrapper (below) is read the same way. When several candidates exist, `hasMore` beats `totalPages`, which beats `totalCount`. List-envelope recognition shares that list of names, so an envelope whose only companion is a stop field in any of these spellings — `{ data, has_more }`, `{ data, hasMore }`, `{ data, hasNext }`, `{ data, pageCount }` — is recognized like any other: unwrapped when a declared query parameter drives the iterator, kept as a plain response model when none does (see the strategy table above). +It is detected from a recognized envelope — a boolean `has_more`/`hasMore`/`has_next`/`hasNext`/`has_next_page`/`hasNextPage`, an integer `total_pages`/`totalPages`/`page_count`/`pageCount`, or an integer `total`/`total_count`/`totalCount`/`total_items`/`totalItems`/`total_results`/`totalResults`, each in any spelling (its snake_case form is matched, as a companion's is below: `HasMore`, `has-more` and `HAS_MORE` are `has_more`, so `{ data, PageInfo: { HasNextPage } }` stops on `PageInfo.HasNextPage`) — beside the array first, when the `dataPath` is nested: the other properties of the object the array sits in, under whatever name (`result.total_pages` beside `result.items`, `data.page.has_more` beside `data.page.items`), then those of each object the path passes through on its way there, nearest the array first (`result.total_pages` beside `result.page` for `result.page.items`), every one reached as the path itself is resolved — one property walk (`collectObjectProperties`) reads a wrapper's companions, its stop field, each metadata companion and every segment of a `dataPath`, through `$ref`s, `allOf` members and nullable wrappers, settling a name two members declare as the model does (`combineDeclarations`); then at the top level; then one level down inside each top-level companion object whose name says it is pagination metadata (`meta`, `pagination`, `page_info`, `cursor`, …, in any spelling: the name's snake_case form is matched, so `pageInfo`, `PageInfo` and `page-info` are read exactly as `page_info` is — `{ data, pageInfo: { hasNextPage } }` is `string[]` with a `PageInfo` model and the stop field `pageInfo.hasNextPage` — while a name whose snake_case form carries none of these words (`nameTag`, whose `name_tag` spells `meta` only across a word boundary) marks no envelope, so `{ data, nameTag }` is a plain `ListPetsResponse` model; a name is read as several words only where it separates them, by a change of case or a separator such as `_` or `-`, so no pattern or stop-field name of several words is found in a name that runs them together: `pageinfo` and `PAGEINFO` are no `page_info` and `hasmore` is no `has_more`, so `{ data, pageinfo: { hasNextPage } }` and `{ data, hasmore }` are plain `ListPetsResponse` models, while a one-word pattern is still found inside such a name (`listmetadata` holds `meta`); inline, `$ref`, `allOf`, or a nullable wrapper) — a `stats.total` beside `pagination.page` is not the list's size, and nothing the path passes through, the array or an object on the way to it, is ever a stop field — and the path follows the `dataPath` convention, each segment spelled as the document spells it (`pageInfo.hasNextPage`). The body an explicit `dataPath` keeps as a wrapper (below) is read the same way. When several candidates exist, `hasMore` beats `totalPages`, which beats `totalCount`. List-envelope recognition shares that list of names and reads a name the same way, so an envelope whose only companion is a stop field in any spelling — `{ data, has_more }`, `{ data, hasMore }`, `{ data, HasNext }`, `{ data, page-count }` — is recognized like any other: unwrapped when a declared query parameter drives the iterator, kept as a plain response model when none does (see the strategy table above). The `dataPath` is dynamically detected from the response envelope — when `undefined`, emitters should treat the entire response as the data array or use their own default. -When the parser recognizes an inline list envelope — one array property plus a companion that looks like pagination metadata (a stop field — `has_more`, `hasMore`, `total`, … — or a name whose snake_case spelling contains `meta`, `pagination`, `cursor`, `page_info`, `total`, `offset`, …, `pageInfo` and `page_info` alike) — and a query parameter the operation declares drives the iterator (the one detection found, or the one an override names), `Operation.response` becomes the bare array, and `envelopeFields` keeps everything else the envelope carries; an envelope nothing pages stays one plain response model, with no `pagination` (see the strategy table above). The array property is an array in any spelling, and two `allOf` declarations of it — one nullable, one not; one with `items`, one with limits alone — merge as the arrays they wrap (the later `items` when they narrow, the earlier otherwise, `null` admitted when either side admits it): `type: 'array'`, `items` with no `type`, or either under a nullable wrapper — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, or a `oneOf`/`anyOf` with a `null` variant, with or without `items` beside it — in which case `response` is `nullable>` while `itemType` is still the element (`ListEventsItem[] | null`, `dataPath: 'data'`). Its items, whatever their shape, are typed as `Item` (see Inline model naming). `envelopeFields` keeps everything else the envelope carries: the pagination companion (`list_metadata`, `pagination`, `has_more`, `total`, …) and any discriminant such as `object: "list"`. Each entry is an ordinary `Field` (wire name, type, `required`, description), so `dataPath` + `itemType` + `envelopeFields` together describe the full shape of what the endpoint returns, and an emitter can both type the response and read the page details it needs for the next request: +When the parser recognizes an inline list envelope — one array property plus a companion that looks like pagination metadata (a stop field in any spelling — `has_more`, `HasMore`, `total`, … — or a name whose snake_case spelling contains `meta`, `pagination`, `cursor`, `page_info`, `total`, `offset`, …, `pageInfo` and `page_info` alike) — and a query parameter the operation declares drives the iterator (the one detection found, or the one an override names), `Operation.response` becomes the bare array, and `envelopeFields` keeps everything else the envelope carries; an envelope nothing pages stays one plain response model, with no `pagination` (see the strategy table above). The array property is an array in any spelling, and two `allOf` declarations of it — one nullable, one not; one with `items`, one with limits alone — merge as the arrays they wrap (the later `items` when they narrow, the earlier otherwise, `null` admitted when either side admits it): `type: 'array'`, `items` with no `type`, or either under a nullable wrapper — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, or a `oneOf`/`anyOf` with a `null` variant, with or without `items` beside it — in which case `response` is `nullable>` while `itemType` is still the element (`ListEventsItem[] | null`, `dataPath: 'data'`). Its items, whatever their shape, are typed as `Item` (see Inline model naming). `envelopeFields` keeps everything else the envelope carries: the pagination companion (`list_metadata`, `pagination`, `has_more`, `total`, …) and any discriminant such as `object: "list"`. Each entry is an ordinary `Field` (wire name, type, `required`, description), so `dataPath` + `itemType` + `envelopeFields` together describe the full shape of what the endpoint returns, and an emitter can both type the response and read the page details it needs for the next request: ```typescript // GET /customers?page=&limit= → { customers: Customer[] ("The customers on this page."), pagination: { page, totalPages } } diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index f7248aa8..7a5ed903 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -33,7 +33,7 @@ Entry point: `parse.ts` → `parseSpec(specPath: string): Promise` 1. **refs.ts** — Loads the spec file and bundles it with `@redocly/openapi-core` (`dereference: false`): external `$ref`s are inlined, internal `$ref`s stay in place for the parser to resolve at the site that reads them (`components/schemas` through the schema walker; `components/parameters`, `requestBodies`, `headers`, `responses` and `pathItems` by the operation, webhook or response that declares the ref), and a `$ref` nothing in the document answers fails the parse (`Failed to parse spec: Can't resolve $ref`) 2. **schemas.ts** — Walks `components.schemas`, extracts `Model[]` and `Enum[]` (an array component yields no model: a `$ref` to it is typed as the array, and only what its items reference becomes a model or enum) 3. **operations.ts** — Walks `paths` (an entry written as a `$ref` into `components/pathItems` is first resolved by **path-item-refs.ts**, the resolver **webhooks.ts** uses for a `webhooks` entry; one that cannot be resolved is warned about and skipped), groups endpoints into `Service[]` with `Operation[]` -4. **pagination.ts** — Detects the pagination strategy from the query parameters the operation declares (a cursor parameter; an offset or page parameter plus a page size), reads the first page number (`pageStart`) from the page parameter's schema and the end-of-iteration field (`has_more`, `total_pages`, `total`, … in any spelling) from the response envelope, and applies an `x-pagination` extension or an `operationHints` entry over detection, field by field (see [Overriding detection](ir-types.md#overriding-detection)); `responses.ts` recognizes a list envelope in the response and unwraps it only when such a parameter, or an override naming a declared one, drives the iterator — an envelope nothing pages stays a plain response model, with a warning +4. **pagination.ts** — Detects the pagination strategy from the query parameters the operation declares (a cursor parameter; an offset or page parameter plus a page size), reads the first page number (`pageStart`) from the page parameter's schema and the end-of-iteration field (`has_more`, `total_pages`, `total`, … in any spelling that separates the words: `HasMore` and `has-more` are `has_more`, `hasmore` is none) from the response envelope, and applies an `x-pagination` extension or an `operationHints` entry over detection, field by field (see [Overriding detection](ir-types.md#overriding-detection)); `responses.ts` recognizes a list envelope in the response and unwraps it only when such a parameter, or an override naming a declared one, drives the iterator — an envelope nothing pages stays a plain response model, with a warning 5. **webhooks.ts** — Walks the top-level `webhooks` map (OpenAPI 3.1) into `Webhook[]`, one per HTTP method of each entry, after the operations: the payload and parameter models it mints (`Payload`, `XMeta`) step around every model and enum name the components and operations decided. `x-webhooks` is not read, and a document that carries one is warned about 6. **normalize-inline-models.ts**, **collect-inline-enums.ts** — Merge the inline models every pass minted (same-named copies folded onto one definition or qualified by their site, `…Json` models collapsed onto their base), then lift every inline enum into `spec.enums`; `servers.ts` and `security.ts` read the document's `servers` and `security`, and `parse.ts` reads `components.securitySchemes` into `auth` and validates that every model reference resolves diff --git a/src/ir/types.ts b/src/ir/types.ts index 4fdedffb..914f1622 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -724,7 +724,8 @@ export interface PaginationMeta { * Where the response says the iteration is over (see * `PaginationEndOfIteration`). Detected from a recognized list envelope * when it carries an obvious field — a boolean `has_more`/`hasMore`, an - * integer `total_pages`/`totalPages`, or an integer `total`/`total_count` — + * integer `total_pages`/`totalPages`, or an integer `total`/`total_count`, + * each in any spelling that separates its words (`HasMore`, `has-more`) — * beside the array a nested `dataPath` names (`result.total_pages`), beside * each object the path passes through on its way there, nearest the array * first, at the top level, or one level down inside a pagination companion diff --git a/src/parser/pagination.ts b/src/parser/pagination.ts index 3c7968b1..cea1cf2a 100644 --- a/src/parser/pagination.ts +++ b/src/parser/pagination.ts @@ -203,10 +203,10 @@ export const TOTAL_COUNT_FIELDS: readonly string[] = [ 'totalResults', ]; /** - * Every companion name that can end an iteration. List-envelope detection - * treats a companion with one of these names as pagination metadata, and - * `detectEndOfIteration` reads it, so the two can never disagree about which - * envelopes carry a stop field. + * Every companion name that can end an iteration, each in any spelling + * (`spellsOneOf`). List-envelope detection treats a companion with one of + * these names as pagination metadata, and `detectEndOfIteration` reads it, + * so the two can never disagree about which envelopes carry a stop field. */ export const END_OF_ITERATION_FIELDS: readonly string[] = [ ...HAS_MORE_FIELDS, @@ -243,7 +243,10 @@ export const PAGINATION_METADATA_PATTERNS: readonly string[] = [ * name is read by its words, so case and separators do not matter — * `page_info`, `pageInfo`, `PageInfo` and `page-info` are all `page_info` — * while a pattern never straddles a word boundary: `nameTag` is `name_tag`, - * which does not contain `meta`. The one recognizer for list-envelope + * which does not contain `meta`. A name that runs its words together is one + * word, so a pattern of several is not found in it (`pageinfo` and + * `PAGEINFO` are no `page_info`), while a one-word pattern still is + * (`listmetadata` holds `meta`). The one recognizer for list-envelope * detection and the nested stop-field scan, so the two cannot disagree * about which companions are pagination metadata. */ @@ -252,6 +255,23 @@ export function looksLikePaginationMetadata(name: string): boolean { return PAGINATION_METADATA_PATTERNS.some((pattern) => spelled.includes(pattern)); } +/** + * Whether `name` is one of `fields` in some spelling: read by its words, in + * the snake_case spelling `looksLikePaginationMetadata` reads a companion's + * name in (`toSnakeCase`), it is a listed name read the same way. Case and + * separators do not matter, so `has_more`, `hasMore`, `HasMore`, `has-more` + * and `HAS_MORE` are all `has_more`, while a name that runs its words + * together is one word: `hasmore` is no `has_more` and `totalpages` no + * `total_pages`. The one test of a stop field's name, for list-envelope + * detection (`END_OF_ITERATION_FIELDS`) and for `detectEndOfIteration` + * (each kind's list), so the two cannot disagree about which companion is + * a stop field. + */ +export function spellsOneOf(name: string, fields: readonly string[]): boolean { + const spelled = toSnakeCase(name); + return fields.some((field) => toSnakeCase(field) === spelled); +} + /** * Find the envelope field that ends an iteration, when the envelope declares * an obvious one. `properties` are the envelope's merged properties and @@ -276,7 +296,10 @@ export function looksLikePaginationMetadata(name: string): boolean { * or an object on the way to it — is ever a stop field, whatever it is * called. The path follows the `dataPath` convention — `has_more`, * `result.total_pages` or `pagination.totalPages` — and spells each - * segment as the document does (`pageInfo.hasNextPage`). When several + * segment as the document does (`pageInfo.hasNextPage`). A candidate is a + * stop field by its name in any spelling (`spellsOneOf`), so `HasMore` and + * `PageInfo.HasNextPage` are read as `has_more` and + * `page_info.has_next_page` are, each kept as written. When several * candidates exist, a `hasMore` boolean wins over a `totalPages` integer, * which wins over a `totalCount` integer; within one kind the first in scan * order wins. A candidate whose type does not fit its kind (a string @@ -327,13 +350,14 @@ function classifyEndField( schema: SchemaObject, componentSchemas?: Record, ): PaginationEndOfIteration['kind'] | undefined { - // The name decides the kind, and only a named stop field has its schema - // read: a companion of any other name is never resolved at all. - const kind: PaginationEndOfIteration['kind'] | undefined = HAS_MORE_FIELDS.includes(name) + // The name decides the kind, in any spelling (`spellsOneOf`), and only a + // named stop field has its schema read: a companion of any other name is + // never resolved at all. + const kind: PaginationEndOfIteration['kind'] | undefined = spellsOneOf(name, HAS_MORE_FIELDS) ? 'hasMore' - : TOTAL_PAGES_FIELDS.includes(name) + : spellsOneOf(name, TOTAL_PAGES_FIELDS) ? 'totalPages' - : TOTAL_COUNT_FIELDS.includes(name) + : spellsOneOf(name, TOTAL_COUNT_FIELDS) ? 'totalCount' : undefined; if (!kind) return undefined; diff --git a/src/parser/responses.ts b/src/parser/responses.ts index fd103ba1..7482fd1c 100644 --- a/src/parser/responses.ts +++ b/src/parser/responses.ts @@ -8,6 +8,7 @@ import { looksLikePaginationMetadata, resolveBareSchema, resolveSchemaPath, + spellsOneOf, } from './pagination.js'; import type { SchemaObject } from './schemas.js'; import { @@ -405,15 +406,17 @@ function detectListEnvelope(schema: SchemaObject): ListEnvelopeResult { const arrayPropName = arrayProps[0]; // A companion is pagination metadata when it is one of the stop fields - // the end-of-iteration detector reads (`hasMore`, `hasNext`, `pageCount`, - // …) — one shared list, so an envelope that carries a stop field is - // always recognized — or when its name, read in any spelling, contains - // one of the metadata patterns (`meta`, `pagination`, `cursor`, …: - // `looksLikePaginationMetadata`, the recognizer the nested stop-field - // scan shares, so `pageInfo` marks an envelope exactly as `page_info` - // does and `pageInfo.hasNextPage` is then read as its stop field). + // the end-of-iteration detector reads, in any spelling (`hasMore`, + // `HasNext`, `page-count`, …: `spellsOneOf`, the test the detector + // reads a stop field's name with) — one shared list and one test, so an + // envelope that carries a stop field is always recognized — or when its + // name, read in any spelling, contains one of the metadata patterns + // (`meta`, `pagination`, `cursor`, …: `looksLikePaginationMetadata`, the + // recognizer the nested stop-field scan shares, so `pageInfo` marks an + // envelope exactly as `page_info` does and `pageInfo.hasNextPage` is + // then read as its stop field). const hasPaginationCompanion = nonArrayKeys.some( - (key) => END_OF_ITERATION_FIELDS.includes(key) || looksLikePaginationMetadata(key), + (key) => spellsOneOf(key, END_OF_ITERATION_FIELDS) || looksLikePaginationMetadata(key), ); if (KNOWN_DATA_PATHS.has(arrayPropName) && hasPaginationCompanion) { diff --git a/test/parser/pagination-companion-spelling.test.ts b/test/parser/pagination-companion-spelling.test.ts index 4ff3b512..febc36db 100644 --- a/test/parser/pagination-companion-spelling.test.ts +++ b/test/parser/pagination-companion-spelling.test.ts @@ -54,6 +54,16 @@ describe('looksLikePaginationMetadata', () => { expect(looksLikePaginationMetadata(name)).toBe(false); } }); + + it('reads a name that runs its words together as one word, which no multi-word pattern matches (#438)', () => { + for (const name of ['pageinfo', 'PAGEINFO', 'Pageinfo', 'hasmore', 'HASMORE', 'nextpage', 'previouspage']) { + expect(looksLikePaginationMetadata(name)).toBe(false); + } + // A one-word pattern is still found inside such a name. + for (const name of ['listmetadata', 'LISTMETADATA', 'pagetotal']) { + expect(looksLikePaginationMetadata(name)).toBe(true); + } + }); }); describe('detectEndOfIteration — companion object in any spelling', () => { @@ -130,6 +140,210 @@ describe('classifyAndExtractResponse — envelope companion in any spelling', () expect(result.response).toEqual({ kind: 'model', name: 'ListPetsResponse' }); expect(result.endOfIteration).toBeUndefined(); }); + + it.each<[string, SchemaObject, string]>([ + ['HasMore', boolean, 'hasMore'], + ['has-more', boolean, 'hasMore'], + ['HasNext', boolean, 'hasMore'], + ['has-next', boolean, 'hasMore'], + ['PageCount', integer, 'totalPages'], + ['page-count', integer, 'totalPages'], + ])( + 'unwraps { data, %s } as a list envelope and stops on it, as on its listed spelling (#438)', + (name, schema, kind) => { + const result = classifyAndExtractResponse(envelope({ [name]: schema }), 'ListPetsResponse'); + expect(result.isPaginated).toBe(true); + expect(result.dataPath).toBe('data'); + expect(result.response).toEqual({ kind: 'array', items: { kind: 'primitive', type: 'string' } }); + expect(result.envelopeFields?.map((f) => f.name)).toEqual([name]); + expect(result.endOfIteration).toEqual({ kind, path: name }); + }, + ); + + it.each(['hasmore', 'hasnext', 'HASNEXT', 'pagecount'])( + 'keeps { data, %s } as a plain model: a name that runs its words together is no stop field (#438)', + (name) => { + const result = classifyAndExtractResponse(envelope({ [name]: boolean }), 'ListPetsResponse'); + expect(result.isPaginated).toBe(false); + expect(result.response).toEqual({ kind: 'model', name: 'ListPetsResponse' }); + expect(result.endOfIteration).toBeUndefined(); + }, + ); +}); + +describe('detectEndOfIteration — stop field in any spelling (#438)', () => { + it('reads a top-level stop field in any spelling that separates its words, its path spelled as written', () => { + for (const name of ['has_more', 'hasMore', 'HasMore', 'has-more', 'HAS_MORE']) { + expect(detectEndOfIteration({ data, [name]: boolean }, 'data')).toEqual({ kind: 'hasMore', path: name }); + } + }); + + it('reads a stop field inside a metadata companion, whichever spelling each is written in', () => { + expect(detectEndOfIteration({ data, PageInfo: companion({ HasNextPage: boolean }) }, 'data')).toEqual({ + kind: 'hasMore', + path: 'PageInfo.HasNextPage', + }); + expect(detectEndOfIteration({ data, 'page-info': companion({ 'has-next-page': boolean }) }, 'data')).toEqual({ + kind: 'hasMore', + path: 'page-info.has-next-page', + }); + expect(detectEndOfIteration({ data, pagination: companion({ 'Total-Pages': integer }) }, 'data')).toEqual({ + kind: 'totalPages', + path: 'pagination.Total-Pages', + }); + }); + + it('gives every stop field its kind in any spelling', () => { + const spellings: Record = { + hasMore: ['HasNext', 'has-next', 'HasNextPage', 'HAS_NEXT_PAGE'], + totalPages: ['TotalPages', 'total-pages', 'PageCount', 'page-count', 'PAGE_COUNT'], + totalCount: ['Total', 'TOTAL', 'TotalCount', 'total-count', 'TotalItems', 'total-items', 'TotalResults'], + }; + for (const [kind, names] of Object.entries(spellings)) { + const schema = kind === 'hasMore' ? boolean : integer; + for (const name of names) { + expect(detectEndOfIteration({ data, [name]: schema }, 'data')).toEqual({ kind, path: name }); + } + } + }); + + it('still prefers hasMore over totalPages over a count, whatever each is spelled', () => { + expect(detectEndOfIteration({ data, Total: integer, 'has-more': boolean }, 'data')).toEqual({ + kind: 'hasMore', + path: 'has-more', + }); + expect(detectEndOfIteration({ data, TOTAL: integer, PageCount: integer }, 'data')).toEqual({ + kind: 'totalPages', + path: 'PageCount', + }); + }); + + it('tests the type of a stop field in a new spelling as in a listed one', () => { + expect(detectEndOfIteration({ data, HasMore: integer }, 'data')).toBeUndefined(); + expect(detectEndOfIteration({ data, 'total-pages': { type: 'string' } }, 'data')).toBeUndefined(); + }); + + it('reads no stop field, and looks inside no companion, whose name runs its words together', () => { + for (const name of ['hasmore', 'HASMORE', 'Hasmore', 'hasnextpage']) { + expect(detectEndOfIteration({ data, [name]: boolean }, 'data')).toBeUndefined(); + } + for (const name of ['totalpages', 'TOTALCOUNT', 'pagecount']) { + expect(detectEndOfIteration({ data, [name]: integer }, 'data')).toBeUndefined(); + } + for (const name of ['pageinfo', 'PAGEINFO']) { + expect(detectEndOfIteration({ data, [name]: companion({ hasNextPage: boolean }) }, 'data')).toBeUndefined(); + } + }); +}); + +describe('parseSpec — the issue #438 repro end to end', () => { + const dirs: string[] = []; + afterAll(async () => { + await Promise.all(dirs.map((d) => fs.rm(d, { recursive: true, force: true }))); + }); + + /** `GET /pets` (`listPets`) with an `after` query parameter, returning `{ data: string[] }` plus `companions`. */ + async function listPets(companions: Record): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oagen-438-')); + dirs.push(dir); + const file = path.join(dir, 'repro.json'); + const schema = { + type: 'object', + properties: { data: { type: 'array', items: { type: 'string' } }, ...companions }, + }; + await fs.writeFile( + file, + JSON.stringify({ + openapi: '3.1.0', + info: { title: 'repro', version: '1' }, + paths: { + '/pets': { + get: { + operationId: 'listPets', + parameters: [{ name: 'after', in: 'query', schema: { type: 'string' } }], + responses: { '200': { description: 'OK', content: { 'application/json': { schema } } } }, + }, + }, + }, + }), + ); + return parseSpec(file); + } + + /** The issue's projection: the response, then the iterator's `dataPath`, envelope field names and stop field. */ + function projection(spec: ApiSpec) { + const [{ pagination, response }] = spec.services.flatMap(({ operations }) => operations); + const { dataPath, endOfIteration, envelopeFields = [] } = pagination!; + const type = + response.kind === 'model' + ? response.name + : response.kind === 'array' && response.items.kind === 'primitive' + ? `${response.items.type}[]` + : response.kind; + return [type, dataPath, envelopeFields.map(({ name }) => name), endOfIteration]; + } + + const flag = { type: 'boolean' }; + const pageInfo = (name: string, stop: string) => ({ [name]: { type: 'object', properties: { [stop]: flag } } }); + + it('reads a stop field in every spelling that separates its words, and no name that runs them together', async () => { + const documents: Record> = { + camel: pageInfo('pageInfo', 'hasNextPage'), + pascal: pageInfo('PageInfo', 'HasNextPage'), + kebab: pageInfo('page-info', 'has-next-page'), + 'top-pascal': { HasMore: flag }, + 'top-kebab': { 'has-more': flag }, + flat: pageInfo('pageinfo', 'hasNextPage'), + 'flat-upper': pageInfo('PAGEINFO', 'hasNextPage'), + 'top-flat': { hasmore: flag }, + }; + const projected: Record = {}; + for (const [file, companions] of Object.entries(documents)) + projected[file] = projection(await listPets(companions)); + expect(projected).toEqual({ + camel: ['string[]', 'data', ['pageInfo'], { kind: 'hasMore', path: 'pageInfo.hasNextPage' }], + pascal: ['string[]', 'data', ['PageInfo'], { kind: 'hasMore', path: 'PageInfo.HasNextPage' }], + kebab: ['string[]', 'data', ['page-info'], { kind: 'hasMore', path: 'page-info.has-next-page' }], + 'top-pascal': ['string[]', 'data', ['HasMore'], { kind: 'hasMore', path: 'HasMore' }], + 'top-kebab': ['string[]', 'data', ['has-more'], { kind: 'hasMore', path: 'has-more' }], + flat: ['ListPetsResponse', undefined, [], undefined], + 'flat-upper': ['ListPetsResponse', undefined, [], undefined], + 'top-flat': ['ListPetsResponse', undefined, [], undefined], + }); + }); + + it('names the stop field by the wire names the IR carries, so the path walks the envelope fields and model', async () => { + for (const [name, stop] of [ + ['PageInfo', 'HasNextPage'], + ['page-info', 'has-next-page'], + ]) { + const spec = await listPets(pageInfo(name, stop)); + const [op] = spec.services.flatMap((s) => s.operations); + expect(op.pagination?.endOfIteration).toEqual({ kind: 'hasMore', path: `${name}.${stop}` }); + expect(op.pagination?.envelopeFields).toEqual([ + { name, type: { kind: 'model', name: 'PageInfo' }, required: false }, + ]); + expect(spec.models.find((m) => m.name === 'PageInfo')?.fields).toEqual([ + { name: stop, type: { kind: 'primitive', type: 'boolean' }, required: false }, + ]); + } + }); + + it('unwraps an envelope whose only companion is a stop field in a new spelling, as one in a listed spelling', async () => { + expect(projection(await listPets({ HasNext: flag }))).toEqual([ + 'string[]', + 'data', + ['HasNext'], + { kind: 'hasMore', path: 'HasNext' }, + ]); + expect(projection(await listPets({ 'page-count': { type: 'integer' } }))).toEqual([ + 'string[]', + 'data', + ['page-count'], + { kind: 'totalPages', path: 'page-count' }, + ]); + expect(projection(await listPets({ hasnext: flag }))).toEqual(['ListPetsResponse', undefined, [], undefined]); + }); }); describe('parseSpec — the issue #311 repro end to end', () => { From 35e0d51f49d246d074d679b7442725941c4228d0 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 17:08:26 -0400 Subject: [PATCH 20/59] fix(parser): read no stop field through a name holding a dot toSnakeCase reads a `.` as a word separator, so spellsOneOf took a property literally named `has.more` for `has_more`. Detection copied the name into endOfIteration.path, which a reader walks as `more` inside `has`, and it beat a valid `total` beside it. spellsOneOf now rejects a name holding a `.`, for detection and for list-envelope recognition alike, so `{ data, 'has.next' }` is no envelope, as before. detectEndOfIteration also no longer looks inside a metadata companion whose name holds one (`page.info`, read since #363), whose fields the dotted path could not reach either. The JSDoc and docs/architecture/ir-types.md say so. --- docs/architecture/ir-types.md | 2 +- src/parser/pagination.ts | 17 +++++++---- .../pagination-companion-spelling.test.ts | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 6ee5d570..7953e707 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -265,7 +265,7 @@ Every iterator stops on an empty page. `endOfIteration`, when present, lets it s - `totalPages` — an integer count of pages; stop once the pages fetched so far reach it. Under `page` that is `page - pageStart + 1 >= totalPages` (`page >= totalPages` for a 1-based API, `page + 1 >= totalPages` for a 0-based one); under `offset` it is `floor(offset / limit) + 1 >= totalPages`. - `totalCount` — an integer count of items; stop once the items received so far reach it. -It is detected from a recognized envelope — a boolean `has_more`/`hasMore`/`has_next`/`hasNext`/`has_next_page`/`hasNextPage`, an integer `total_pages`/`totalPages`/`page_count`/`pageCount`, or an integer `total`/`total_count`/`totalCount`/`total_items`/`totalItems`/`total_results`/`totalResults`, each in any spelling (its snake_case form is matched, as a companion's is below: `HasMore`, `has-more` and `HAS_MORE` are `has_more`, so `{ data, PageInfo: { HasNextPage } }` stops on `PageInfo.HasNextPage`) — beside the array first, when the `dataPath` is nested: the other properties of the object the array sits in, under whatever name (`result.total_pages` beside `result.items`, `data.page.has_more` beside `data.page.items`), then those of each object the path passes through on its way there, nearest the array first (`result.total_pages` beside `result.page` for `result.page.items`), every one reached as the path itself is resolved — one property walk (`collectObjectProperties`) reads a wrapper's companions, its stop field, each metadata companion and every segment of a `dataPath`, through `$ref`s, `allOf` members and nullable wrappers, settling a name two members declare as the model does (`combineDeclarations`); then at the top level; then one level down inside each top-level companion object whose name says it is pagination metadata (`meta`, `pagination`, `page_info`, `cursor`, …, in any spelling: the name's snake_case form is matched, so `pageInfo`, `PageInfo` and `page-info` are read exactly as `page_info` is — `{ data, pageInfo: { hasNextPage } }` is `string[]` with a `PageInfo` model and the stop field `pageInfo.hasNextPage` — while a name whose snake_case form carries none of these words (`nameTag`, whose `name_tag` spells `meta` only across a word boundary) marks no envelope, so `{ data, nameTag }` is a plain `ListPetsResponse` model; a name is read as several words only where it separates them, by a change of case or a separator such as `_` or `-`, so no pattern or stop-field name of several words is found in a name that runs them together: `pageinfo` and `PAGEINFO` are no `page_info` and `hasmore` is no `has_more`, so `{ data, pageinfo: { hasNextPage } }` and `{ data, hasmore }` are plain `ListPetsResponse` models, while a one-word pattern is still found inside such a name (`listmetadata` holds `meta`); inline, `$ref`, `allOf`, or a nullable wrapper) — a `stats.total` beside `pagination.page` is not the list's size, and nothing the path passes through, the array or an object on the way to it, is ever a stop field — and the path follows the `dataPath` convention, each segment spelled as the document spells it (`pageInfo.hasNextPage`). The body an explicit `dataPath` keeps as a wrapper (below) is read the same way. When several candidates exist, `hasMore` beats `totalPages`, which beats `totalCount`. List-envelope recognition shares that list of names and reads a name the same way, so an envelope whose only companion is a stop field in any spelling — `{ data, has_more }`, `{ data, hasMore }`, `{ data, HasNext }`, `{ data, page-count }` — is recognized like any other: unwrapped when a declared query parameter drives the iterator, kept as a plain response model when none does (see the strategy table above). +It is detected from a recognized envelope — a boolean `has_more`/`hasMore`/`has_next`/`hasNext`/`has_next_page`/`hasNextPage`, an integer `total_pages`/`totalPages`/`page_count`/`pageCount`, or an integer `total`/`total_count`/`totalCount`/`total_items`/`totalItems`/`total_results`/`totalResults`, each in any spelling (its snake_case form is matched, as a companion's is below: `HasMore`, `has-more` and `HAS_MORE` are `has_more`, so `{ data, PageInfo: { HasNextPage } }` stops on `PageInfo.HasNextPage`) — beside the array first, when the `dataPath` is nested: the other properties of the object the array sits in, under whatever name (`result.total_pages` beside `result.items`, `data.page.has_more` beside `data.page.items`), then those of each object the path passes through on its way there, nearest the array first (`result.total_pages` beside `result.page` for `result.page.items`), every one reached as the path itself is resolved — one property walk (`collectObjectProperties`) reads a wrapper's companions, its stop field, each metadata companion and every segment of a `dataPath`, through `$ref`s, `allOf` members and nullable wrappers, settling a name two members declare as the model does (`combineDeclarations`); then at the top level; then one level down inside each top-level companion object whose name says it is pagination metadata (`meta`, `pagination`, `page_info`, `cursor`, …, in any spelling: the name's snake_case form is matched, so `pageInfo`, `PageInfo` and `page-info` are read exactly as `page_info` is — `{ data, pageInfo: { hasNextPage } }` is `string[]` with a `PageInfo` model and the stop field `pageInfo.hasNextPage` — while a name whose snake_case form carries none of these words (`nameTag`, whose `name_tag` spells `meta` only across a word boundary) marks no envelope, so `{ data, nameTag }` is a plain `ListPetsResponse` model; a name is read as several words only where it separates them, by a change of case or a separator such as `_` or `-`, so no pattern or stop-field name of several words is found in a name that runs them together: `pageinfo` and `PAGEINFO` are no `page_info` and `hasmore` is no `has_more`, so `{ data, pageinfo: { hasNextPage } }` and `{ data, hasmore }` are plain `ListPetsResponse` models, while a one-word pattern is still found inside such a name (`listmetadata` holds `meta`); inline, `$ref`, `allOf`, or a nullable wrapper) — a `stats.total` beside `pagination.page` is not the list's size, and nothing the path passes through, the array or an object on the way to it, is ever a stop field — and the path follows the `dataPath` convention, each segment spelled as the document spells it (`pageInfo.hasNextPage`); a name holding a `.` cannot be one segment of such a path, so it is never a stop field and a companion so named is not looked inside (`{ data, 'has.more', total }` stops on `total`, and `{ data, 'has.next' }` is no envelope). The body an explicit `dataPath` keeps as a wrapper (below) is read the same way. When several candidates exist, `hasMore` beats `totalPages`, which beats `totalCount`. List-envelope recognition shares that list of names and reads a name the same way, so an envelope whose only companion is a stop field in any spelling — `{ data, has_more }`, `{ data, hasMore }`, `{ data, HasNext }`, `{ data, page-count }` — is recognized like any other: unwrapped when a declared query parameter drives the iterator, kept as a plain response model when none does (see the strategy table above). The `dataPath` is dynamically detected from the response envelope — when `undefined`, emitters should treat the entire response as the data array or use their own default. diff --git a/src/parser/pagination.ts b/src/parser/pagination.ts index cea1cf2a..2aed7dd6 100644 --- a/src/parser/pagination.ts +++ b/src/parser/pagination.ts @@ -262,12 +262,14 @@ export function looksLikePaginationMetadata(name: string): boolean { * separators do not matter, so `has_more`, `hasMore`, `HasMore`, `has-more` * and `HAS_MORE` are all `has_more`, while a name that runs its words * together is one word: `hasmore` is no `has_more` and `totalpages` no - * `total_pages`. The one test of a stop field's name, for list-envelope - * detection (`END_OF_ITERATION_FIELDS`) and for `detectEndOfIteration` - * (each kind's list), so the two cannot disagree about which companion is - * a stop field. + * `total_pages`. A name holding a `.` is none of them: a stop field's path + * is dotted, so `has.more` would be read as `more` inside `has`. The one + * test of a stop field's name, for list-envelope detection + * (`END_OF_ITERATION_FIELDS`) and for `detectEndOfIteration` (each kind's + * list), so the two cannot disagree about which companion is a stop field. */ export function spellsOneOf(name: string, fields: readonly string[]): boolean { + if (name.includes('.')) return false; const spelled = toSnakeCase(name); return fields.some((field) => toSnakeCase(field) === spelled); } @@ -299,7 +301,9 @@ export function spellsOneOf(name: string, fields: readonly string[]): boolean { * segment as the document does (`pageInfo.hasNextPage`). A candidate is a * stop field by its name in any spelling (`spellsOneOf`), so `HasMore` and * `PageInfo.HasNextPage` are read as `has_more` and - * `page_info.has_next_page` are, each kept as written. When several + * `page_info.has_next_page` are, each kept as written; a name holding a + * `.` is neither a stop field nor a companion looked inside, since the + * dotted path could not address it (`has.more`, `page.info`). When several * candidates exist, a `hasMore` boolean wins over a `totalPages` integer, * which wins over a `totalCount` integer; within one kind the first in scan * order wins. A candidate whose type does not fit its kind (a string @@ -332,7 +336,8 @@ export function detectEndOfIteration( ); for (const [name, schema] of companions) record(name, name, schema); for (const [name, schema] of companions) { - if (!looksLikePaginationMetadata(name)) continue; + // A `.` in the name would split the path inside it. + if (name.includes('.') || !looksLikePaginationMetadata(name)) continue; for (const [inner, innerSchema] of Object.entries(objectProperties(schema, componentSchemas))) { if (innerSchema) record(inner, `${name}.${inner}`, innerSchema); } diff --git a/test/parser/pagination-companion-spelling.test.ts b/test/parser/pagination-companion-spelling.test.ts index febc36db..4c8ca815 100644 --- a/test/parser/pagination-companion-spelling.test.ts +++ b/test/parser/pagination-companion-spelling.test.ts @@ -160,6 +160,13 @@ describe('classifyAndExtractResponse — envelope companion in any spelling', () }, ); + it('keeps { data, has.next } as a plain model: a name with a `.` is no stop field', () => { + const result = classifyAndExtractResponse(envelope({ 'has.next': boolean }), 'ListPetsResponse'); + expect(result.isPaginated).toBe(false); + expect(result.response).toEqual({ kind: 'model', name: 'ListPetsResponse' }); + expect(result.endOfIteration).toBeUndefined(); + }); + it.each(['hasmore', 'hasnext', 'HASNEXT', 'pagecount'])( 'keeps { data, %s } as a plain model: a name that runs its words together is no stop field (#438)', (name) => { @@ -234,6 +241,19 @@ describe('detectEndOfIteration — stop field in any spelling (#438)', () => { expect(detectEndOfIteration({ data, [name]: companion({ hasNextPage: boolean }) }, 'data')).toBeUndefined(); } }); + + it('reads no stop field from, and looks inside no companion named with, a `.` the dotted path cannot address', () => { + // `has.more` would be read as `more` inside `has`, so the valid `total` beside it stays the stop. + expect(detectEndOfIteration({ data, 'has.more': boolean, total: integer }, 'data')).toEqual({ + kind: 'totalCount', + path: 'total', + }); + expect(detectEndOfIteration({ data, 'has.more': boolean }, 'data')).toBeUndefined(); + expect(detectEndOfIteration({ data, pagination: companion({ 'total.pages': integer }) }, 'data')).toBeUndefined(); + expect(detectEndOfIteration({ data, 'page.info': companion({ hasNextPage: boolean }) }, 'data')).toBeUndefined(); + const nested = { result: companion({ items: data, 'has.more': boolean }) }; + expect(detectEndOfIteration(nested, 'result.items')).toBeUndefined(); + }); }); describe('parseSpec — the issue #438 repro end to end', () => { @@ -344,6 +364,15 @@ describe('parseSpec — the issue #438 repro end to end', () => { ]); expect(projection(await listPets({ hasnext: flag }))).toEqual(['ListPetsResponse', undefined, [], undefined]); }); + + it('stops on a field the dotted path can address, never on one whose name holds a `.`', async () => { + expect(projection(await listPets({ 'has.more': flag, total: { type: 'integer' } }))).toEqual([ + 'string[]', + 'data', + ['has.more', 'total'], + { kind: 'totalCount', path: 'total' }, + ]); + }); }); describe('parseSpec — the issue #311 repro end to end', () => { From 2d34e3b254277fa96f0e57d593791e9f3318d29c Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 17:24:35 -0400 Subject: [PATCH 21/59] fix(parser)!: keep a secondary 2xx envelope that lacks the override's stop field Since #362, docs/architecture/ir-types.md says that when an x-pagination override is the only possible source of an iterator, a 2xx body the override cannot satisfy keeps its list envelope as a plain model: its dataPath reaches no array there, or its endOfIteration resolves in it to no field of the fitting type. extractResponses checked the dataPath half only, so a secondary 2xx envelope that lacks the stop field, or types it otherwise, was unwrapped like the primary and lost its companions without a warning. extractResponses now also keeps such a body as a plain model when the override's endOfIteration fails bodyEndOfIterationProblem on it, the check decidePaginationOverride makes against the primary. The primary reads as before: an override whose stop field misses it is rejected already. A body that is no list envelope reads the same either way. Closes #436 BREAKING CHANGE: under an iterator only an override drives (no query parameter yields one), a secondary 2xx list envelope that lacks the override's endOfIteration field, or types it otherwise, is now a plain response model instead of the bare array. A 201 `{ data: string[], has_more: boolean }` beside a 200 that stops on `total_pages` goes from `string[]` to `ListPetsResponse { data, has_more }`; `oagen diff` reports the 201 as retyped (breaking) and the model as added. --- src/parser/operations.ts | 12 ++-- .../pagination-undeclared-param.test.ts | 66 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/parser/operations.ts b/src/parser/operations.ts index 9b7419bc..d908118e 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -6210,13 +6210,17 @@ function extractResponses( : pagination.dataPath; // When only the override can drive an iterator, a body it will not // page — every body once the override is rejected, or a secondary - // its `dataPath` reaches no array in — keeps a list envelope as a - // plain response model, as under `disabled`, rather than unwrapped - // for an iterator that never comes. + // its `dataPath` reaches no array in, or its `endOfIteration` + // resolves in to no field of the fitting type — keeps a list + // envelope as a plain response model, as under `disabled`, rather + // than unwrapped for an iterator that never comes. const paginationDisabled = pagination.disabled === true || (pagination.overrideOnly === true && - (!overrideDecision.usable || (pagination.dataPath !== undefined && dataPathOverride === undefined))); + (!overrideDecision.usable || + (pagination.dataPath !== undefined && dataPathOverride === undefined) || + (pagination.endOfIteration !== undefined && + bodyEndOfIterationProblem(pagination.endOfIteration, bodySchema, componentSchemas) !== undefined))); // The primary body's array items are settled against the registry // (`typeInlineItems`); the dry run that reads a component response's // names (`bodyModelNames`) passes none and mints nothing into it. diff --git a/test/parser/pagination-undeclared-param.test.ts b/test/parser/pagination-undeclared-param.test.ts index 96e8b6b2..b2f06399 100644 --- a/test/parser/pagination-undeclared-param.test.ts +++ b/test/parser/pagination-undeclared-param.test.ts @@ -617,4 +617,70 @@ describe('a list envelope on an operation without a paging parameter (issue #309 expect(warnings()).toEqual([]); expectSound(withSecondary); }); + + describe('a secondary 2xx envelope the override cannot stop on is kept too, while the primary is iterated (#436)', () => { + // Only the override pages the operation (`page` without a page size makes + // no iterator). Its `endOfIteration` resolves in the 200, so the override + // applies; a 201 that lacks the field, or types it otherwise, is a body + // the iterator cannot stop on, so its envelope stays the wire shape — as + // one the override's `dataPath` reaches no array in does — instead of + // being unwrapped into a bare array that drops its companions. + const withCreated = (companions: Record, dataPath?: string) => { + const doc = petsSpec([query('page')], totalPagesEnvelope, { + 'x-pagination': { + strategy: 'page', + ...(dataPath !== undefined && { dataPath }), + endOfIteration: { kind: 'totalPages', path: 'total_pages' }, + }, + }); + const created = { + type: 'object', + properties: { data: { type: 'array', items: { type: 'string' } }, ...companions }, + }; + Object.assign(doc.paths['/pets'].get.responses, { + '201': { description: 'created', content: { 'application/json': { schema: created } } }, + }); + return doc; + }; + const unwrapped = { kind: 'array', items: { kind: 'primitive', type: 'string' } }; + + it.each([ + ['lacks the stop field (the issue repro)', { has_more: { type: 'boolean' } }, undefined, ['data', 'has_more']], + ['types the stop field otherwise', { total_pages: { type: 'string' } }, undefined, ['data', 'total_pages']], + [ + 'lacks the stop field, though the dataPath reaches its array', + { has_more: { type: 'boolean' } }, + 'data', + ['data', 'has_more'], + ], + ])('a 201 that %s keeps its envelope as a plain model', async (_label, companions, dataPath, fields) => { + const ir = await parseJson(withCreated(companions, dataPath)); + const op = onlyOperation(ir); + expect(op.response).toEqual(unwrapped); + expect(op.pagination).toMatchObject({ + strategy: 'page', + param: 'page', + dataPath: 'data', + endOfIteration: { kind: 'totalPages', path: 'total_pages' }, + }); + const secondary = op.successResponses?.find((r) => r.statusCode === 201)?.type; + expect(secondary?.kind).toBe('model'); + const wrapper = ir.models.find((m) => secondary?.kind === 'model' && m.name === secondary.name); + expect(wrapper?.fields.map((f) => f.name)).toEqual(fields); + expect(warnings()).toEqual([]); + expectSound(ir); + }); + + it('a 201 that declares the stop field with the fitting type is unwrapped like the 200', async () => { + const ir = await parseJson(withCreated({ total_pages: { type: 'integer' } })); + const op = onlyOperation(ir); + expect(op.successResponses?.map((r) => [r.statusCode, r.type])).toEqual([ + [200, unwrapped], + [201, unwrapped], + ]); + expect(ir.models).toEqual([]); + expect(warnings()).toEqual([]); + expectSound(ir); + }); + }); }); From 35bb564aa5910954fd3affcdb3abd76e86cccbaa Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 17:42:29 -0400 Subject: [PATCH 22/59] fix(parser)!: unwrap a list envelope whose data array is an allOf of the array detectListEnvelope counted a property as the envelope's array through arraySchemaOf and isArrayComponentRef, which strip a nullable wrapper but never look into an allOf. schemaToTypeRef reads an allOf of one member as that member (readInlineAllOf), so `data: { allOf: [{ type: 'array', items }] }` was typed Pet[] on the field while detection saw a companion: the body stayed a ListPetsResponse model and the operation was not paginated. arraySchemaOf now reads through an allOf the way schemaToTypeRef types it (a lone member, carried by unwrappedAllOfMember, or the $ref of a plain reference), alternating with nullable wrappers; declaresNull follows the same member so the response keeps a null declared inside the allOf (`Pet[] | null`), and isArrayComponentRef reads `allOf: [{ $ref: Pets }]` as the reference. The three move together, so the allOf merge of two declarations of one array property (combineDeclarations) keeps the null whichever side wears the wrapper. Consequences, each matching the spelling without the allOf: a second allOf-wrapped array beside `data` counts as an array, so the envelope falls under the one-array rule; and an array body under a nullable union around an allOf names its inline items `Item`. The IR of every fixture spec and of the WorkOS spec is unchanged. Closes #395 --- docs/architecture/ir-types.md | 4 +- src/parser/responses.ts | 12 +-- src/parser/schemas.ts | 80 +++++++++++----- test/parser/inline-array-items.test.ts | 18 ++++ test/parser/parse-pagination-envelope.test.ts | 93 ++++++++++++++++++- 5 files changed, 173 insertions(+), 34 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 7953e707..4d5b54b4 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -269,7 +269,7 @@ It is detected from a recognized envelope — a boolean `has_more`/`hasMore`/`ha The `dataPath` is dynamically detected from the response envelope — when `undefined`, emitters should treat the entire response as the data array or use their own default. -When the parser recognizes an inline list envelope — one array property plus a companion that looks like pagination metadata (a stop field in any spelling — `has_more`, `HasMore`, `total`, … — or a name whose snake_case spelling contains `meta`, `pagination`, `cursor`, `page_info`, `total`, `offset`, …, `pageInfo` and `page_info` alike) — and a query parameter the operation declares drives the iterator (the one detection found, or the one an override names), `Operation.response` becomes the bare array, and `envelopeFields` keeps everything else the envelope carries; an envelope nothing pages stays one plain response model, with no `pagination` (see the strategy table above). The array property is an array in any spelling, and two `allOf` declarations of it — one nullable, one not; one with `items`, one with limits alone — merge as the arrays they wrap (the later `items` when they narrow, the earlier otherwise, `null` admitted when either side admits it): `type: 'array'`, `items` with no `type`, or either under a nullable wrapper — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, or a `oneOf`/`anyOf` with a `null` variant, with or without `items` beside it — in which case `response` is `nullable>` while `itemType` is still the element (`ListEventsItem[] | null`, `dataPath: 'data'`). Its items, whatever their shape, are typed as `Item` (see Inline model naming). `envelopeFields` keeps everything else the envelope carries: the pagination companion (`list_metadata`, `pagination`, `has_more`, `total`, …) and any discriminant such as `object: "list"`. Each entry is an ordinary `Field` (wire name, type, `required`, description), so `dataPath` + `itemType` + `envelopeFields` together describe the full shape of what the endpoint returns, and an emitter can both type the response and read the page details it needs for the next request: +When the parser recognizes an inline list envelope — one array property plus a companion that looks like pagination metadata (a stop field in any spelling — `has_more`, `HasMore`, `total`, … — or a name whose snake_case spelling contains `meta`, `pagination`, `cursor`, `page_info`, `total`, `offset`, …, `pageInfo` and `page_info` alike) — and a query parameter the operation declares drives the iterator (the one detection found, or the one an override names), `Operation.response` becomes the bare array, and `envelopeFields` keeps everything else the envelope carries; an envelope nothing pages stays one plain response model, with no `pagination` (see the strategy table above). The array property is an array in any spelling, and two `allOf` declarations of it — one nullable, one not; one with `items`, one with limits alone — merge as the arrays they wrap (the later `items` when they narrow, the earlier otherwise, `null` admitted when either side admits it): `type: 'array'`, `items` with no `type`, or either under a nullable wrapper — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, or a `oneOf`/`anyOf` with a `null` variant, with or without `items` beside it — in which case `response` is `nullable>` while `itemType` is still the element (`ListEventsItem[] | null`, `dataPath: 'data'`). Any of these, or a `$ref` to an array component, is still the array when written as an `allOf` that `schemaToTypeRef` reads as its member (`readInlineAllOf`: one member with nothing beside it that adds to it, or `$ref` members with nothing inline beside them), at any depth and on either side of a nullable wrapper, as a field of that schema is typed: `allOf: [{ type: 'array', items: Pet }]` is `Pet[]` and `allOf: [{ oneOf: [{ type: 'array', items: Pet }, { type: 'null' }] }]` is `Pet[] | null`, each with `dataPath: 'data'`. Its items, whatever their shape, are typed as `Item` (see Inline model naming). `envelopeFields` keeps everything else the envelope carries: the pagination companion (`list_metadata`, `pagination`, `has_more`, `total`, …) and any discriminant such as `object: "list"`. Each entry is an ordinary `Field` (wire name, type, `required`, description), so `dataPath` + `itemType` + `envelopeFields` together describe the full shape of what the endpoint returns, and an emitter can both type the response and read the page details it needs for the next request: ```typescript // GET /customers?page=&limit= → { customers: Customer[] ("The customers on this page."), pagination: { page, totalPages } } @@ -733,7 +733,7 @@ Every pass that walks a model's type refs — reachability for emission, service ### Array components -A component schema that is an array — `type: 'array'`, an OAS 3.1 type list `['array', 'null']`, OAS 3.0 `nullable: true` beside `type: 'array'`, or `items` with no `type`, in every case with `items` declared (`isArrayComponentSchema`) — or one `schemaToTypeRef` types as an array through its readings of a `$ref` and an `allOf`: a bare `$ref` to it; an `allOf` read as a plain reference (`readInlineAllOf`: `$ref` members with nothing inline beside them — `allOf: [{ $ref: Pets }]`, `allOf: [{ $ref: Pets }, { maxItems: 10 }]`), read as its first `$ref` member; an `allOf` of one member, read as that member; followed through aliases of aliases (`isArrayComponentEntry`) — becomes no `Model`. The parser knows an array component by its `components/schemas` key, the key a `$ref` names, not by its IR name: `cleanSchemaName` singularizes the leading word, so `Note: { type: object }` and `Notes: { type: array, items: { $ref: Note } }` both resolve to `Note`, and keying by name would send `$ref: Note` into the array; keyed by `Notes`, the object keeps its model and `$ref: Notes` is `array`. A `$ref` is resolved by its key before any name-based test, so `$ref: Notes` is the array even when another key resolving to `Note` is an enum. A `$ref` to an array component resolves to the array the component declares, the shape the same array written inline has: with `Pets: { type: array, items: { $ref: Widget } }`, every `$ref` to `Pets` is `{ kind: "array", items: { kind: "model", name: "Widget" } }`, and a component that admits `null` wraps that in `nullable`, as a `$ref` to a nullable enum component is wrapped. This holds at every reference site — a component field, an `allOf` member (`allOf: [{ $ref: Pets }]`, with or without limits beside it), a named union's variant, a request body, a parameter in any location, a response header, a success, error, `default` or range response body, a webhook payload — because every site types a `$ref` through `schemaToTypeRef`; and a list envelope whose data array is a `$ref` to an array component, under any nullable wrapper, is detected and unwrapped as one whose array is written inline, iterating the component's items. `spec.models` holds no `Pets`, so an emitter never sees an empty class, or a list typed as one object. +A component schema that is an array — `type: 'array'`, an OAS 3.1 type list `['array', 'null']`, OAS 3.0 `nullable: true` beside `type: 'array'`, or `items` with no `type`, in every case with `items` declared (`isArrayComponentSchema`) — or one `schemaToTypeRef` types as an array through its readings of a `$ref` and an `allOf`: a bare `$ref` to it; an `allOf` read as a plain reference (`readInlineAllOf`: `$ref` members with nothing inline beside them — `allOf: [{ $ref: Pets }]`, `allOf: [{ $ref: Pets }, { maxItems: 10 }]`), read as its first `$ref` member; an `allOf` of one member, read as that member; followed through aliases of aliases (`isArrayComponentEntry`) — becomes no `Model`. The parser knows an array component by its `components/schemas` key, the key a `$ref` names, not by its IR name: `cleanSchemaName` singularizes the leading word, so `Note: { type: object }` and `Notes: { type: array, items: { $ref: Note } }` both resolve to `Note`, and keying by name would send `$ref: Note` into the array; keyed by `Notes`, the object keeps its model and `$ref: Notes` is `array`. A `$ref` is resolved by its key before any name-based test, so `$ref: Notes` is the array even when another key resolving to `Note` is an enum. A `$ref` to an array component resolves to the array the component declares, the shape the same array written inline has: with `Pets: { type: array, items: { $ref: Widget } }`, every `$ref` to `Pets` is `{ kind: "array", items: { kind: "model", name: "Widget" } }`, and a component that admits `null` wraps that in `nullable`, as a `$ref` to a nullable enum component is wrapped. This holds at every reference site — a component field, an `allOf` member (`allOf: [{ $ref: Pets }]`, with or without limits beside it), a named union's variant, a request body, a parameter in any location, a response header, a success, error, `default` or range response body, a webhook payload — because every site types a `$ref` through `schemaToTypeRef`; and a list envelope whose data array is a `$ref` to an array component, under any nullable wrapper or an `allOf` read as the reference (`allOf: [{ $ref: Pets }]`), is detected and unwrapped as one whose array is written inline, iterating the component's items. `spec.models` holds no `Pets`, so an emitter never sees an empty class, or a list typed as one object. What the items reference is extracted under the component's name, by the inline extractor a field's array goes through, as the `item` property of a synthetic object under the component: an inline object item is `Item` (`PetsItem`), with everything nested under it named from that (`PetsItemOwner`); the object variants of an `items.oneOf` are named by the inline-variant rules from `PetsItem` (`PetsItem`, `PetsItemWithSide`); an inline enum is `PetsItem`, lifted into `spec.enums` whether or not anything refers to the component (an inline object item's model is likewise emitted for a component nothing refers to); the objects of a nested array or map are `PetsItem` too, by the one rule a field's items and values follow. The component's name is the one `cleanSchemaName` and `schemaNameTransform` give it, so a component `pet_list` renamed to `PetCollection` gives `PetCollectionItem`. A `$ref` item mints nothing: it stays a reference to its model or enum, and a `$ref` to another array component resolves in turn (`array>`). Every name the whole tree mints — the item model, an object nested under it (`PetsItemOwner`), an inline enum a nested field holds, a union variant — is read off a rehearsal of the extraction itself before any plan is decided, so a nested name counts exactly as the item model's does. When any of them is already claimed for a different shape — by any name any extraction pass takes from another `components/schemas` entry, read off a rehearsal of those passes: the entry's own model (compared by `modelFingerprint`), enum (by its members) or array component's name, a discriminated allOf+oneOf variant (an `Event` whose variant pins `type: 'pets_item'` owns `PetsItem`), a named union's variant or `variant_` member, an object or enum the entry's fields mint, and everything nested under those; or by another array component's tree — the whole tree's context steps exactly as a named union's `variant_` does (see Named unions): `item_item` (`PetsItemItem`, `PetsItemItemOwner`), then `item_array_item`, then numbered, until no name under it is claimed; a claimant of the identical shape is shared, and the tree references it. Shapes are compared as the real pass types them, under the names the plans settle on. The plans are decided in rounds: every tree starts under `item`; a round registers every tree under its current context, so a `$ref` to an array component resolves to its array in every rehearsal — `{ value: { $ref: Notes } }` and `{ value: { $ref: Note } }` differ, as `array` and `Note` do, although both keys resolve to `Note` — while the names a tree mints are read with none registered, so an enum another component's items hold is never counted as the tree's own; then the trees are decided one at a time, in the order of their keys sorted by code point — never the document's order — each against every component and every other tree's current names, a tree that moves being re-registered at once so the trees after it resolve its items under its new name. A round that moves nothing is the last, so no sharing rests on a name a tree then steps away from (`Pets`'s items holding `{ value: { $ref: Palette } }` are shared with a component `PetsItem { value: array }` only while `Palette`'s items are still `PaletteItem`; once `Palette` steps to `PaletteItemItem`, the next round reads `array`, and `Pets`'s items step to `PetsItemItem`), no two trees end on one name for different shapes however many step (two trees whose names nest into each other, `Pets` and `PetsItemItemCat`, both step, the second past the first's names), and the decision does not depend on declaration order; two trees that mint one name for the identical shape keep it and one model is emitted. Two rounds suffice unless trees chase each other's names; after eight the last decision is kept with a warning. The reference and the extractor read one plan, decided once per document before any reference is typed, so the two agree. A name an operation mints — `operationId: listPets` names an inline array response's items `ListPetsItem`, as a component `ListPets` names its own — is not read here: the component's name is settled first, and the operation's items are settled afterwards against the spec-wide registry of model shapes (see Inline model naming), so a name the component's items own with another shape sends them to `ListPetsResponseItem`, and an identical shape shares the model. diff --git a/src/parser/responses.ts b/src/parser/responses.ts index 7482fd1c..afedc692 100644 --- a/src/parser/responses.ts +++ b/src/parser/responses.ts @@ -382,10 +382,10 @@ function detectListEnvelope(schema: SchemaObject): ListEnvelopeResult { } // Find array properties — typed, `items` with no `type`, or either under a - // nullable wrapper of any spelling (`arraySchemaOf`), or a `$ref` to an - // array component under such a wrapper (`isArrayComponentRef`), which - // `schemaToTypeRef` types as the array the component declares — and - // non-array companion properties + // nullable wrapper of any spelling or an `allOf` read as its member + // (`arraySchemaOf`), or a `$ref` to an array component under such wrappers + // (`isArrayComponentRef`), which `schemaToTypeRef` types as the array the + // component declares — and non-array companion properties const arrayProps: string[] = []; const nonArrayKeys: string[] = []; @@ -460,8 +460,8 @@ function extractListResponse( const array = dataType.kind === 'nullable' ? dataType.inner : dataType; if (array.kind === 'array') itemTypeRef = array.items; } else { - // The array under whatever nullable wrapper the data property wears - // (`arraySchemaOf`); its items, whatever their shape, are typed and + // The array under whatever nullable wrapper or `allOf` the data property + // wears (`arraySchemaOf`); its items, whatever their shape, are typed and // materialized as `Item` (`typeInlineItems`). const dataArray = dataProp ? arraySchemaOf(dataProp) : undefined; if (dataArray?.items) { diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index 30a23ae8..616199ca 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -601,37 +601,65 @@ export function nullableUnionBranch(schema: SchemaObject): SchemaObject | undefi * The array schema `schema` is, once a nullable wrapper of any spelling is * removed — an OAS 3.1 type list naming `null` (`type: ['array', 'null']`), * OAS 3.0 `nullable: true`, or a `oneOf`/`anyOf` with one non-null variant, - * written with or without `items` beside it — and `undefined` when it is - * not an array: `unwrapNullableSchema`, the one definition the model side - * unwraps by, then `isArraySchema`, the one test every site makes. So a - * list envelope's data array, an array response body and an `allOf`-merged - * array property are arrays here whatever wrapper they wear, as - * `schemaToTypeRef` types them `nullable>`. A `$ref` is a reference - * and is not looked through: a `$ref` to an array component is typed as the - * array the component declares by `schemaToTypeRef` itself, with the items - * the component pass built (`isArrayComponentRef` says whether a property - * is one), and the explicit `dataPath` walk (`resolveBareSchema`) is the one - * place any other reference is followed. + * written with or without `items` beside it — and an `allOf` read as one + * member (`allOfReadAsMember`: `allOf: [{ type: 'array', items }]`), the two + * however they alternate, and `undefined` when it is not an array: + * `unwrapNullableSchema`, the one definition the model side unwraps by, then + * `isArraySchema`, the one test every site makes. So a list envelope's data + * array, an array response body and an `allOf`-merged array property are + * arrays here whatever wrapper they wear, as `schemaToTypeRef` types them + * `nullable>`. A `$ref` is a reference and is not looked through: + * a `$ref` to an array component is typed as the array the component + * declares by `schemaToTypeRef` itself, with the items the component pass + * built (`isArrayComponentRef` says whether a property is one), and the + * explicit `dataPath` walk (`resolveBareSchema`) is the one place any other + * reference is followed. */ export function arraySchemaOf(schema: SchemaObject): SchemaObject | undefined { const bare = unwrapNullableSchema(schema); - return isArraySchema(bare) ? bare : undefined; + if (isArraySchema(bare)) return bare; + const member = allOfReadAsMember(bare); + return member && arraySchemaOf(member); +} + +/** + * The member `schemaToTypeRef` types an `allOf` as (`readInlineAllOf`): a + * lone member with nothing added to it (`'unwrapped'`), read as the schema + * itself with the wrapper's limits, flag and documentation + * (`unwrappedAllOfMember`), or the first `$ref` member of a plain reference + * (`'reference'`). `undefined` for any other schema: no `allOf` that types + * it (`classifyInlineSchema`), or one merged into a model. Read on a schema + * whose nullable wrappers are removed and sibling `properties` folded in + * (`unwrapNullableSchema`), as `schemaToTypeRef` reads it. + */ +function allOfReadAsMember(schema: SchemaObject): SchemaObject | undefined { + if (!schema.allOf || classifyInlineSchema(schema) !== 'allOf') return undefined; + const reading = readInlineAllOf(schema.allOf, schema); + if (reading === 'unwrapped') return unwrappedAllOfMember(schema, schema.allOf[0]); + return reading === 'reference' ? schema.allOf.find((member) => member.$ref !== undefined) : undefined; } /** * Whether `schemaToTypeRef` wraps the schema's own type in `nullable`: an OAS * 3.1 type list naming `null` (`type: ['array', 'null']`), an OAS 3.0 - * `nullable: true` beside a `type`, or a `oneOf`/`anyOf` that collapses to - * one non-null variant (`nullableUnionBranch`). The one test for the places - * the response path builds an array TypeRef itself (`nullableIf`) or merges - * two array declarations (`combineDeclarations`). + * `nullable: true` beside a `type`, a `oneOf`/`anyOf` that collapses to one + * non-null variant (`nullableUnionBranch`), or an `allOf` read as a member + * that does (`allOfReadAsMember`: `allOf: [{ type: ['array', 'null'] }]`). + * The one test for the places the response path builds an array TypeRef + * itself (`nullableIf`) or merges two array declarations + * (`combineDeclarations`), which find the array through the same wrappers + * (`arraySchemaOf`). */ export function declaresNull(schema: SchemaObject): boolean { - return ( + if ( (Array.isArray(schema.type) && schema.type.includes('null')) || (schema.nullable === true && schema.type !== undefined) || nullableUnionBranch(schema) !== undefined - ); + ) { + return true; + } + const member = allOfReadAsMember(unwrapNullableSchema(schema)); + return member !== undefined && declaresNull(member); } /** @@ -3531,16 +3559,18 @@ function arrayComponentTypeRef(key: string): TypeRef | undefined { /** * Whether `schema` is a `$ref` to an array component — by the key the `$ref` * names, as `schemaToTypeRef` resolves it — under any nullable wrapper - * `unwrapNullableSchema` strips, so a reader that asks whether a property is - * an array from the document (`detectListEnvelope`, `buildFieldFromSchema`) - * answers for a reference as `schemaToTypeRef` types it. False outside an - * extraction, as a `$ref` to an enum component is a model reference there. + * `unwrapNullableSchema` strips and any `allOf` read as the reference + * (`allOfReadAsMember`: `allOf: [{ $ref: Pets }]`), so a reader that asks + * whether a property is an array from the document (`detectListEnvelope`, + * `buildFieldFromSchema`) answers for a reference as `schemaToTypeRef` + * types it. False outside an extraction, as a `$ref` to an enum component + * is a model reference there. */ export function isArrayComponentRef(schema: SchemaObject | undefined): boolean { if (!schema) return false; - const { $ref } = unwrapNullableSchema(schema); - if (!$ref) return false; - const segments = $ref.split('/'); + const bare = unwrapNullableSchema(schema); + if (!bare.$ref) return isArrayComponentRef(allOfReadAsMember(bare)); + const segments = bare.$ref.split('/'); return activeArrayComponentPlans.has(segments[segments.length - 1]); } diff --git a/test/parser/inline-array-items.test.ts b/test/parser/inline-array-items.test.ts index b320a932..586a23b6 100644 --- a/test/parser/inline-array-items.test.ts +++ b/test/parser/inline-array-items.test.ts @@ -1217,6 +1217,24 @@ describe('(3) a nullable array is the array it is, however the null is written', } }); + it('reads it through an allOf of one member on either side of the null, as a body and as data (#395)', () => { + const plain = spellings.oneOfAlone; + const wrapped: Record = { + allOfAroundOneOf: { allOf: [plain] }, + allOfAroundTypeList: { allOf: [spellings.typeList] }, + oneOfAroundAllOf: { oneOf: [{ allOf: [{ type: 'array', items: pet }] }, { type: 'null' }] }, + }; + const envelope = (data: SchemaObject) => obj({ data, list_metadata: obj({ after: str }) }); + for (const [name, schema] of Object.entries(wrapped)) { + expect(classifyAndExtractResponse(schema, 'ListPetsResponse'), name).toEqual( + classifyAndExtractResponse(plain, 'ListPetsResponse'), + ); + expect(classifyAndExtractResponse(envelope(schema), 'ListPetsResponse'), name).toEqual( + classifyAndExtractResponse(envelope(plain), 'ListPetsResponse'), + ); + } + }); + it('keeps the items of a nullable data array when a later allOf branch only constrains it', () => { const result = classifyAndExtractResponse( { diff --git a/test/parser/parse-pagination-envelope.test.ts b/test/parser/parse-pagination-envelope.test.ts index 26f0d64a..b4c2a779 100644 --- a/test/parser/parse-pagination-envelope.test.ts +++ b/test/parser/parse-pagination-envelope.test.ts @@ -5,7 +5,7 @@ import * as path from 'node:path'; import { parseSpec } from '../../src/parser/parse.js'; import { validateModelRefs } from '../../src/parser/normalize-model-refs.js'; import { defaultSdkBehavior } from '../../src/ir/sdk-behavior.js'; -import type { ApiSpec, Operation } from '../../src/ir/types.js'; +import type { ApiSpec, Operation, TypeRef } from '../../src/ir/types.js'; /** * End-to-end coverage for `PaginationMeta.envelopeFields` (#183): a list @@ -453,6 +453,97 @@ describe('parseSpec — pagination envelope fields', () => { expect(op.pagination?.envelopeFields, op.name).toBeUndefined(); } }); + + describe('a data array written as an allOf schemaToTypeRef reads as its member', () => { + const pet = { $ref: '#/components/schemas/Pet' }; + const array = { type: 'array', items: pet }; + const nullableArray = { oneOf: [array, { type: 'null' }] }; + const pets: TypeRef = { kind: 'array', items: { kind: 'model', name: 'Pet' } }; + + const parseList = (body: unknown): Promise => + parseJson({ + openapi: '3.1.0', + info: { title: 'repro', version: '1' }, + paths: { '/pets': listOp({}, 'listPets', [{ name: 'after', in: 'query', schema: { type: 'string' } }], body) }, + components: { + schemas: { + Pet: { type: 'object', properties: { name: { type: 'string' } } }, + Pets: { type: 'array', items: pet }, + }, + }, + }); + const envelope = (data: unknown) => ({ + type: 'object', + properties: { data, list_metadata: { type: 'object', properties: { after: { type: 'string' } } } }, + }); + + it('is the envelope array, paged and typed as the array it wraps (#395 repro)', async () => { + const cases: Array<[unknown, unknown, TypeRef]> = [ + [{ allOf: [array] }, array, pets], + [{ allOf: [nullableArray] }, nullableArray, { kind: 'nullable', inner: pets }], + ]; + for (const [data, plain, response] of cases) { + const ir = await parseList(envelope(data)); + const [op] = ir.services.flatMap((s) => s.operations); + expect(op.pagination?.dataPath, JSON.stringify(data)).toBe('data'); + expect(op.response, JSON.stringify(data)).toEqual(response); + expect(ir, JSON.stringify(data)).toEqual(await parseList(envelope(plain))); + } + }); + + it('is read through nested allOfs, nullable wrappers on either side and a $ref to an array component', async () => { + const cases: Array<[unknown, unknown]> = [ + [{ allOf: [{ allOf: [array] }] }, array], + [ + { allOf: [array], description: 'The pets' }, + { ...array, description: 'The pets' }, + ], + [{ allOf: [{ items: pet }] }, { items: pet }], + [{ allOf: [{ type: ['array', 'null'], items: pet }] }, { type: ['array', 'null'], items: pet }], + [{ oneOf: [{ allOf: [array] }, { type: 'null' }] }, nullableArray], + [{ allOf: [{ $ref: '#/components/schemas/Pets' }] }, { $ref: '#/components/schemas/Pets' }], + [{ allOf: [{ $ref: '#/components/schemas/Pets' }, { maxItems: 10 }] }, { $ref: '#/components/schemas/Pets' }], + ]; + for (const [data, plain] of cases) { + const ir = await parseList(envelope(data)); + expect(ir.services[0].operations[0].pagination?.dataPath, JSON.stringify(data)).toBe('data'); + expect(ir, JSON.stringify(data)).toEqual(await parseList(envelope(plain))); + } + }); + + it('merges with a second allOf declaration of data as the arrays the two wrap, null kept', async () => { + const wrapped = { allOf: [{ type: ['array', 'null'], items: pet }] }; + const limits = { type: 'array', minItems: 1 }; + const metadata = { list_metadata: { type: 'object', properties: { after: { type: 'string' } } } }; + for (const [first, second] of [ + [wrapped, limits], + [limits, wrapped], + ]) { + const ir = await parseList({ + allOf: [ + { type: 'object', properties: { data: first, ...metadata } }, + { type: 'object', properties: { data: second } }, + ], + }); + const [op] = ir.services.flatMap((s) => s.operations); + expect(op.pagination?.dataPath).toBe('data'); + expect(op.response).toEqual({ kind: 'nullable', inner: pets }); + } + }); + + it('counts a second array written so as an array too: two arrays make no envelope, as when written plainly', async () => { + const tags = { type: 'array', items: { type: 'string' } }; + const withTags = (spelling: unknown) => ({ + type: 'object', + properties: { ...envelope(array).properties, tags: spelling }, + }); + const ir = await parseList(withTags({ allOf: [tags] })); + const [op] = ir.services.flatMap((s) => s.operations); + expect(op.pagination?.dataPath).toBeUndefined(); + expect(op.response).toEqual({ kind: 'model', name: 'ListPetsResponse' }); + expect(ir).toEqual(await parseList(withTags(tags))); + }); + }); }); describe('validateModelRefs — pagination envelope fields', () => { From aced2255c33ead94d3d22230961435cfa29b583d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 18:05:34 -0400 Subject: [PATCH 23/59] fix(parser)!: read a null variant in any spelling A oneOf/anyOf was nullable only through a `{ type: 'null' }` variant. Four copies of that test (isNullSchema and isNullVariantSchema in schemas.ts, isNullSchema in pagination.ts, and inline checks in responses.ts and operations.ts) all read `type: 'null'` alone, so a variant written `{ const: null }` or `{ enum: [null] }`, which JSON Schema reads the same, was kept as a variant: the union gained an `unknown` or an empty enum (`PetTag []`), a wrapper did not collapse onto its one non-null branch, and a list envelope whose `data` was nullable that way was not unwrapped, so the operation was not paginated. isNullSchema in schemas.ts is now the one test. It also holds for `{ const: null }` and for an `enum:` list of nothing but `null`, each only with no `type` beside it naming another type and as the keyword that types the schema (classifyInlineSchema), so `{ enum: [null, 'x'] }`, `{ type: 'string', const: null }` and a `$ref` or composition beside them stay variants. isNullVariantSchema and pagination.ts's copy are gone, and responses.ts and operations.ts call it. schemaToTypeRef's union builder now sets aside every null variant, as nullableUnionBranch does, not only the first, so `oneOf: [X, { type: 'null' }, { enum: [null] }]` is `X | null` and mints no enum. Consequences, each matching the `{ type: 'null' }` spelling: such a wrapper collapses to `nullable`, a list envelope's data is unwrapped, a component `oneOf` of string consts beside such a null is a nullable enum, not a named union, and a named union drops the variant and makes its `$ref` nullable. The IR of every fixture spec and of the WorkOS specs is unchanged. Closes #394 --- docs/architecture/ir-types.md | 12 ++- src/parser/operations.ts | 7 +- src/parser/pagination.ts | 6 +- src/parser/responses.ts | 3 +- src/parser/schemas.ts | 91 +++++++++------- test/parser/operations.test.ts | 45 ++++++++ test/parser/pagination-page.test.ts | 24 +++++ test/parser/parse-pagination-envelope.test.ts | 48 +++++++++ test/parser/responses.test.ts | 30 ++++++ test/parser/schemas.test.ts | 101 ++++++++++++++++++ 10 files changed, 313 insertions(+), 54 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 4d5b54b4..509cb020 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -169,7 +169,7 @@ A map's value schema is typed the same way an array's items are: with the owning Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. -Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor `type: 'null'` — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context, so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and their children, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it, or an earlier variant of the union minted it; when the chosen context still mints a claimed name it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). +Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context, so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and their children, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it, or an earlier variant of the union minted it; when the chosen context still mints a claimed name it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). **`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the schema's own declaration kept where the variant declares the same property and the `required` names the union of both lists; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, two of arrays keep the `items` that say something, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it; a union no discriminator dispatches is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), two typing arrays keep the `items` that say something — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). @@ -186,6 +186,8 @@ interface UnionType { The `compositionKind` field tells emitters which OAS composition keyword produced the union, allowing them to distinguish inheritance (`allOf`) from exclusive union (`oneOf`) from open union (`anyOf`). +A variant that admits `null` alone is a `null` variant, in every spelling JSON Schema gives it — `{ type: 'null' }` (or `type: ['null']`), `{ const: null }`, and an `enum:` list of nothing but `null` (`{ enum: [null] }`) — wherever the union sits (`isNullSchema`, the one test every site makes): it is never one of `variants` and mints no model or enum, one or more of them make the union `nullable<…>`, and a union left with one other variant collapses to that variant, so `oneOf: [{ type: 'string' }, { const: null }]` is `nullable` and a list envelope whose `data` is `oneOf: [{ type: 'array', items: Pet }, { enum: [null] }]` is unwrapped as `Pet[] | null`. A `const` or `enum:` counts only as the keyword that types the variant (no `$ref` or composition beside it) and with no `type` beside it naming another type: `{ enum: [null, 'x'] }` and `{ type: 'string', const: null }` stay variants. + ### TypeParam ```typescript @@ -269,7 +271,7 @@ It is detected from a recognized envelope — a boolean `has_more`/`hasMore`/`ha The `dataPath` is dynamically detected from the response envelope — when `undefined`, emitters should treat the entire response as the data array or use their own default. -When the parser recognizes an inline list envelope — one array property plus a companion that looks like pagination metadata (a stop field in any spelling — `has_more`, `HasMore`, `total`, … — or a name whose snake_case spelling contains `meta`, `pagination`, `cursor`, `page_info`, `total`, `offset`, …, `pageInfo` and `page_info` alike) — and a query parameter the operation declares drives the iterator (the one detection found, or the one an override names), `Operation.response` becomes the bare array, and `envelopeFields` keeps everything else the envelope carries; an envelope nothing pages stays one plain response model, with no `pagination` (see the strategy table above). The array property is an array in any spelling, and two `allOf` declarations of it — one nullable, one not; one with `items`, one with limits alone — merge as the arrays they wrap (the later `items` when they narrow, the earlier otherwise, `null` admitted when either side admits it): `type: 'array'`, `items` with no `type`, or either under a nullable wrapper — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, or a `oneOf`/`anyOf` with a `null` variant, with or without `items` beside it — in which case `response` is `nullable>` while `itemType` is still the element (`ListEventsItem[] | null`, `dataPath: 'data'`). Any of these, or a `$ref` to an array component, is still the array when written as an `allOf` that `schemaToTypeRef` reads as its member (`readInlineAllOf`: one member with nothing beside it that adds to it, or `$ref` members with nothing inline beside them), at any depth and on either side of a nullable wrapper, as a field of that schema is typed: `allOf: [{ type: 'array', items: Pet }]` is `Pet[]` and `allOf: [{ oneOf: [{ type: 'array', items: Pet }, { type: 'null' }] }]` is `Pet[] | null`, each with `dataPath: 'data'`. Its items, whatever their shape, are typed as `Item` (see Inline model naming). `envelopeFields` keeps everything else the envelope carries: the pagination companion (`list_metadata`, `pagination`, `has_more`, `total`, …) and any discriminant such as `object: "list"`. Each entry is an ordinary `Field` (wire name, type, `required`, description), so `dataPath` + `itemType` + `envelopeFields` together describe the full shape of what the endpoint returns, and an emitter can both type the response and read the page details it needs for the next request: +When the parser recognizes an inline list envelope — one array property plus a companion that looks like pagination metadata (a stop field in any spelling — `has_more`, `HasMore`, `total`, … — or a name whose snake_case spelling contains `meta`, `pagination`, `cursor`, `page_info`, `total`, `offset`, …, `pageInfo` and `page_info` alike) — and a query parameter the operation declares drives the iterator (the one detection found, or the one an override names), `Operation.response` becomes the bare array, and `envelopeFields` keeps everything else the envelope carries; an envelope nothing pages stays one plain response model, with no `pagination` (see the strategy table above). The array property is an array in any spelling, and two `allOf` declarations of it — one nullable, one not; one with `items`, one with limits alone — merge as the arrays they wrap (the later `items` when they narrow, the earlier otherwise, `null` admitted when either side admits it): `type: 'array'`, `items` with no `type`, or either under a nullable wrapper — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, or a `oneOf`/`anyOf` with a `null` variant in any spelling (`{ type: 'null' }`, `{ const: null }`, `{ enum: [null] }`), with or without `items` beside it — in which case `response` is `nullable>` while `itemType` is still the element (`ListEventsItem[] | null`, `dataPath: 'data'`). Any of these, or a `$ref` to an array component, is still the array when written as an `allOf` that `schemaToTypeRef` reads as its member (`readInlineAllOf`: one member with nothing beside it that adds to it, or `$ref` members with nothing inline beside them), at any depth and on either side of a nullable wrapper, as a field of that schema is typed: `allOf: [{ type: 'array', items: Pet }]` is `Pet[]` and `allOf: [{ oneOf: [{ type: 'array', items: Pet }, { type: 'null' }] }]` is `Pet[] | null`, each with `dataPath: 'data'`. Its items, whatever their shape, are typed as `Item` (see Inline model naming). `envelopeFields` keeps everything else the envelope carries: the pagination companion (`list_metadata`, `pagination`, `has_more`, `total`, …) and any discriminant such as `object: "list"`. Each entry is an ordinary `Field` (wire name, type, `required`, description), so `dataPath` + `itemType` + `envelopeFields` together describe the full shape of what the endpoint returns, and an emitter can both type the response and read the page details it needs for the next request: ```typescript // GET /customers?page=&limit= → { customers: Customer[] ("The customers on this page."), pagination: { page, totalPages } } @@ -721,7 +723,7 @@ interface Field { A component schema that is itself a `oneOf`/`anyOf` — no `allOf`, no `properties` of its own, and not every non-null variant a string `const` (that is an enum; see Enum) — is a named union. It still becomes a `Model` (every component other than an enum or an array component does; see Array components below), and a `$ref` to it stays `{ kind: "model" }`, but the model carries the union: `variants` holds one `TypeRef` per non-null variant in document order, `compositionKind` says which keyword produced them, and `discriminator` is set when the union is discriminated. `fields` holds the inline variants' properties merged as optional fields (`UpdateOrganizationMembership`'s `role_slug` / `role_slugs`), so an emitter that reads only `fields` sees a plain object model, while one that reads `variants` emits the union from them. -The variants are built exactly as the same union written inline is (`UnionType`): a `$ref` variant is a model or enum ref; an inline object variant is referenced by a model extracted for it; a nullable (`type: ['object', 'null']`) or multi-type (`['object', 'string']`) inline variant keeps its wrapper around that model; a primitive stays a primitive; a `type: 'null'` variant is dropped from `variants` — an inline union lifts it into a `nullable` wrapper, and a `Model` has no wrapper to carry it — so, as for a nullable enum component, the nullability travels on the reference instead: every `$ref` to a named union that admits `null` (a `type: 'null'` variant, a `type` list with `'null'`, or OAS 3.0 `nullable: true`) resolves to `nullable`, the shape the same union written inline has. Every other variant — a primitive, an inline enum, an array, a map, an `allOf`, a nested union — is typed and extracted as the `variant_` property of one synthetic object under the union's name, where n is the variant's 1-based position among the non-null variants, so whatever it mints is named `Variant…` by the one rule a field's inline models follow (an inline enum second in the list is `PetVariant2`, an array's inline items third in the list are `PetVariant3`, a nested object under them `PetVariant3Child`), and two variants of the same kind can never share a name or a value list. When a name such a variant would mint is already a component's, or another named union's inline variant's, of a different shape, the context steps exactly as an array's or map's does beside a claiming sibling — `variant_1_item` / `variant_1_value` (`PetVariant1Item`, `PetVariant1Value`), then `variant_1_array_item` / `variant_1_member_value`, then numbered, with the same no-progress and attempt-cap guards — and every name nested under the variant moves with it, since both the reference and the extractor read the chosen context; a claimant of the identical shape is shared instead (the array's items then point at the component). +The variants are built exactly as the same union written inline is (`UnionType`): a `$ref` variant is a model or enum ref; an inline object variant is referenced by a model extracted for it; a nullable (`type: ['object', 'null']`) or multi-type (`['object', 'string']`) inline variant keeps its wrapper around that model; a primitive stays a primitive; the `null` variant (in any spelling, see UnionType) is dropped from `variants` — an inline union lifts it into a `nullable` wrapper, and a `Model` has no wrapper to carry it — so, as for a nullable enum component, the nullability travels on the reference instead: every `$ref` to a named union that admits `null` (a `null` variant, a `type` list with `'null'`, or OAS 3.0 `nullable: true`) resolves to `nullable`, the shape the same union written inline has. Every other variant — a primitive, an inline enum, an array, a map, an `allOf`, a nested union — is typed and extracted as the `variant_` property of one synthetic object under the union's name, where n is the variant's 1-based position among the non-null variants, so whatever it mints is named `Variant…` by the one rule a field's inline models follow (an inline enum second in the list is `PetVariant2`, an array's inline items third in the list are `PetVariant3`, a nested object under them `PetVariant3Child`), and two variants of the same kind can never share a name or a value list. When a name such a variant would mint is already a component's, or another named union's inline variant's, of a different shape, the context steps exactly as an array's or map's does beside a claiming sibling — `variant_1_item` / `variant_1_value` (`PetVariant1Item`, `PetVariant1Value`), then `variant_1_array_item` / `variant_1_member_value`, then numbered, with the same no-progress and attempt-cap guards — and every name nested under the variant moves with it, since both the reference and the extractor read the chosen context; a claimant of the identical shape is shared instead (the array's items then point at the component). Inline object variants are named by the inline-variant rules with the union's model name as the base, except that the base itself is taken by the union's own model, so variant 0 steps off it like every later variant: a const-discriminator prefix (`CatPet` for `kind: cat`), its title as a prefix (`CircleShape`), the first property no other variant declares (`UpdateAuditLogsRetentionDtoWithRetentionPeriod` beside `…WithRetentionPeriodInDays`), else the position counter (`Pet1`, `Pet2`). Every component schema's name is reserved, so a variant can never shadow a component (a variant titled `Cat` beside a component `Cat` takes `CatPet`), and so is every context-free title another named union keeps for a variant of a different shape — the comparison sibling field unions already make (`schemaShapeKey`), read symmetrically: when `Circle` is `{ radius }` on `Shape` and `{ diameter }` on `Marker`, both fall back (`CircleShape`, `CircleMarker`) and the mappings follow; when the shapes are identical, both keep `Circle` and one model is emitted. The component schemas are one root for the depth rule above as well, so a title a field union nested in any component gives a different shape (`Drawing.marker.shape`'s `Circle` as `{ diameter }`) is contested for the named union too, and both fall back (`CircleShape`, `CircleDrawingMarkerShape`). Each variant and everything nested under it — objects with or without `type`, array items, map values, `allOf`s, nested unions, the members of a multi-type variant — is materialized by the same inline extractor a field's union variants go through, so a reference always has a model behind it. Each extracted variant model is a complete model of its own, so the union's merged fields and the variant's fields are typed independently (an inline `config` object appears as both `PetConfig` and `CatPetConfig`). @@ -846,11 +848,11 @@ interface EnumValue { } ``` -**Two spellings declare an enum.** An `enum:` list, and — the OAS 3.1 way to document or deprecate values one by one — a `oneOf`/`anyOf` whose every non-null variant is a string `const` schema (a single-member string `enum` counts as a const). Under `components.schemas`, both become an `Enum`, and a `$ref` to either resolves to `{ kind: "enum" }`; a `oneOf` of consts there is an enum, not a named union, so `PetStatus: { oneOf: [{ const: "available" }, { const: "sold" }] }` is `enums[].PetStatus` with no `PetStatus` model. The same `oneOf` written inline on a field, parameter or header collapses to an inline enum (one of two or more consts; a single const with no `enum:` list beside it stays a `literal`). A union with any other variant — a `$ref`, an object, a number const — is a union, and a `oneOf` written beside `properties` or an `allOf` is not an enum. An `enum:` list written beside a `oneOf`/`anyOf` of consts applies together with it, as JSON Schema keywords do: the members are the consts whose value the list allows (a const the list leaves out is dropped, with its documentation), and a `type: 'null'` variant admits `null` only when the list does not leave `null` out — so `{ enum: ["a"], oneOf: [{ const: "a" }, { const: "b" }, { type: "null" }] }` is the one-value enum `a`, not nullable. Inline, the list is read the same way whatever it leaves — the members `constUnionEnumMembers` keeps and the nullability `enumListAdmitsNull` decides, the two helpers the component path reads: one member left is an inline enum of that member, none left is an inline enum with no members (as an empty `enum:` list is), never the union of every const the list forbade — so that schema on a field is the one-value inline enum `a`, not nullable, exactly as the component is. +**Two spellings declare an enum.** An `enum:` list, and — the OAS 3.1 way to document or deprecate values one by one — a `oneOf`/`anyOf` whose every non-null variant is a string `const` schema (a single-member string `enum` counts as a const). Under `components.schemas`, both become an `Enum`, and a `$ref` to either resolves to `{ kind: "enum" }`; a `oneOf` of consts there is an enum, not a named union, so `PetStatus: { oneOf: [{ const: "available" }, { const: "sold" }] }` is `enums[].PetStatus` with no `PetStatus` model. The same `oneOf` written inline on a field, parameter or header collapses to an inline enum (one of two or more consts; a single const with no `enum:` list beside it stays a `literal`). A union with any other variant — a `$ref`, an object, a number const — is a union, and a `oneOf` written beside `properties` or an `allOf` is not an enum. An `enum:` list written beside a `oneOf`/`anyOf` of consts applies together with it, as JSON Schema keywords do: the members are the consts whose value the list allows (a const the list leaves out is dropped, with its documentation), and a `null` variant (`{ type: 'null' }`, `{ const: null }` or `{ enum: [null] }`) admits `null` only when the list does not leave `null` out — so `{ enum: ["a"], oneOf: [{ const: "a" }, { const: "b" }, { type: "null" }] }` is the one-value enum `a`, not nullable. Inline, the list is read the same way whatever it leaves — the members `constUnionEnumMembers` keeps and the nullability `enumListAdmitsNull` decides, the two helpers the component path reads: one member left is an inline enum of that member, none left is an inline enum with no members (as an empty `enum:` list is), never the union of every const the list forbade — so that schema on a field is the one-value inline enum `a`, not nullable, exactly as the component is. **Enum description.** `Enum.description` is the enum schema's own `description`, present only when it has non-whitespace content and kept verbatim. For a component enum it is the component's, as a model's is `Model.description`. For an enum lifted into `spec.enums` from an inline schema — on a property, parameter, response header, request body, response body (primary, secondary-media-type, error or fallback) or webhook parameter or payload, including array items, `oneOf` variants and a `oneOf` of string consts collapsed into one enum — it is the text of the schema that declares the members: the `enum:` list's schema, or the `oneOf`/`anyOf` wrapper whose consts are the members, read where that schema's link is read (`inlineEnumRef` records both; `collectInlineEnumFromRef`, the one place an inline enum becomes an `Enum`, writes both). A nullable `oneOf`/`anyOf` or single-member `allOf` wrapper the type builder collapses is the schema the document wrote for the property, parameter, header or body, so its text is the enum's when it has content, and the branch's own counts only when the wrapper has none (`withWrapperDocs`, which carries the wrapper's text and link onto the branch by one rule, at the type builder's two wrapper sites and where the response path types the member of a single-member `allOf` body it unwrapped itself — through nested wrappers the outermost text with content wins); the `type: [..., 'null']`, `nullable: true` and `enum: [..., null]` spellings are one schema, so their text is the enum's as written. The model built from an object branch or member of such a wrapper documents itself by the same rule and at the same step (`unwrapNullableField` through `unwrapNullableSchema`, `unwrappedAllOfMember`, both reading `withWrapperDocs`): the wrapper's text when it has content, the branch's or member's own only when it has none, nested wrappers composing outermost-first. The text stays where it was as well: the `Field`, `Parameter` or `ResponseHeader` typed by the schema keeps its own `description` by its own rule, and an inline enum body's text is also the operation's `requestBodyDescription` (see Request Body Description). A Parameter Object's or Header Object's `description` is never the enum's: only a Schema Object's text is read. `Enum.description` is documentation, not shape: the differ does not report a change to it (no change carries a flag for it), and two inline enums with the same members that differ only in it are one enum. -**Per-value documentation.** Each `EnumValue` carries what the document attaches to that one value. `description` is the const member's own `description`; a value whose own text has no content — every value of an `enum:` list — takes its entry from the enum schema's `x-enum-descriptions` or `x-enumDescriptions` extension. Either key may hold an object mapping each value's text form (`"5"` for a numeric `5`) to its description, or a list aligned with the members as written (the `enum:` entries, or the `oneOf`/`anyOf` variants, a `null` entry or `type: 'null'` variant counted in its place); `x-enum-descriptions` is read when it holds an object or a list, `x-enumDescriptions` otherwise. Text reaches the IR only when it has non-whitespace content; the key is absent otherwise. `deprecated` is present, and `true`, only when the value's const member says `deprecated: true` — the literal `true`, as on every other node — so a value of an `enum:` list never carries it. Members are read by occurrence, not by value: a `oneOf`/`anyOf` that repeats a const yields one `EnumValue` per branch, each with its own documentation, inline and under `components.schemas` alike. Nullability is never a member: a `null` entry or a `type: 'null'` variant makes a `$ref` to the component resolve to `nullable`. +**Per-value documentation.** Each `EnumValue` carries what the document attaches to that one value. `description` is the const member's own `description`; a value whose own text has no content — every value of an `enum:` list — takes its entry from the enum schema's `x-enum-descriptions` or `x-enumDescriptions` extension. Either key may hold an object mapping each value's text form (`"5"` for a numeric `5`) to its description, or a list aligned with the members as written (the `enum:` entries, or the `oneOf`/`anyOf` variants, a `null` entry or `null` variant counted in its place); `x-enum-descriptions` is read when it holds an object or a list, `x-enumDescriptions` otherwise. Text reaches the IR only when it has non-whitespace content; the key is absent otherwise. `deprecated` is present, and `true`, only when the value's const member says `deprecated: true` — the literal `true`, as on every other node — so a value of an `enum:` list never carries it. Members are read by occurrence, not by value: a `oneOf`/`anyOf` that repeats a const yields one `EnumValue` per branch, each with its own documentation, inline and under `components.schemas` alike. Nullability is never a member: a `null` entry or a `null` variant makes a `$ref` to the component resolve to `nullable`. **Inline enums sharing a name.** When two inline enums lift to one name, the first lifted gives the enum its `description` and its value descriptions, as it gives its link — so a later copy's text is not read even when the first declared none. Per-value deprecation is part of the members, not documentation: a second copy with the same values that deprecates a different one is renamed (`PetsMode2`) before it is promoted, as a copy with other values is, so a field whose schema does not deprecate a value never resolves to an enum that marks it. Every pass that compares shapes to decide whether two models are one reads an inline enum the same way — each value with its deprecation (`shapeWithEnumFlags`, `enumShapeKey`): the materialization fingerprint header and body models share a registry entry by, the merge passes' identical-shape checks and the `Json` fold, the named-union shape key, and the companion and parameter-group fingerprints — so a copy differing only in that keeps its own model name, as a deprecated copy of a model does. The raw-schema comparison that lets two sibling inline objects share one model, or two unions' variants share a title (`schemaShapeKey`), reads `deprecated: true` as shape at every level for the same reason, a const member's included. The member key is a JSON pair of value and flag, so a live `"active!deprecated"` and a deprecated `"active"` never read alike, and a member list is compared as a complete sorted list, so a repeated member counts each time it occurs: `[a, a!, b]` and `[a, a, b]` are different types. With `--doc-url`, `Enum.description` has its relative links expanded exactly as `Model.description` and every `EnumValue.description` do. diff --git a/src/parser/operations.ts b/src/parser/operations.ts index d908118e..fafb8d2e 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -51,6 +51,7 @@ import { inlineEnumSchemaName, isComponentModelRef, isInlineObjectSchema, + isNullSchema, nonObjectVariantContexts, unwrapNullableField, warnOnUncarriedProperties, @@ -3590,7 +3591,7 @@ function collectBodyPropertyNames( for (const name of collectBodyPropertyNames(sub, componentSchemas, seen)) names.add(name); } for (const alternatives of [schema.oneOf, schema.anyOf]) { - const branches = (alternatives ?? []).filter((alt) => alt.type !== 'null'); + const branches = (alternatives ?? []).filter((alt) => !isNullSchema(alt)); if (branches.length === 0) continue; let common: Set | undefined; for (const branch of branches) { @@ -3916,7 +3917,7 @@ function typePickedRequestBody( if (variant.$ref) { const ref = schemaToTypeRef(variant, contextName); variants.push(ref); - } else if (variant.type === 'null') { + } else if (isNullSchema(variant)) { // skip null variant in union, handle separately } else { const variantName = objectVariantNames.get(variant); @@ -3932,7 +3933,7 @@ function typePickedRequestBody( } const hasNull = schema.oneOf.some( - (v: SchemaObject) => v.type === 'null' || (Array.isArray(v.type) && v.type.includes('null')), + (v: SchemaObject) => isNullSchema(v) || (Array.isArray(v.type) && v.type.includes('null')), ); const union: TypeRef = { kind: 'union', diff --git a/src/parser/pagination.ts b/src/parser/pagination.ts index 2aed7dd6..3d06de5f 100644 --- a/src/parser/pagination.ts +++ b/src/parser/pagination.ts @@ -8,7 +8,7 @@ import type { } from '../ir/types.js'; import type { PaginationHint } from '../ir/operation-hints.js'; import type { SchemaObject } from './schemas.js'; -import { classifyInlineSchema, resolveComponentSchemaRef, unwrapNullableSchema } from './schemas.js'; +import { classifyInlineSchema, isNullSchema, resolveComponentSchemaRef, unwrapNullableSchema } from './schemas.js'; import { toSnakeCase } from '../utils/naming.js'; import { combineDeclarations, readInlineAllOf } from './inline-models.js'; @@ -534,10 +534,6 @@ export function resolveBareSchema( return lone ? resolveBareSchema(lone, componentSchemas, seen) : bare; } -function isNullSchema(schema: SchemaObject): boolean { - return schema.type === 'null' || (Array.isArray(schema.type) && schema.type.every((t) => t === 'null')); -} - /** * The properties of an object schema as `collectObjectProperties` collects * them — the one walk through `$ref`s, `allOf` members and nullable wrappers, diff --git a/src/parser/responses.ts b/src/parser/responses.ts index afedc692..06b35539 100644 --- a/src/parser/responses.ts +++ b/src/parser/responses.ts @@ -22,6 +22,7 @@ import { foldSiblingProperties, isArrayComponentRef, isArraySchema, + isNullSchema, collapsedNullableBranch, typesAsUnknown, warnOnUncarriedProperties, @@ -764,7 +765,7 @@ function extractWrappedResource( inlineModels.push(...extractInlineModel(modelName, source, componentSchemas)); const hasNullVariant = propSchema.oneOf.some( - (v) => v.type === 'null' || (Array.isArray(v.type) && v.type.includes('null')), + (v) => isNullSchema(v) || (Array.isArray(v.type) && v.type.includes('null')), ); const modelRef: TypeRef = { kind: 'model', name: modelName }; diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index 616199ca..30ce5b48 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -174,19 +174,19 @@ function nonNullEnumValues(values: SchemaObject['enum']): (string | number)[] { * True when an enum schema admits `null` by any OAS 3.0 / 3.1 spelling: the * `nullable: true` flag, a `type` list naming `'null'`, a `null` member in * `enum:`, or — for an enum declared as a `oneOf`/`anyOf` of `const` - * schemas — a `type: 'null'` variant that an `enum:` list beside the union - * does not forbid (`enumListAdmitsNull`). + * schemas — a `null` variant (`isNullSchema`) that an `enum:` list beside + * the union does not forbid (`enumListAdmitsNull`). */ function isNullableEnumSchema(schema: SchemaObject): boolean { if (schema.nullable === true) return true; if (Array.isArray(schema.type) && schema.type.includes('null')) return true; if (Array.isArray(schema.enum) && schema.enum.includes(null)) return true; - return (schema.oneOf ?? schema.anyOf ?? []).some(isNullVariantSchema) && enumListAdmitsNull(schema); + return (schema.oneOf ?? schema.anyOf ?? []).some(isNullSchema) && enumListAdmitsNull(schema); } /** * Whether an `enum:` list written beside a `oneOf`/`anyOf` of consts lets a - * `type: 'null'` variant admit `null`. The keywords apply together, so a + * `null` variant admit `null`. The keywords apply together, so a * list that leaves `null` out forbids it whatever the variant says; a schema * with no list admits it. */ @@ -258,8 +258,8 @@ function enumListMembers(schema: SchemaObject): EnumMember[] | null { /** * The members a `oneOf`/`anyOf` of string `const` schemas declares — a - * single-member string `enum` is the same thing — once its `type: 'null'` - * variants are set aside: each variant's `const`, with the variant's own + * single-member string `enum` is the same thing — once its `null` variants + * are set aside: each variant's `const`, with the variant's own * `description` and, when it says `deprecated: true`, `deprecated` (the * literal `true` only, as `deprecatedEntry` reads it everywhere). A member * whose own `description` has no content takes the schema's @@ -268,15 +268,15 @@ function enumListMembers(schema: SchemaObject): EnumMember[] | null { * written. An `enum:` list written beside the union applies together with * it, as JSON Schema keywords do: a member whose value the list leaves out * is dropped (so the list may leave no member at all), and the list decides - * whether a `type: 'null'` variant admits `null` (`enumListAdmitsNull`). - * Null when the schema has no `oneOf`/`anyOf`, when every variant is - * `type: 'null'`, or when any variant is anything but a string const — a + * whether a `null` variant admits `null` (`enumListAdmitsNull`). + * Null when the schema has no `oneOf`/`anyOf`, when every variant is a + * `null` variant, or when any variant is anything but a string const — a * `$ref`, an object, a number const — since such a union is not an enum. */ function constUnionEnumMembers(schema: SchemaObject): EnumMember[] | null { const variants = (schema.oneOf ?? schema.anyOf) ?.map((variant, index) => ({ variant, index })) - .filter(({ variant }) => !isNullVariantSchema(variant)); + .filter(({ variant }) => !isNullSchema(variant)); if (!variants || variants.length === 0) return null; const allowed = schema.enum ? new Set(nonNullEnumValues(schema.enum).map((v) => (typeof v === 'number' ? v : String(v)))) @@ -508,10 +508,29 @@ function singleNonNullBranch(schema: SchemaObject): SchemaObject | undefined { return nonNull.length === 1 && !nonNull[0].$ref ? nonNull[0] : undefined; } -/** True for the `{ type: 'null' }` / `{ type: ['null'] }` branch of a nullable wrapper. */ +/** + * True for the `null` variant of a `oneOf`/`anyOf` — the branch that makes a + * union nullable, never a variant to type or name — in each spelling of a + * schema that admits `null` alone: `{ type: 'null' }` / `{ type: ['null'] }`, + * `{ const: null }`, and an `enum:` list of nothing but `null` + * (`{ enum: [null] }`). A `const` or `enum:` counts only with no `type` + * beside it and as the keyword that types the schema + * (`classifyInlineSchema`), so `{ type: 'string', const: null }` and a + * `$ref` or composition written beside one stay variants, as + * `{ enum: [null, 'x'] }` does. The one test of every site that sets a + * union's null variant aside. + */ export function isNullSchema(schema: SchemaObject): boolean { + const { type } = schema; + if (type === 'null' || (Array.isArray(type) && type.length === 1 && type[0] === 'null')) return true; + if (type !== undefined) return false; + if (schema.const === null) return classifyInlineSchema(schema) === 'const'; + const values = schema.enum; return ( - schema.type === 'null' || (Array.isArray(schema.type) && schema.type.length === 1 && schema.type[0] === 'null') + Array.isArray(values) && + values.length > 0 && + values.every((value) => value === null) && + classifyInlineSchema(schema) === 'enum' ); } @@ -580,10 +599,10 @@ export function unwrapNullableSchema( /** * The one variant a nullable `oneOf`/`anyOf` wrapper collapses to: what is - * left once every `null` variant (`isNullSchema` — `{ type: 'null' }` or - * `{ type: ['null'] }`, however many of either) is discarded, when at least - * one was and exactly one variant remains. The single definition of a - * nullable union wrapper on the model side, shared by `unwrapNullableSchema` + * left once every `null` variant (`isNullSchema` — `{ type: 'null' }`, + * `{ const: null }` or `{ enum: [null] }`, however many) is discarded, when + * at least one was and exactly one variant remains. The single definition of + * a nullable union wrapper on the model side, shared by `unwrapNullableSchema` * and `collapsedNullableBranch`. `undefined` otherwise: no union, a union * under `allOf` (which `schemaToTypeRef` gives precedence), no `null` * variant, or more than one non-null variant. Exported for the response @@ -1785,7 +1804,7 @@ export function nameInlineVariantEntries( reserved: ReadonlySet = NO_RESERVED, baseTaken = false, ): Map { - const nonNullVariants = (union.oneOf ?? union.anyOf ?? []).filter((v) => !isNullVariantSchema(v)); + const nonNullVariants = (union.oneOf ?? union.anyOf ?? []).filter((v) => !isNullSchema(v)); const inlineObjectVariants = nonNullVariants.filter((v) => isInlineObjectSchema(v)); const discriminator = deriveConstNamingDiscriminator(inlineObjectVariants); const titles = titleMappedVariantNames(union); @@ -1833,7 +1852,7 @@ export function nameInlineVariantEntries( export function titleMappedVariantNames(union: SchemaObject): ReadonlyMap { const names = new Map(); if (union.oneOf === undefined || union.discriminator) return names; - const nonNullVariants = union.oneOf.filter((v) => !isNullVariantSchema(v)); + const nonNullVariants = union.oneOf.filter((v) => !isNullSchema(v)); if (titleMappedDiscriminatorProperty(nonNullVariants) === null) return names; for (const variant of nonNullVariants) { if (!isInlineObjectSchema(variant)) continue; @@ -1879,11 +1898,11 @@ export function isInlineObjectSchema(schema: SchemaObject | undefined): schema i return type === undefined || type === 'object' || (Array.isArray(type) && type.includes('object')); } -/** True when a named union admits `null`: a `type: 'null'` variant, a `type` list with `'null'`, or OAS 3.0 `nullable: true`. */ +/** True when a named union admits `null`: a `null` variant (`isNullSchema`), a `type` list with `'null'`, or OAS 3.0 `nullable: true`. */ function isNullableUnionSchema(schema: SchemaObject): boolean { if (schema.nullable === true) return true; if (Array.isArray(schema.type) && schema.type.includes('null')) return true; - return (schema.oneOf ?? schema.anyOf ?? []).some(isNullVariantSchema); + return (schema.oneOf ?? schema.anyOf ?? []).some(isNullSchema); } /** @@ -2030,7 +2049,7 @@ export function nonObjectVariantContexts( const keptTitles = new Map(); let position = 0; for (const variant of variants) { - if (isNullVariantSchema(variant)) continue; + if (isNullSchema(variant)) continue; position++; if (objectVariantNames.has(variant)) continue; const member = unwrapNullableSchema(variant); @@ -2171,13 +2190,6 @@ function unionTypesSchema(variants: readonly SchemaObject[]): boolean { ); } -/** The `{ type: 'null' }` member that marks a `oneOf`/`anyOf` as nullable; never a variant to name. */ -function isNullVariantSchema(schema: SchemaObject): boolean { - return ( - schema.type === 'null' || (Array.isArray(schema.type) && schema.type.length === 1 && schema.type[0] === 'null') - ); -} - /** * The schema `schemaToTypeRef` and every extractor dispatch on when a schema * writes `properties` beside a composition that types it: the composition, @@ -2336,7 +2348,7 @@ export function warnOnUncarriedProperties( if (reportedUncarriedProperties.has(variants)) return; const uncarried: number[] = []; variants.forEach((variant, index) => { - if (isNullVariantSchema(variant) || isInlineObjectSchema(variant)) return; + if (isNullSchema(variant) || isInlineObjectSchema(variant)) return; const kind = classifyInlineSchema(unwrapNullableSchema(variant)); if (kind === 'ref' || kind === 'allOf' || kind === 'union' || kind === 'map' || kind === 'object') { uncarried.push(index + 1); @@ -2882,7 +2894,7 @@ function isNamedUnionSchema(schema: SchemaObject): boolean { if (Object.keys(schema.properties ?? {}).length > 0) return false; // A `oneOf`/`anyOf` of string consts is an enum (`isEnumComponentSchema`), not a union. if (isConstUnionEnumSchema(schema)) return false; - const nonNull = (schema.oneOf ?? schema.anyOf ?? []).filter((v) => !isNullVariantSchema(v)); + const nonNull = (schema.oneOf ?? schema.anyOf ?? []).filter((v) => !isNullSchema(v)); return !(nonNull.length === 1 && isPlainObjectSchema(nonNull[0])); } @@ -2998,7 +3010,7 @@ function namedUnionMembers( ): NamedUnionMember[] { const members: NamedUnionMember[] = []; for (const raw of schema.oneOf ?? schema.anyOf ?? []) { - if (isNullVariantSchema(raw)) continue; + if (isNullSchema(raw)) continue; let resolved = raw; if (raw.$ref) { const refName = raw.$ref.split('/').pop() ?? ''; @@ -4503,9 +4515,11 @@ export function schemaToTypeRef( const compositionKind: 'oneOf' | 'anyOf' = schema.oneOf ? 'oneOf' : 'anyOf'; const rawVariants: SchemaObject[] = schema.oneOf ?? schema.anyOf ?? []; - // Check for nullable pattern: oneOf: [realType, { type: 'null' }] - const nullVariant = rawVariants.find(isNullVariantSchema); - const nonNullVariants = rawVariants.filter((v) => v !== nullVariant); + // Check for nullable pattern: oneOf: [realType, { type: 'null' }]. Every + // `null` variant is set aside, however many and however spelled + // (`isNullSchema`), as `nullableUnionBranch` sets them aside. + const nonNullVariants = rawVariants.filter((v) => !isNullSchema(v)); + const hasNull = nonNullVariants.length < rawVariants.length; // Collapse an oneOf/anyOf whose non-null variants are all string-const // schemas into a single enum. This turns patterns like @@ -4538,10 +4552,10 @@ export function schemaToTypeRef( ); // An `enum:` list beside the union that leaves `null` out forbids it, // whatever the `type: 'null'` variant says (`enumListAdmitsNull`). - return nullVariant && enumListAdmitsNull(schema) ? { kind: 'nullable', inner: enumRef } : enumRef; + return hasNull && enumListAdmitsNull(schema) ? { kind: 'nullable', inner: enumRef } : enumRef; } - if (nullVariant && nonNullVariants.length === 1) { + if (hasNull && nonNullVariants.length === 1) { // Nullable single type — unwrap as nullable. `asNullable` keeps a // `$ref` to an already-nullable enum component from double-wrapping. // The wrapper's `description` and link ride onto the branch @@ -4660,8 +4674,7 @@ export function schemaToTypeRef( inlineVariantNames, reservedNames, ); - for (const v of rawVariants) { - if (v === nullVariant) continue; + for (const v of nonNullVariants) { const inlineName = inlineVariantNames.get(v); if (inlineName) { const mappedName = resolvedDiscriminator ? mapVariantToDiscriminatorEntry(v, resolvedDiscriminator) : null; @@ -4675,8 +4688,6 @@ export function schemaToTypeRef( ); } } - const hasNull = !!nullVariant; - const union: TypeRef = { kind: 'union', variants, diff --git a/test/parser/operations.test.ts b/test/parser/operations.test.ts index 0e6d8930..e0c2436c 100644 --- a/test/parser/operations.test.ts +++ b/test/parser/operations.test.ts @@ -1718,6 +1718,51 @@ describe('body-owned query parameters: composition, groups, and body refs', () = expect(op.bodyOwnedQueryParams?.map((p) => p.name)).toEqual(['client_id']); }); + it('sets a null alternative aside however it is spelled, the body typed nullable (#394)', () => { + const extract = (nullVariant: SchemaObject) => + extractOperations({ + '/sso/token': { + post: { + operationId: 'SsoController_token', + parameters: [q('client_id'), q('code'), q('subject_token')], + requestBody: jsonBody({ + oneOf: [obj('client_id', 'code'), obj('client_id', 'subject_token'), nullVariant], + }), + responses: ok, + }, + }, + '/sso/logout': { + post: { + operationId: 'SsoController_logout', + parameters: [q('session_id')], + requestBody: jsonBody({ oneOf: [obj('session_id'), nullVariant] }), + responses: ok, + }, + }, + }); + const expected = extract({ type: 'null' }); + const [token, logout] = expected.services[0].operations; + expect(token.requestBody).toEqual({ + kind: 'nullable', + inner: { + kind: 'union', + variants: [ + { kind: 'model', name: 'SSOTokenRequest' }, + { kind: 'model', name: 'SSOTokenRequestWithSubjectToken' }, + ], + }, + }); + expect(token.bodyOwnedQueryParams?.map((p) => p.name)).toEqual(['client_id']); + expect(logout.requestBody).toEqual({ + kind: 'nullable', + inner: { kind: 'union', variants: [{ kind: 'model', name: 'SSOLogoutRequest' }] }, + }); + expect(logout.bodyOwnedQueryParams?.map((p) => p.name)).toEqual(['session_id']); + for (const spelling of [{ const: null }, { enum: [null] }]) { + expect(extract(spelling), JSON.stringify(spelling)).toEqual(expected); + } + }); + it('unions allOf members and intersects anyOf alternatives', () => { const paths = { '/sso/token': { diff --git a/test/parser/pagination-page.test.ts b/test/parser/pagination-page.test.ts index 8074d8b2..f7747566 100644 --- a/test/parser/pagination-page.test.ts +++ b/test/parser/pagination-page.test.ts @@ -152,6 +152,15 @@ describe('resolveSchemaKeyword', () => { expect(resolveSchemaKeyword({ anyOf: [{ type: 'null' }, { default: 4 }] }, 'default')).toBe(4); }); + it('reads through a nullable wrapper whose null is `{ const: null }` or `{ enum: [null] }` (#394)', () => { + for (const nullVariant of [{ const: null }, { enum: [null] }]) { + expect( + resolveSchemaKeyword({ anyOf: [nullVariant, { default: 4 }] }, 'default'), + JSON.stringify(nullVariant), + ).toBe(4); + } + }); + it('returns undefined when no level declares the keyword, and survives a self-referencing $ref', () => { expect(resolveSchemaKeyword({ type: 'integer' }, 'default')).toBeUndefined(); expect(resolveSchemaKeyword({ oneOf: [{ default: 1 }, { default: 2 }] }, 'default')).toBeUndefined(); @@ -1182,4 +1191,19 @@ describe('bodyEndOfIterationProblem — an explicit stop field, read where a det 'endOfIteration path "meta.has_more" is not a boolean field, as kind "hasMore" requires', ); }); + + it('sets a null variant on the path aside however it is spelled (#394)', () => { + const object = (properties: Record): SchemaObject => ({ type: 'object', properties }); + const boolean: SchemaObject = { type: 'boolean' }; + for (const nullVariant of [{ const: null }, { enum: [null] }]) { + const meta = { + anyOf: [object({ has_more: boolean }), object({ has_more: boolean, page: boolean }), nullVariant], + }; + const body = object({ data: { type: 'array', items: { type: 'string' } }, meta }); + expect( + bodyEndOfIterationProblem({ kind: 'hasMore', path: 'meta.has_more' }, body), + JSON.stringify(nullVariant), + ).toBeUndefined(); + } + }); }); diff --git a/test/parser/parse-pagination-envelope.test.ts b/test/parser/parse-pagination-envelope.test.ts index b4c2a779..c164f777 100644 --- a/test/parser/parse-pagination-envelope.test.ts +++ b/test/parser/parse-pagination-envelope.test.ts @@ -544,6 +544,54 @@ describe('parseSpec — pagination envelope fields', () => { expect(ir).toEqual(await parseList(withTags(tags))); }); }); + + describe('a null variant written as `{ const: null }` or `{ enum: [null] }`', () => { + const nulls = (nickname: unknown, tag: unknown, data: unknown) => ({ + openapi: '3.1.0', + info: { title: 'nulls', version: '1' }, + paths: { + '/pets': listOp({}, 'listPets', [{ name: 'after', in: 'query', schema: { type: 'string' } }], { + type: 'object', + properties: { + data: { oneOf: [{ type: 'array', items: { $ref: '#/components/schemas/Pet' } }, data] }, + list_metadata: { type: 'object', properties: { after: { type: 'string' } } }, + }, + }), + }, + components: { + schemas: { + Pet: { + type: 'object', + properties: { + nickname: { oneOf: [{ type: 'string' }, nickname] }, + tag: { oneOf: [{ type: 'string' }, tag] }, + }, + }, + }, + }, + }); + const typeNull = { type: 'null' }; + const nullableString: TypeRef = { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } }; + + it('parses as `{ type: "null" }` does: no unknown, no empty enum, the list envelope unwrapped (#394 repro)', async () => { + const expected = await parseJson(nulls(typeNull, typeNull, typeNull)); + for (const data of [{ const: null }, { enum: [null] }]) { + const ir = await parseJson(nulls({ const: null }, { enum: [null] }, data)); + const [op] = ir.services.flatMap((s) => s.operations); + expect(op.response, JSON.stringify(data)).toEqual({ + kind: 'nullable', + inner: { kind: 'array', items: { kind: 'model', name: 'Pet' } }, + }); + expect(op.pagination?.dataPath, JSON.stringify(data)).toBe('data'); + expect(ir.models.find((m) => m.name === 'Pet')?.fields.map((f) => [f.name, f.type])).toEqual([ + ['nickname', nullableString], + ['tag', nullableString], + ]); + expect(ir.enums, JSON.stringify(data)).toEqual([]); + expect(ir, JSON.stringify(data)).toEqual(expected); + } + }); + }); }); describe('validateModelRefs — pagination envelope fields', () => { diff --git a/test/parser/responses.test.ts b/test/parser/responses.test.ts index c2af6cc4..8e4265ca 100644 --- a/test/parser/responses.test.ts +++ b/test/parser/responses.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { classifyAndExtractResponse } from '../../src/parser/responses.js'; +import type { SchemaObject } from '../../src/parser/schemas.js'; describe('classifyAndExtractResponse', () => { describe('$ref schemas', () => { @@ -166,6 +167,35 @@ describe('classifyAndExtractResponse', () => { expect(result.isPaginated).toBe(false); }); + it('unwraps the nullable wrapper whose null is `{ const: null }` or `{ enum: [null] }` (#394)', () => { + const wrapper = (nullVariant: SchemaObject): SchemaObject => ({ + type: 'object', + properties: { + api_key: { + deprecated: true, + oneOf: [ + { + type: 'object', + properties: { object: { type: 'string', const: 'api_key' }, id: { type: 'string' } }, + required: ['object', 'id'], + }, + nullVariant, + ], + }, + }, + required: ['api_key'], + }); + const expected = classifyAndExtractResponse(wrapper({ type: 'null' }), 'ValidateApiKeyResponse'); + expect(expected.response).toEqual({ kind: 'nullable', inner: { kind: 'model', name: 'ApiKey' } }); + expect(expected.inlineModels.map((m) => [m.name, m.deprecated])).toEqual([['ApiKey', true]]); + for (const spelling of [{ const: null }, { enum: [null] }]) { + expect( + classifyAndExtractResponse(wrapper(spelling), 'ValidateApiKeyResponse'), + JSON.stringify(spelling), + ).toEqual(expected); + } + }); + it('does not match wrapper with multiple required properties', () => { const schema = { type: 'object', diff --git a/test/parser/schemas.test.ts b/test/parser/schemas.test.ts index ca94a3a1..7a3caa64 100644 --- a/test/parser/schemas.test.ts +++ b/test/parser/schemas.test.ts @@ -5,6 +5,7 @@ import { schemaToTypeRef, clearSchemaNameTransform, } from '../../src/parser/schemas.js'; +import type { SchemaObject } from '../../src/parser/schemas.js'; describe('extractSchemas – backend suffix handling', () => { it('preserves Dto suffix in schema names', () => { @@ -707,6 +708,106 @@ describe('nullable enum components (#174)', () => { }); }); +describe('a null variant written as `{ const: null }` or `{ enum: [null] }` (#394)', () => { + afterEach(() => clearSchemaNameTransform()); + + const components = (nullVariant: SchemaObject): Record => ({ + Thing: { + type: 'object', + properties: { + name: { anyOf: [{ type: 'string', maxLength: 8 }, nullVariant] }, + status: { $ref: '#/components/schemas/Status' }, + inline_status: { oneOf: [{ const: 'on' }, { const: 'off' }, nullVariant] }, + pet: { $ref: '#/components/schemas/Pet' }, + }, + }, + Status: { oneOf: [{ const: 'on' }, { const: 'off' }, nullVariant] }, + Pet: { oneOf: [{ $ref: '#/components/schemas/Cat' }, { $ref: '#/components/schemas/Dog' }, nullVariant] }, + Cat: { type: 'object', properties: { meow: { type: 'string' } } }, + Dog: { type: 'object', properties: { bark: { type: 'string' } } }, + }); + + it('is set aside as `{ type: "null" }` is: a wrapper collapses, an enum of consts and a named union admit null', () => { + const expected = extractSchemas(components({ type: 'null' })); + const thing = expected.models.find((m) => m.name === 'Thing')!; + expect(Object.fromEntries(thing.fields.map((f) => [f.name, f.type]))).toEqual({ + name: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } }, + status: { kind: 'nullable', inner: { kind: 'enum', name: 'Status' } }, + inline_status: { kind: 'nullable', inner: { kind: 'enum', name: 'ThingInlineStatus', values: ['on', 'off'] } }, + pet: { kind: 'nullable', inner: { kind: 'model', name: 'Pet' } }, + }); + expect(thing.fields.find((f) => f.name === 'name')?.constraints).toEqual({ maxLength: 8 }); + expect(expected.enums.map((e) => [e.name, e.values.map((v) => v.value)])).toEqual([ + ['Status', ['on', 'off']], + ['ThingInlineStatus', ['on', 'off']], + ]); + for (const spelling of [ + { const: null }, + { enum: [null] }, + { enum: [null, null] }, + { nullable: true, enum: [null] }, + { const: null, description: 'No value.' }, + ]) { + expect(extractSchemas(components(spelling)), JSON.stringify(spelling)).toEqual(expected); + } + }); + + it('sets aside every null variant of a union, however many and however mixed', () => { + const thing = (...nulls: SchemaObject[]): Record => ({ + Thing: { + type: 'object', + properties: { + tag: { oneOf: [{ type: 'string' }, ...nulls] }, + pet: { oneOf: [{ type: 'object', properties: { name: { type: 'string' } } }, ...nulls] }, + }, + }, + }); + const expected = extractSchemas(thing({ type: 'null' })); + expect(expected.models.find((m) => m.name === 'Thing')?.fields.map((f) => f.type)).toEqual([ + { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } }, + { kind: 'nullable', inner: { kind: 'model', name: 'ThingPet' } }, + ]); + expect(expected.enums).toEqual([]); + for (const nulls of [ + [{ type: 'null' }, { type: 'null' }], + [{ type: 'null' }, { enum: [null] }], + [{ const: null }, { enum: [null] }], + ]) { + expect(extractSchemas(thing(...nulls)), JSON.stringify(nulls)).toEqual(expected); + } + }); + + it('keeps a variant that admits another value, or that a `type`, `$ref` or composition beside it types', () => { + const string = { kind: 'primitive', type: 'string' }; + const cases: Array<[SchemaObject, unknown]> = [ + [{ enum: [null, 'x'] }, { kind: 'nullable', inner: { kind: 'literal', value: 'x' } }], + [ + { enum: [null], const: 'x' }, + { kind: 'literal', value: 'x' }, + ], + [ + { type: 'string', const: null }, + { kind: 'nullable', inner: { kind: 'primitive', type: 'unknown' } }, + ], + [ + { $ref: '#/components/schemas/Cat', const: null }, + { kind: 'model', name: 'Cat' }, + ], + [ + { allOf: [{ $ref: '#/components/schemas/Cat' }], const: null }, + { kind: 'model', name: 'Cat' }, + ], + ]; + for (const [variant, typed] of cases) { + expect(schemaToTypeRef({ anyOf: [{ type: 'string' }, variant] }, 'name'), JSON.stringify(variant)).toEqual({ + kind: 'union', + variants: [string, typed], + compositionKind: 'anyOf', + }); + } + }); +}); + describe('extractSchemas – schemaNameTransform receives the original schema name (issue #175)', () => { // extractSchemas() leaves the transform active for later extraction phases; // clear it so no other test in this file sees it. From f0017c94015be6472344489f12708008e862cf48 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 27 Oct 2026 18:18:50 -0400 Subject: [PATCH 24/59] docs(ir): state the totalPages stop under cursor The parser hands an envelope's totalPages stop to an iterator of any strategy, a cursor one included: buildOperation spreads the response's stop field onto the detected iterator and passes it to an override that creates one, and pagination-undeclared-param.test.ts pins a cursor iterator on `after` that stops on `total_pages`. Since #370, emitter-contract.md and the generator guide give that stop its cursor rule, the number of requests made, but ir-types.md, which defines PaginationMeta, and the PaginationEndOfIteration doc comment it mirrors still gave the check for page and offset only, so an emitter that follows them had no rule for the stop a cursor iterator receives. Both now give the cursor rule beside the other two: an opaque cursor carries no page number, so the pages fetched so far are the requests made. The IR does not change. Closes #434 --- docs/architecture/ir-types.md | 2 +- src/ir/types.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 509cb020..336ebfdd 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -264,7 +264,7 @@ A cursor param wins over an offset param, which wins over a page param, when an Every iterator stops on an empty page. `endOfIteration`, when present, lets it stop one request sooner: - `hasMore` — a boolean that is `false` on the last page. -- `totalPages` — an integer count of pages; stop once the pages fetched so far reach it. Under `page` that is `page - pageStart + 1 >= totalPages` (`page >= totalPages` for a 1-based API, `page + 1 >= totalPages` for a 0-based one); under `offset` it is `floor(offset / limit) + 1 >= totalPages`. +- `totalPages` — an integer count of pages; stop once the pages fetched so far reach it. Under `page` that is `page - pageStart + 1 >= totalPages` (`page >= totalPages` for a 1-based API, `page + 1 >= totalPages` for a 0-based one); under `offset` it is `floor(offset / limit) + 1 >= totalPages`; under `cursor`, whose opaque cursor carries no page number, it is the number of requests made so far `>= totalPages`. - `totalCount` — an integer count of items; stop once the items received so far reach it. It is detected from a recognized envelope — a boolean `has_more`/`hasMore`/`has_next`/`hasNext`/`has_next_page`/`hasNextPage`, an integer `total_pages`/`totalPages`/`page_count`/`pageCount`, or an integer `total`/`total_count`/`totalCount`/`total_items`/`totalItems`/`total_results`/`totalResults`, each in any spelling (its snake_case form is matched, as a companion's is below: `HasMore`, `has-more` and `HAS_MORE` are `has_more`, so `{ data, PageInfo: { HasNextPage } }` stops on `PageInfo.HasNextPage`) — beside the array first, when the `dataPath` is nested: the other properties of the object the array sits in, under whatever name (`result.total_pages` beside `result.items`, `data.page.has_more` beside `data.page.items`), then those of each object the path passes through on its way there, nearest the array first (`result.total_pages` beside `result.page` for `result.page.items`), every one reached as the path itself is resolved — one property walk (`collectObjectProperties`) reads a wrapper's companions, its stop field, each metadata companion and every segment of a `dataPath`, through `$ref`s, `allOf` members and nullable wrappers, settling a name two members declare as the model does (`combineDeclarations`); then at the top level; then one level down inside each top-level companion object whose name says it is pagination metadata (`meta`, `pagination`, `page_info`, `cursor`, …, in any spelling: the name's snake_case form is matched, so `pageInfo`, `PageInfo` and `page-info` are read exactly as `page_info` is — `{ data, pageInfo: { hasNextPage } }` is `string[]` with a `PageInfo` model and the stop field `pageInfo.hasNextPage` — while a name whose snake_case form carries none of these words (`nameTag`, whose `name_tag` spells `meta` only across a word boundary) marks no envelope, so `{ data, nameTag }` is a plain `ListPetsResponse` model; a name is read as several words only where it separates them, by a change of case or a separator such as `_` or `-`, so no pattern or stop-field name of several words is found in a name that runs them together: `pageinfo` and `PAGEINFO` are no `page_info` and `hasmore` is no `has_more`, so `{ data, pageinfo: { hasNextPage } }` and `{ data, hasmore }` are plain `ListPetsResponse` models, while a one-word pattern is still found inside such a name (`listmetadata` holds `meta`); inline, `$ref`, `allOf`, or a nullable wrapper) — a `stats.total` beside `pagination.page` is not the list's size, and nothing the path passes through, the array or an object on the way to it, is ever a stop field — and the path follows the `dataPath` convention, each segment spelled as the document spells it (`pageInfo.hasNextPage`); a name holding a `.` cannot be one segment of such a path, so it is never a stop field and a companion so named is not looked inside (`{ data, 'has.more', total }` stops on `total`, and `{ data, 'has.next' }` is no envelope). The body an explicit `dataPath` keeps as a wrapper (below) is read the same way. When several candidates exist, `hasMore` beats `totalPages`, which beats `totalCount`. List-envelope recognition shares that list of names and reads a name the same way, so an envelope whose only companion is a stop field in any spelling — `{ data, has_more }`, `{ data, hasMore }`, `{ data, HasNext }`, `{ data, page-count }` — is recognized like any other: unwrapped when a declared query parameter drives the iterator, kept as a plain response model when none does (see the strategy table above). diff --git a/src/ir/types.ts b/src/ir/types.ts index 914f1622..ee6aeacc 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -649,8 +649,10 @@ export type PaginationStrategy = 'cursor' | 'offset' | 'page' | 'link-header'; * - `totalPages` — an integer count of pages; stop once the pages fetched so * far reach it. For the `page` strategy that is * `page - pageStart + 1 >= totalPages` — `page >= totalPages` when pages - * start at 1, `page + 1 >= totalPages` when they start at 0 — and for - * `offset` it is `floor(offset / limit) + 1 >= totalPages`. + * start at 1, `page + 1 >= totalPages` when they start at 0 — for + * `offset` it is `floor(offset / limit) + 1 >= totalPages`, and for + * `cursor`, whose opaque cursor carries no page number, it is the number + * of requests made so far `>= totalPages`. * - `totalCount` — an integer count of items; stop once the items received * so far reach it. * From 93af4f115651abc74eba5014f12b4b108e08a637 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 13:35:00 -0400 Subject: [PATCH 25/59] fix(parser)!: read no requestBody from a +json media range Since #329 a request body under any +json media type is read as JSON, and requestBodyContentType is that key's essence: the Content-Type an emitter sends. The +json tier of REQUEST_BODY_MEDIA_TYPES used isJsonMediaType, which also accepts a range such as application/*+json, so a body declared only under one reached emitters with requestBodyContentType 'application/*+json', a value no request can be labelled with. A body under application/* or */* was already left unread. The +json tier now skips an essence holding a `*`, so such a body is read as one under application/* is: no requestBody, requestBodyEncoding or requestBodyContentType, a warning, and the body carried in requestBodyContents alone. A concrete +json key declared beside the range is picked instead. isJsonMediaType is unchanged, so a response under application/*+json is still read as JSON. Webhook payloads go through the same pick and change the same way. The warning now lists "a concrete +json media type" among what the parser reads. docs/architecture/ir-types.md states the rule, and the emitter guide and the parity skill no longer say requestBodyEncoding sets the Content-Type: requestBodyContentType does. Closes #387 BREAKING CHANGE: a request body or webhook payload declared only under a +json media range (application/*+json) no longer sets requestBody, requestBodyEncoding or requestBodyContentType; its inline model moves from CreatePetRequest to CreatePetApplicationAnyJsonRequest in requestBodyContents, and the parser warns. Where a concrete +json key is declared beside the range, requestBody is read from that key instead. --- docs/architecture/ir-types.md | 2 +- skills/check-emitter-parity/SKILL.md | 4 +- .../references/generator-guide.md | 2 +- src/ir/types.ts | 5 +- src/parser/operations.ts | 11 +- test/parser/request-body-media-types.test.ts | 137 +++++++++++++++++- 6 files changed, 150 insertions(+), 11 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 336ebfdd..a1018e26 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -518,7 +518,7 @@ The `injectIdempotencyKey` flag is spec-driven: it is `true` only when the opera | `binary` | `application/octet-stream` | | `text` | `text/plain` | -Media types are matched by essence — `type/subtype` with parameters stripped and case folded, as response media types are (see Multiple Success Responses below) — so `application/json; charset=utf-8`, `APPLICATION/JSON` and `multipart/form-data; boundary=x` are read as their bare types, and `application/vnd.api+json` or `application/merge-patch+json` as JSON. The entries are tried in a fixed order: `application/json`, then a `+json` media type, then `multipart/form-data`, then `application/x-www-form-urlencoded`, each only when it declares a schema (an `application/json` entry without one yields to a later media type that has one); then `application/octet-stream`, then `text/plain`, with or without a schema — a schema-less binary body is a `string` with `format: "binary"`, a schema-less text body a `string`, and one with a schema is typed from it. Within a tier the first matching key in document order wins. `requestBodyEncoding` is set exactly when `requestBody` is, and so is `requestBodyContentType`: the essence of the media type the body was read from (`application/merge-patch+json`; `application/json` for `application/json; charset=utf-8`; `multipart/form-data` for `multipart/form-data; boundary=x`), which is what an emitter sends as the request's `Content-Type` — `requestBodyEncoding` says how to serialize the body, `requestBodyContentType` how to label it, so a server that requires its declared media type accepts the request; parameters such as a multipart boundary are the emitter's to produce. A body that declares media types but none the parser reads — `application/xml`, `text/csv`, or only an `application/json` entry without a schema — yields no `requestBody`, `requestBodyEncoding` or `requestBodyContentType`; it is carried in `requestBodyContents` alone (see Request Body Content Types below), and the parser warns once per operation (and once per webhook, whose payload goes through the same pick) rather than dropping it silently. The `x-mutually-exclusive-body-groups` extension resolves its members from the same pick, as do the body-owned query parameters (`bodyOwnedQueryParams`). +Media types are matched by essence — `type/subtype` with parameters stripped and case folded, as response media types are (see Multiple Success Responses below) — so `application/json; charset=utf-8`, `APPLICATION/JSON` and `multipart/form-data; boundary=x` are read as their bare types, and `application/vnd.api+json` or `application/merge-patch+json` as JSON. A media range is never read, since no request can be labelled with one: a body under `application/*+json` is not read as JSON, although a response under it is. The entries are tried in a fixed order: `application/json`, then a `+json` media type, then `multipart/form-data`, then `application/x-www-form-urlencoded`, each only when it declares a schema (an `application/json` entry without one yields to a later media type that has one); then `application/octet-stream`, then `text/plain`, with or without a schema — a schema-less binary body is a `string` with `format: "binary"`, a schema-less text body a `string`, and one with a schema is typed from it. Within a tier the first matching key in document order wins. `requestBodyEncoding` is set exactly when `requestBody` is, and so is `requestBodyContentType`: the essence of the media type the body was read from (`application/merge-patch+json`; `application/json` for `application/json; charset=utf-8`; `multipart/form-data` for `multipart/form-data; boundary=x`), which is what an emitter sends as the request's `Content-Type` — `requestBodyEncoding` says how to serialize the body, `requestBodyContentType` how to label it, so a server that requires its declared media type accepts the request; parameters such as a multipart boundary are the emitter's to produce. A body that declares media types but none the parser reads — `application/xml`, `text/csv`, a media range such as `application/*` or `application/*+json`, or only an `application/json` entry without a schema — yields no `requestBody`, `requestBodyEncoding` or `requestBodyContentType`; it is carried in `requestBodyContents` alone (see Request Body Content Types below), and the parser warns once per operation (and once per webhook, whose payload goes through the same pick) rather than dropping it silently. The `x-mutually-exclusive-body-groups` extension resolves its members from the same pick, as do the body-owned query parameters (`bodyOwnedQueryParams`). A body that is itself an inline enum, or an array of one, mints no model: it is promoted to `spec.enums` under the `Request` name it is typed by (`SetMoodRequest`), whichever media type it was read from, with the per-value documentation and link the schema declares (see Enum below). A name an enum of other members already owns — a component's — is numbered (`SetMoodRequest2`), and a name a model owns is never taken; identical members share the existing enum. An enum nested in the body's model (`SetMoodRequest.mood`) is promoted with every other model's field enums (`SetMoodRequestMood`). diff --git a/skills/check-emitter-parity/SKILL.md b/skills/check-emitter-parity/SKILL.md index 86caab6e..1ce30c94 100644 --- a/skills/check-emitter-parity/SKILL.md +++ b/skills/check-emitter-parity/SKILL.md @@ -65,7 +65,7 @@ A missing kind means an entire category of types won't generate correctly. #### Operation - `pagination` — Auto-paging iterator generation (cursor, offset, page, link-header strategies; optional `endOfIteration` stop field) -- `requestBodyEncoding` — Content-type handling: json (`application/json` and any `+json` media type), form-data, form-urlencoded, binary, text +- `requestBodyEncoding` — Body serialization: json (`application/json` and any `+json` media type), form-data, form-urlencoded, binary, text - `requestBodyRequired` — Whether the body argument is mandatory or optional - `requestBodyContentType` — The media type to send as `Content-Type` (the picked entry's essence, `application/merge-patch+json`) - `injectIdempotencyKey` — Whether to inject an idempotency key header (the `Idempotency-Key` parameter is left out of `headerParams`) @@ -259,7 +259,7 @@ For each gap, provide a specific, actionable suggestion for what the emitter sho | Missing TypeRef kinds | Add cases to type-mapping switch; use `assertNever` for compile-time exhaustiveness | | `ApiSpec.auth` | Client constructor auth configuration (API key header, bearer token, OAuth flow) | | `Operation.pagination` | Auto-paging iterator/generator that handles cursor, offset, page, or link-header strategies and stops on `endOfIteration` | -| `Operation.requestBodyEncoding` | Set correct Content-Type header; use multipart handling for form-data, raw bytes for binary | +| `Operation.requestBodyEncoding` | Serialize the body to match: multipart handling for form-data, raw bytes for binary (the `Content-Type` header comes from `requestBodyContentType`) | | `Operation.requestBodyRequired` | Make the body argument mandatory when `true`; when `false`, make it optional and omit the body only if the caller leaves it out | | `Operation.requestBodyContentType` | Send it as the `Content-Type` header, producing parameters such as a multipart boundary yourself | | `Operation.injectIdempotencyKey` | Generate and attach `Idempotency-Key` header on applicable requests | diff --git a/skills/generate-emitter/references/generator-guide.md b/skills/generate-emitter/references/generator-guide.md index 57f57e63..e230a3c2 100644 --- a/skills/generate-emitter/references/generator-guide.md +++ b/skills/generate-emitter/references/generator-guide.md @@ -149,7 +149,7 @@ Use the shared utilities from `@workos/oagen` (`toPascalCase`, `toSnakeCase`, `t - `bodyOwnedQueryParams?` — query declarations the request body also owns; never serialize these to the URL - `cookieParams?` — `Parameter[]`, present only when the operation declares cookie parameters - `requestBody?` — `TypeRef` of the request body, read from one `content` entry by media type essence (parameters and case aside): `application/json`, else a `+json` media type, else `multipart/form-data`, else `application/x-www-form-urlencoded` (each only with a schema), else `application/octet-stream`, else `text/plain`; absent, with a parser warning, when none of the declared media types is read - - `requestBodyEncoding?` — `json` (`application/json` and any `+json` media type) | `form-data` | `form-urlencoded` | `binary` | `text`; selects the Content-Type and serialization; set exactly when `requestBody` is + - `requestBodyEncoding?` — `json` (`application/json` and any `+json` media type) | `form-data` | `form-urlencoded` | `binary` | `text`; selects the serialization, while `requestBodyContentType` is the `Content-Type` to send; set exactly when `requestBody` is - `requestBodyRequired?` — whether the caller must send the body; present exactly when `requestBody` is - `requestBodyDescription?` — the request body's doc text: the Request Body Object's own `description`, else the `description` written on the schema `requestBody` was read from (a `$ref` not followed); present only when one has non-whitespace content, for the body argument's doc tag (a `Webhook` carries the same for its payload) - `requestBodyContentType?` — the essence of the media type `requestBody` was read from (`application/merge-patch+json`; `application/json` for `application/json; charset=utf-8`); send it as the `Content-Type`, producing parameters such as a multipart boundary yourself; set exactly when `requestBody` is diff --git a/src/ir/types.ts b/src/ir/types.ts index ee6aeacc..ac07edbf 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -322,8 +322,9 @@ export interface Operation { * types are matched by essence — parameters and case aside, so * `application/json; charset=utf-8` and `APPLICATION/JSON` are * `application/json` — and tried in a fixed order: `application/json`, then - * a `+json` media type (`application/vnd.api+json`), then - * `multipart/form-data`, then `application/x-www-form-urlencoded`, each + * a `+json` media type (`application/vnd.api+json`; never a range such as + * `application/*+json`), then `multipart/form-data`, then + * `application/x-www-form-urlencoded`, each * only when it declares a schema; then `application/octet-stream`, then * `text/plain`, with or without one (a schema-less binary body is a binary * string, a schema-less text body a string). An inline object body is diff --git a/src/parser/operations.ts b/src/parser/operations.ts index fafb8d2e..dc4dd7f4 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -3401,7 +3401,9 @@ const BODY_CARRYING_METHODS: ReadonlySet = new Set(['pos * `application/json; charset=utf-8`, `APPLICATION/JSON` and * `multipart/form-data; boundary=x` read as their bare types — and a `+json` * media type (`application/vnd.api+json`, `application/merge-patch+json`) is - * JSON. A JSON or form body is read only when it declares a schema, so an + * JSON. A media range (`application/*`, `application/*+json`) is never read: + * the picked essence is the request's `Content-Type`, which a range cannot + * be. A JSON or form body is read only when it declares a schema, so an * `application/json` entry without one yields to a later media type that has * one; a binary or text body may legitimately declare none. */ @@ -3412,10 +3414,11 @@ const REQUEST_BODY_MEDIA_TYPES: ReadonlyArray<{ }> = [ // The one JSON rule requests and responses share (`isJsonMediaType`), in // the order `jsonResponseSchema` applies it: `application/json` itself - // first, then any `+json` type. + // first, then any `+json` type — on a request a concrete one only, while a + // response is still read under `application/*+json`. { matches: isPlainJsonMediaType, encoding: 'json', schemaRequired: true }, { - matches: (essence) => isJsonMediaType(essence) && !isPlainJsonMediaType(essence), + matches: (essence) => isJsonMediaType(essence) && !isPlainJsonMediaType(essence) && !essence.includes('*'), encoding: 'json', schemaRequired: true, }, @@ -3503,7 +3506,7 @@ export function warnUnreadRequestBody(body: RequestBodyObject | undefined, label if (declared.length === 0 || selectRequestBodyContent(body)) return; console.warn( `[oagen] Warning: Request body of ${label} is not read as requestBody: none of its media types ` + - `(${declared.join(', ')}) is one the parser reads — application/json or a +json media type, ` + + `(${declared.join(', ')}) is one the parser reads — application/json or a concrete +json media type, ` + `multipart/form-data or application/x-www-form-urlencoded with a schema, application/octet-stream or ` + `text/plain with or without one, parameters and case aside. It is carried in requestBodyContents only.`, ); diff --git a/test/parser/request-body-media-types.test.ts b/test/parser/request-body-media-types.test.ts index de16a056..3e8ba577 100644 --- a/test/parser/request-body-media-types.test.ts +++ b/test/parser/request-body-media-types.test.ts @@ -77,7 +77,7 @@ function fieldNames(models: Model[], name: string): string[] | undefined { const DROPPED = (label: string, keys: string) => `[oagen] Warning: Request body of ${label} is not read as requestBody: none of its media types ` + - `(${keys}) is one the parser reads — application/json or a +json media type, ` + + `(${keys}) is one the parser reads — application/json or a concrete +json media type, ` + `multipart/form-data or application/x-www-form-urlencoded with a schema, application/octet-stream or ` + `text/plain with or without one, parameters and case aside. It is carried in requestBodyContents only.`; @@ -697,3 +697,138 @@ describe('request body media types are matched by essence (issue #278)', () => { }); }); }); + +/** + * Issue #387: `requestBodyContentType` is what an emitter sends as the + * request's `Content-Type`, so it is never a media range. A body declared + * only under a `+json` range (`application/*+json`) is read as one under + * `application/*` is: no `requestBody`, a warning, and the body carried in + * `requestBodyContents` alone. A response under such a range is still read + * as JSON. + */ +describe('a +json media range is not read as the request body (issue #387)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('through parseSpec (issue repro)', () => { + let dir: string; + let specPath: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'oagen-387-')); + specPath = join(dir, 'range.json'); + writeFileSync( + specPath, + JSON.stringify({ + openapi: '3.1.0', + info: { title: 'range', version: '1' }, + paths: { + '/notes': { + post: { + operationId: 'createNote', + requestBody: { content: { 'application/*': { schema: NAME_SCHEMA } } }, + responses: ok, + }, + }, + '/pets': { + post: { + operationId: 'createPet', + requestBody: { content: { 'application/*+json': { schema: NAME_SCHEMA } } }, + responses: ok, + }, + }, + }, + webhooks: { + petAdopted: { + post: { requestBody: { content: { 'application/*+json': { schema: NAME_SCHEMA } } }, responses: {} }, + }, + }, + }), + ); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads a body under application/*+json as one under application/*, on an operation and a webhook', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const spec = await parseSpec(specPath); + const sites = [...spec.services.flatMap(({ operations }) => operations), ...(spec.webhooks ?? [])]; + expect( + sites.map((site) => ({ + name: site.name, + requestBody: site.requestBody, + requestBodyContentType: site.requestBodyContentType, + requestBodyContentTypes: site.requestBodyContentTypes, + requestBodyContents: site.requestBodyContents?.map(({ type }) => type), + requestBodyEncoding: site.requestBodyEncoding, + })), + ).toEqual([ + { + name: 'createNote', + requestBody: undefined, + requestBodyContentType: undefined, + requestBodyContentTypes: ['application/*'], + requestBodyContents: [{ kind: 'model', name: 'CreateNoteApplicationAnyRequest' }], + requestBodyEncoding: undefined, + }, + { + name: 'createPet', + requestBody: undefined, + requestBodyContentType: undefined, + requestBodyContentTypes: ['application/*+json'], + requestBodyContents: [{ kind: 'model', name: 'CreatePetApplicationAnyJsonRequest' }], + requestBodyEncoding: undefined, + }, + { + name: 'petAdopted', + requestBody: undefined, + requestBodyContentType: undefined, + requestBodyContentTypes: ['application/*+json'], + requestBodyContents: [{ kind: 'model', name: 'PetAdoptedApplicationAnyJsonPayload' }], + requestBodyEncoding: undefined, + }, + ]); + expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([ + DROPPED('createNote', 'application/*'), + DROPPED('createPet', 'application/*+json'), + DROPPED('webhooks.petAdopted.post', 'application/*+json'), + ]); + }); + }); + + it('reads no +json range, however it is spelled', () => { + for (const mediaType of ['application/*+json', '*/*+json', ' APPLICATION/*+JSON ; charset=utf-8']) { + expect( + selectRequestBodyContent({ content: { [mediaType]: { schema: NAME_SCHEMA } } }), + mediaType, + ).toBeUndefined(); + } + }); + + it('picks a concrete +json media type declared after a range', () => { + const { op, inlineModels } = createPet({ + 'application/*+json': { schema: TITLE_SCHEMA }, + 'application/vnd.api+json; charset=utf-8': { schema: NAME_SCHEMA }, + }); + expect(op.requestBody).toEqual({ kind: 'model', name: 'CreatePetRequest' }); + expect(op.requestBodyContentType).toBe('application/vnd.api+json'); + expect(op.requestBodyEncoding).toBe('json'); + expect(fieldNames(inlineModels, 'CreatePetRequest')).toEqual(['name']); + }); + + it('still reads a response declared under application/*+json as JSON', () => { + const paths = { + '/pets': { + get: { + operationId: 'getPet', + responses: { '200': { description: 'ok', content: { 'application/*+json': { schema: NAME_SCHEMA } } } }, + }, + }, + }; + const [op] = extractOperations(paths as never).services.flatMap((s) => s.operations); + expect(op.response).toEqual({ kind: 'model', name: 'GetPetResponse' }); + }); +}); From 92f68511259ceb3de86e4fe67e9b92fb6cf290e0 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 10:11:50 -0400 Subject: [PATCH 26/59] docs(ir): say json covers only a concrete +json media type A request body under `application/*+json` is no longer read, so it gets no `requestBodyEncoding`, while several descriptions still said `json` covers "any `+json` media type". Say "a concrete `+json` media type", as the unread-body warning does, so an emitter author does not expect `requestBodyEncoding: 'json'` for `{ 'application/*+json': { schema } }`: - the `json` row of the Request Body Encoding table in ir-types.md - the `requestBodyEncoding` JSDoc in src/ir/types.ts - the Encoding bullet in emitter-contract.md - the `requestBody?` and `requestBodyEncoding?` bullets in the generator guide, and the `requestBodyEncoding` bullet in the check-emitter-parity skill - the `selectRequestBodyContent` JSDoc, which lists the same pick order Behavior is unchanged and already pinned: the #387 parseSpec test asserts `requestBodyEncoding: undefined` for `createPet` and the `petAdopted` webhook. Addresses review finding 1 on #473 and its inline threads on check-emitter-parity/SKILL.md:68 and generator-guide.md:151-152. --- docs/architecture/emitter-contract.md | 2 +- docs/architecture/ir-types.md | 14 +++++++------- skills/check-emitter-parity/SKILL.md | 2 +- .../generate-emitter/references/generator-guide.md | 4 ++-- src/ir/types.ts | 4 ++-- src/parser/operations.ts | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/architecture/emitter-contract.md b/docs/architecture/emitter-contract.md index b061a2a2..346f2ebe 100644 --- a/docs/architecture/emitter-contract.md +++ b/docs/architecture/emitter-contract.md @@ -107,7 +107,7 @@ Emitters should handle these additional variants introduced for open-source read - **Auth:** `'cookie'` in the `apiKey` auth scheme `in` field - **Pagination:** `'page'` strategy for page-number pagination — send `pageStart` first and advance `param` by one per request — next to `'link-header'` for RFC 5988 Link header pagination; optional `endOfIteration` (`{ kind: 'hasMore' | 'totalPages' | 'totalCount', path }`) naming the response field that ends the iteration, whatever the strategy (`hasMore === false`; pages fetched so far `>= totalPages` — `page - pageStart + 1` under `page`, `floor(offset / limit) + 1` under `offset`, the number of requests made under `cursor`; items received `>= totalCount`), in addition to the empty-page stop every strategy has; `dataPath` may be `undefined` (meaning the response IS the data) -- **Encoding:** `'form-urlencoded'` for `application/x-www-form-urlencoded` request bodies; every request body media type is matched by essence (parameters and case aside), and a `+json` media type is `'json'` +- **Encoding:** `'form-urlencoded'` for `application/x-www-form-urlencoded` request bodies; every request body media type is matched by essence (parameters and case aside), and a concrete `+json` media type is `'json'` - **HTTP methods:** `head`, `options`, `trace` — HEAD returns no body, OPTIONS/TRACE are informational - **Cookie params:** Optional `cookieParams` array on operations that use cookie parameters - **Content parameters:** Optional `contentType: string` on `Parameter` — the `content` key a `content`-declared parameter's value is serialized under, as written (`application/json`, `application/json; charset=utf-8`), present exactly when the parameter is declared with `content` and the map is read; serialize such a value in that media type (JSON-encode an `application/json` or `+json` value) instead of by `style`/`explode`. Optional `contentTypes: string[]` beside it lists every media type the `content` map declares, as written, read or not: a map none of whose media types is read keeps its media type there, and its value, a string, is sent as written diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index a1018e26..770e7568 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -510,13 +510,13 @@ The `injectIdempotencyKey` flag is spec-driven: it is `true` only when the opera `requestBody` is read from one entry of the Request Body Object's `content` map (after a `$ref` into `components/requestBodies` is resolved), and `requestBodyEncoding` tells emitters how to serialize it: -| Value | Media types | -| ----------------- | ------------------------------------------ | -| `json` | `application/json`, any `+json` media type | -| `form-data` | `multipart/form-data` | -| `form-urlencoded` | `application/x-www-form-urlencoded` | -| `binary` | `application/octet-stream` | -| `text` | `text/plain` | +| Value | Media types | +| ----------------- | --------------------------------------------------- | +| `json` | `application/json`, any concrete `+json` media type | +| `form-data` | `multipart/form-data` | +| `form-urlencoded` | `application/x-www-form-urlencoded` | +| `binary` | `application/octet-stream` | +| `text` | `text/plain` | Media types are matched by essence — `type/subtype` with parameters stripped and case folded, as response media types are (see Multiple Success Responses below) — so `application/json; charset=utf-8`, `APPLICATION/JSON` and `multipart/form-data; boundary=x` are read as their bare types, and `application/vnd.api+json` or `application/merge-patch+json` as JSON. A media range is never read, since no request can be labelled with one: a body under `application/*+json` is not read as JSON, although a response under it is. The entries are tried in a fixed order: `application/json`, then a `+json` media type, then `multipart/form-data`, then `application/x-www-form-urlencoded`, each only when it declares a schema (an `application/json` entry without one yields to a later media type that has one); then `application/octet-stream`, then `text/plain`, with or without a schema — a schema-less binary body is a `string` with `format: "binary"`, a schema-less text body a `string`, and one with a schema is typed from it. Within a tier the first matching key in document order wins. `requestBodyEncoding` is set exactly when `requestBody` is, and so is `requestBodyContentType`: the essence of the media type the body was read from (`application/merge-patch+json`; `application/json` for `application/json; charset=utf-8`; `multipart/form-data` for `multipart/form-data; boundary=x`), which is what an emitter sends as the request's `Content-Type` — `requestBodyEncoding` says how to serialize the body, `requestBodyContentType` how to label it, so a server that requires its declared media type accepts the request; parameters such as a multipart boundary are the emitter's to produce. A body that declares media types but none the parser reads — `application/xml`, `text/csv`, a media range such as `application/*` or `application/*+json`, or only an `application/json` entry without a schema — yields no `requestBody`, `requestBodyEncoding` or `requestBodyContentType`; it is carried in `requestBodyContents` alone (see Request Body Content Types below), and the parser warns once per operation (and once per webhook, whose payload goes through the same pick) rather than dropping it silently. The `x-mutually-exclusive-body-groups` extension resolves its members from the same pick, as do the body-owned query parameters (`bodyOwnedQueryParams`). diff --git a/skills/check-emitter-parity/SKILL.md b/skills/check-emitter-parity/SKILL.md index 1ce30c94..07d12688 100644 --- a/skills/check-emitter-parity/SKILL.md +++ b/skills/check-emitter-parity/SKILL.md @@ -65,7 +65,7 @@ A missing kind means an entire category of types won't generate correctly. #### Operation - `pagination` — Auto-paging iterator generation (cursor, offset, page, link-header strategies; optional `endOfIteration` stop field) -- `requestBodyEncoding` — Body serialization: json (`application/json` and any `+json` media type), form-data, form-urlencoded, binary, text +- `requestBodyEncoding` — Body serialization: json (`application/json` and any concrete `+json` media type), form-data, form-urlencoded, binary, text - `requestBodyRequired` — Whether the body argument is mandatory or optional - `requestBodyContentType` — The media type to send as `Content-Type` (the picked entry's essence, `application/merge-patch+json`) - `injectIdempotencyKey` — Whether to inject an idempotency key header (the `Idempotency-Key` parameter is left out of `headerParams`) diff --git a/skills/generate-emitter/references/generator-guide.md b/skills/generate-emitter/references/generator-guide.md index e230a3c2..d94a4862 100644 --- a/skills/generate-emitter/references/generator-guide.md +++ b/skills/generate-emitter/references/generator-guide.md @@ -148,8 +148,8 @@ Use the shared utilities from `@workos/oagen` (`toPascalCase`, `toSnakeCase`, `t - `pathParams`, `queryParams`, `headerParams` — `Parameter[]` per location; always present, possibly empty. Each `name` is as the spec wrote it; a parameter's identity is its location plus name, a header's name compared case-insensitively (`parameterKey` from `@workos/oagen`), so one list never holds two headers that differ in case alone. An `Idempotency-Key` header parameter (any case) is not in `headerParams`: it sets `injectIdempotencyKey` instead, and its `description`, `required` and schema do not reach the IR - `bodyOwnedQueryParams?` — query declarations the request body also owns; never serialize these to the URL - `cookieParams?` — `Parameter[]`, present only when the operation declares cookie parameters - - `requestBody?` — `TypeRef` of the request body, read from one `content` entry by media type essence (parameters and case aside): `application/json`, else a `+json` media type, else `multipart/form-data`, else `application/x-www-form-urlencoded` (each only with a schema), else `application/octet-stream`, else `text/plain`; absent, with a parser warning, when none of the declared media types is read - - `requestBodyEncoding?` — `json` (`application/json` and any `+json` media type) | `form-data` | `form-urlencoded` | `binary` | `text`; selects the serialization, while `requestBodyContentType` is the `Content-Type` to send; set exactly when `requestBody` is + - `requestBody?` — `TypeRef` of the request body, read from one `content` entry by media type essence (parameters and case aside): `application/json`, else a concrete `+json` media type, else `multipart/form-data`, else `application/x-www-form-urlencoded` (each only with a schema), else `application/octet-stream`, else `text/plain`; absent, with a parser warning, when none of the declared media types is read + - `requestBodyEncoding?` — `json` (`application/json` and any concrete `+json` media type) | `form-data` | `form-urlencoded` | `binary` | `text`; selects the serialization, while `requestBodyContentType` is the `Content-Type` to send; set exactly when `requestBody` is - `requestBodyRequired?` — whether the caller must send the body; present exactly when `requestBody` is - `requestBodyDescription?` — the request body's doc text: the Request Body Object's own `description`, else the `description` written on the schema `requestBody` was read from (a `$ref` not followed); present only when one has non-whitespace content, for the body argument's doc tag (a `Webhook` carries the same for its payload) - `requestBodyContentType?` — the essence of the media type `requestBody` was read from (`application/merge-patch+json`; `application/json` for `application/json; charset=utf-8`); send it as the `Content-Type`, producing parameters such as a multipart boundary yourself; set exactly when `requestBody` is diff --git a/src/ir/types.ts b/src/ir/types.ts index ac07edbf..8c2d6d2e 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -337,8 +337,8 @@ export interface Operation { requestBody?: TypeRef; /** * The wire encoding of the media type `requestBody` was read from: `json` - * for `application/json` and any `+json` media type, `form-data` for - * `multipart/form-data`, `form-urlencoded` for + * for `application/json` and any concrete `+json` media type, `form-data` + * for `multipart/form-data`, `form-urlencoded` for * `application/x-www-form-urlencoded`, `binary` for * `application/octet-stream`, `text` for `text/plain`. Set exactly when * `requestBody` is. diff --git a/src/parser/operations.ts b/src/parser/operations.ts index dc4dd7f4..c03a3150 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -3465,7 +3465,7 @@ export interface SelectedRequestBodyContent { /** * Pick the content entry a request body is read from, by media type essence * in the fixed order of `REQUEST_BODY_MEDIA_TYPES`: `application/json`, then - * a `+json` media type, then `multipart/form-data`, then + * a concrete `+json` media type, then `multipart/form-data`, then * `application/x-www-form-urlencoded` — each only with a schema — then * `application/octet-stream`, then `text/plain`. Within one tier the first * matching key in document order wins. `undefined` when the body declares no From ac26911d5989210aa751bbd53b4e71bc0f84e2a8 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 13:42:32 -0400 Subject: [PATCH 27/59] fix(parser): keep requestBodyRequired on a body carried in requestBodyContents alone Since #329 a request body none of whose media types the parser reads is carried in requestBodyContents, so an emitter can still offer it. But buildOperation set requestBodyRequired only when a body was picked as requestBody, so an emitter offering such a body could not tell whether the caller must send it. buildWebhook reads the flag off the Request Body Object whatever was picked, so a webhook kept it for the same body. The flag is now set exactly when requestBodyContentTypes is: whenever the body declares a media type, read as requestBody or not. It is still set beside every requestBody, and a body with no content, or an empty content map, still has no entry. The differ is unchanged: it compares the flag, and reads an added body as optional, only while a requestBody is carried, so no diff output moves. The Operation JSDoc, ir-types.md, emitter-contract.md and the generator guide said the flag is present exactly when requestBody is; they now give the new rule, and ir-types.md says the differ compares a flip only while both specs carry a requestBody. Closes #388 --- docs/architecture/emitter-contract.md | 2 +- docs/architecture/ir-types.md | 6 +- .../references/generator-guide.md | 2 +- src/ir/types.ts | 8 +- src/parser/operations.ts | 5 +- .../parser/request-body-content-types.test.ts | 8 +- test/parser/request-body-media-types.test.ts | 10 +- test/parser/request-body-required.test.ts | 110 +++++++++++++++++- 8 files changed, 129 insertions(+), 22 deletions(-) diff --git a/docs/architecture/emitter-contract.md b/docs/architecture/emitter-contract.md index 346f2ebe..e7bf2c58 100644 --- a/docs/architecture/emitter-contract.md +++ b/docs/architecture/emitter-contract.md @@ -121,7 +121,7 @@ Emitters should handle these additional variants introduced for open-source read - **Response headers:** Optional `responseHeaders: ResponseHeader[]` on `Operation` (the primary response's headers) and `headers: ResponseHeader[]` on each `SuccessResponse` / `ErrorResponse`, present only when the spec declares at least one - **Response descriptions:** Optional `responseDescription: string` on `Operation` (the primary response's description, e.g. for a `@returns` tag) and `description: string` on each `SuccessResponse` / `ErrorResponse`, present only when the spec declares one with non-whitespace content - **Primary response status code:** Optional `responseStatusCode: number` on `Operation`: the status code of the response `response` was read from (`200`, a lone `302`, a POST's `200`), present exactly when the operation declares at least one 2xx or 3xx response -- **Request body required:** Optional `requestBodyRequired: boolean` on `Operation`, present exactly when `requestBody` is; `false` when the spec omits the flag (the OpenAPI default) +- **Request body required:** Optional `requestBodyRequired: boolean` on `Operation`, present exactly when `requestBodyContentTypes` is — a body carried in `requestBodyContents` alone has it too, so it does not imply `requestBody`; `false` when the spec omits the flag (the OpenAPI default) - **Request body description:** Optional `requestBodyDescription: string` on `Operation` and `Webhook` — the Request Body Object's own `description` (after a `components/requestBodies` `$ref` is resolved), else the `description` written on the schema `requestBody` was read from (a `$ref` not followed) — present only when one of the two has non-whitespace content, e.g. for the body argument's doc tag; an inline object body's model carries its schema's `description` as any other inline model does - **Request body content types:** Optional `requestBodyContentType: string` on `Operation` and `Webhook` — the essence of the media type `requestBody` was read from (`application/merge-patch+json`; `application/json` for `application/json; charset=utf-8`), set exactly when `requestBody` is: send it as the `Content-Type`, producing parameters such as a multipart boundary yourself — and optional `requestBodyContentTypes: string[]` / `requestBodyContents: RequestBodyContent[]` on both (every media type the body declares, verbatim and in document order, with a type per media type; the entry for the picked media type shares `requestBody`'s type), present only when the body declares at least one media type; `requestBody` is the fixed-order pick - **Deprecation:** Optional `deprecated: boolean` on `Operation`, `Parameter`, `ResponseHeader`, `Field`, `Model` and `Enum`, each read from its own source: `Operation` from the Operation Object's own keyword; `Parameter` from any of the Parameter Object (after a `components.parameters` `$ref` is resolved), its inline `schema` and the component schema its `schema.$ref` points at — deprecated when any of the three says `deprecated: true`, so a `false` on one level does not stop a `true` on another; `ResponseHeader` from the Header Object only (after a `components/headers` `$ref` is resolved), never from its schema; `Field` from the property schema itself, a keyword written beside a `$ref` included, while the referenced model keeps its own and an `allOf` member's is not read — except that a bare `$ref` to an array component, which has no model to keep the flag, deprecates the field when the component says `deprecated: true`, as the field takes that component's `description`, `externalDocs`, `extensions` and `example`; `Model` from the schema the model was built from — a component's own top-level keyword, or the inline schema an inline model is materialized from — never an `allOf` member's; `Enum` from the component enum schema's own top-level keyword, while an inline enum lifted into `spec.enums` carries no flag of its own: the field, parameter or header that declares it carries `deprecated`. At every site the parser tests `=== true`: a `false`, absent or non-boolean keyword (`"yes"`, `1`) leaves the key absent, so the IR never carries anything but a literal `true` and `if (node.deprecated)` is the whole check. Emit the language's deprecation marker on the generated symbol (a `@deprecated` doc tag, `[Obsolete]`, `#[deprecated]`, a runtime warning where the language has no annotation). `EnumValue.deprecated` is present, and `true`, when the enum is declared as a `oneOf`/`anyOf` of `const` schemas and the value's own member says `deprecated: true` — the OAS 3.1 spelling for deprecating one value; a plain `enum:` list has no per-value keyword, so its values never carry it. Emit the same deprecation marker on that member alone diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 770e7568..ca7ecc16 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -383,7 +383,7 @@ interface Operation { requestBody?: TypeRef; requestBodyEncoding?: "json" | "form-data" | "form-urlencoded" | "binary" | "text"; - requestBodyRequired?: boolean; // Whether the caller must send the body; present exactly when requestBody is + requestBodyRequired?: boolean; // Whether the caller must send the body; present exactly when requestBodyContentTypes is, so also for a body carried in requestBodyContents alone requestBodyDescription?: string; // The Request Body Object's description, else the picked schema's own (only when one has content) requestBodyContentType?: string; // The essence of the media type requestBody was read from — send it as Content-Type; set exactly when requestBody is requestBodyContentTypes?: string[]; // Media types the request body declares, in document order (only when any exist) @@ -524,9 +524,9 @@ A body that is itself an inline enum, or an array of one, mints no model: it is ### Request Body Required -`requestBodyRequired` carries the Request Body Object's `required` flag, so an emitter can make the body argument mandatory or optional to match the document. It is read after a `$ref` into `components/requestBodies` is resolved, so a referenced body's flag counts. OpenAPI defaults the flag to `false`, so a body the document does not mark required is `false` here — an explicit `required: false` and an omitted flag are the same thing, and only a literal `true` makes a body required. The field is present exactly when `requestBody` is, whatever the encoding or the body's shape (an inline object, a bare `$ref`, a `oneOf`, a schema-less binary or text body); an operation without a body, or whose declared body yields no `requestBody`, has no entry. +`requestBodyRequired` carries the Request Body Object's `required` flag, so an emitter can make the body argument mandatory or optional to match the document. It is read after a `$ref` into `components/requestBodies` is resolved, so a referenced body's flag counts. OpenAPI defaults the flag to `false`, so a body the document does not mark required is `false` here — an explicit `required: false` and an omitted flag are the same thing, and only a literal `true` makes a body required. The field is present exactly when `requestBodyContentTypes` is — whenever the body declares at least one media type — whatever the encoding or the body's shape (an inline object, a bare `$ref`, a `oneOf`, a schema-less binary or text body), and whether or not one of its media types is read as `requestBody`: a body carried in `requestBodyContents` alone (see Request Body Encoding above) keeps its flag, as a webhook payload does, so an emitter that offers it can tell whether the caller must send it. The field is therefore set beside every `requestBody` but does not imply one. An operation without a body, or whose body declares no media type (no `content`, or an empty map), has no entry. -The differ reports a flip as `requestBodyRequiredChanged` on an `operation-modified` change: optional → required is breaking (callers must now send a body), required → optional is additive. A body appearing or disappearing is reported as `requestBodyChanged` alone. A body changing type also sets `requestBodyChanged`, and if its required flag flips in the same change, both flags are set. A body change is breaking, except when a body appears with `requestBodyRequired` not `true`: an optional body only gives callers an argument they may leave out, so it is additive, the same as `param-added` for an optional parameter. A body appearing as required, a body disappearing and a changed body type stay breaking, and as with every other flag, one breaking flag makes the whole `operation-modified` change breaking (an optional body added alongside a required parameter is breaking). While both specs declare a body, the media types it may be sent as (`requestBodyContentTypes`) are compared by essence as a response entry's are and reported as `requestBodyContentTypesChanged` with `requestBodyContentTypeChange` beside it: a media type callers could send that is gone, or a secondary media type whose body changed type, is breaking; one they can newly send is additive (see `oagen diff` in the CLI reference). A body changing encoding — `requestBodyEncoding`, the wire format it is serialized as (see Request Body Encoding above) — while both specs declare one sets `requestBodyEncodingChanged`, with `oldRequestBodyEncoding`/`newRequestBodyEncoding` beside it, and is breaking when the new document no longer accepts the representation the old document's body was read from — that media type gone from `requestBodyContentTypes`, or its shape changed (compared as `retyped` is) — since an SDK built for the old document keeps serializing and labelling the body as that document said; it is additive when the new document still accepts it, as when a media type the parser prefers is added beside it (a body without media-type lists reads as breaking). A body moving from `application/json` to `application/x-www-form-urlencoded` sets both flags — the JSON media type is removed and the form one added — while a media type changing essence within one encoding (`application/json` → `application/vnd.api+json`) sets `requestBodyContentTypesChanged` alone. A body that appears or disappears takes its encoding with it and is `requestBodyChanged` alone; one that changes type and encoding at once sets `requestBodyChanged` and `requestBodyEncodingChanged`. A webhook payload's encoding is compared through the same function (see Webhook below). +While both specs carry a `requestBody`, the differ reports a flip as `requestBodyRequiredChanged` on an `operation-modified` change: optional → required is breaking (callers must now send a body), required → optional is additive. A body appearing or disappearing is reported as `requestBodyChanged` alone. A body changing type also sets `requestBodyChanged`, and if its required flag flips in the same change, both flags are set. A body change is breaking, except when a body appears with `requestBodyRequired` not `true`: an optional body only gives callers an argument they may leave out, so it is additive, the same as `param-added` for an optional parameter. A body appearing as required, a body disappearing and a changed body type stay breaking, and as with every other flag, one breaking flag makes the whole `operation-modified` change breaking (an optional body added alongside a required parameter is breaking). While both specs declare a body, the media types it may be sent as (`requestBodyContentTypes`) are compared by essence as a response entry's are and reported as `requestBodyContentTypesChanged` with `requestBodyContentTypeChange` beside it: a media type callers could send that is gone, or a secondary media type whose body changed type, is breaking; one they can newly send is additive (see `oagen diff` in the CLI reference). A body changing encoding — `requestBodyEncoding`, the wire format it is serialized as (see Request Body Encoding above) — while both specs declare one sets `requestBodyEncodingChanged`, with `oldRequestBodyEncoding`/`newRequestBodyEncoding` beside it, and is breaking when the new document no longer accepts the representation the old document's body was read from — that media type gone from `requestBodyContentTypes`, or its shape changed (compared as `retyped` is) — since an SDK built for the old document keeps serializing and labelling the body as that document said; it is additive when the new document still accepts it, as when a media type the parser prefers is added beside it (a body without media-type lists reads as breaking). A body moving from `application/json` to `application/x-www-form-urlencoded` sets both flags — the JSON media type is removed and the form one added — while a media type changing essence within one encoding (`application/json` → `application/vnd.api+json`) sets `requestBodyContentTypesChanged` alone. A body that appears or disappears takes its encoding with it and is `requestBodyChanged` alone; one that changes type and encoding at once sets `requestBodyChanged` and `requestBodyEncodingChanged`. A webhook payload's encoding is compared through the same function (see Webhook below). ### Request Body Description diff --git a/skills/generate-emitter/references/generator-guide.md b/skills/generate-emitter/references/generator-guide.md index d94a4862..e1399a19 100644 --- a/skills/generate-emitter/references/generator-guide.md +++ b/skills/generate-emitter/references/generator-guide.md @@ -150,7 +150,7 @@ Use the shared utilities from `@workos/oagen` (`toPascalCase`, `toSnakeCase`, `t - `cookieParams?` — `Parameter[]`, present only when the operation declares cookie parameters - `requestBody?` — `TypeRef` of the request body, read from one `content` entry by media type essence (parameters and case aside): `application/json`, else a concrete `+json` media type, else `multipart/form-data`, else `application/x-www-form-urlencoded` (each only with a schema), else `application/octet-stream`, else `text/plain`; absent, with a parser warning, when none of the declared media types is read - `requestBodyEncoding?` — `json` (`application/json` and any concrete `+json` media type) | `form-data` | `form-urlencoded` | `binary` | `text`; selects the serialization, while `requestBodyContentType` is the `Content-Type` to send; set exactly when `requestBody` is - - `requestBodyRequired?` — whether the caller must send the body; present exactly when `requestBody` is + - `requestBodyRequired?` — whether the caller must send the body; present exactly when `requestBodyContentTypes` is, so a body carried in `requestBodyContents` alone has it too and it does not imply `requestBody` - `requestBodyDescription?` — the request body's doc text: the Request Body Object's own `description`, else the `description` written on the schema `requestBody` was read from (a `$ref` not followed); present only when one has non-whitespace content, for the body argument's doc tag (a `Webhook` carries the same for its payload) - `requestBodyContentType?` — the essence of the media type `requestBody` was read from (`application/merge-patch+json`; `application/json` for `application/json; charset=utf-8`); send it as the `Content-Type`, producing parameters such as a multipart boundary yourself; set exactly when `requestBody` is - `requestBodyContentTypes?`, `requestBodyContents?` — every media type the request body declares, as written and in document order, with a `TypeRef` per media type (the entry `requestBody` was read from shares its `TypeRef`); present only when the body declares at least one, so a body argument or serializer per media type can be offered diff --git a/src/ir/types.ts b/src/ir/types.ts index 8c2d6d2e..3c842443 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -359,11 +359,15 @@ export interface Operation { */ requestBodyContentType?: string; /** - * Whether a caller must send `requestBody`: the Request Body Object's + * Whether a caller must send the request body: the Request Body Object's * `required`, read after a `components/requestBodies` `$ref` is resolved. * OpenAPI defaults the flag to `false`, so a body the document does not * mark required comes through as `false`. Present exactly when - * `requestBody` is; an operation without a body has no entry. + * `requestBodyContentTypes` is — whenever the body declares a media type, + * whether it is read as `requestBody` or carried in `requestBodyContents` + * alone — so it is set beside every `requestBody` but does not imply one; + * an operation without a body, or whose body declares no media type, has + * no entry. */ requestBodyRequired?: boolean; /** diff --git a/src/parser/operations.ts b/src/parser/operations.ts index c03a3150..adfe1662 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -2673,8 +2673,9 @@ function buildOperation( // Read off the resolved body so a `components/requestBodies` ref's flag // counts. Only `true` makes the body required: OpenAPI defaults the flag // to `false`, and a non-boolean value is not a declaration of requiredness. - // Set exactly when a body is, so the two fields can be read together. - const requestBodyRequired = requestBody !== undefined ? requestBodyObject?.required === true : undefined; + // Set exactly when the body declares a media type, read or not, so a body + // carried in `requestBodyContents` alone keeps it, as a webhook's does. + const requestBodyRequired = requestBodyContentTypes !== undefined ? requestBodyObject?.required === true : undefined; // The page parameter's first value is read from its schema as written — // `schema`, else the picked `content` entry's (`parameterTyping`) — so a diff --git a/test/parser/request-body-content-types.test.ts b/test/parser/request-body-content-types.test.ts index e5e10006..1250ac71 100644 --- a/test/parser/request-body-content-types.test.ts +++ b/test/parser/request-body-content-types.test.ts @@ -351,10 +351,10 @@ describe('request bodies keep every media type (issue #278)', () => { const op = opsOf(spec).createPet; expect(op.requestBody).toBeUndefined(); expect(op.requestBodyEncoding).toBeUndefined(); - // Set exactly when `requestBody` is, so neither key exists here. - for (const key of ['requestBodyContentType', 'requestBodyRequired']) { - expect(Object.keys(op), key).not.toContain(key); - } + // Set exactly when `requestBody` is, so the key does not exist here. + expect(Object.keys(op)).not.toContain('requestBodyContentType'); + // Set whenever the body declares a media type, read or not (#388). + expect(op.requestBodyRequired).toBe(true); expect(op.requestBodyContentTypes).toEqual(['application/xml']); expect(op.requestBodyContents).toEqual([ { contentType: 'application/xml', type: { kind: 'model', name: 'CreatePetApplicationXmlRequest' } }, diff --git a/test/parser/request-body-media-types.test.ts b/test/parser/request-body-media-types.test.ts index 3e8ba577..b92cc2ce 100644 --- a/test/parser/request-body-media-types.test.ts +++ b/test/parser/request-body-media-types.test.ts @@ -263,11 +263,12 @@ describe('request body media types are matched by essence (issue #278)', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const spec = await parseSpec(specPath); const [op] = spec.services.flatMap((s) => s.operations); + // The required flag stays with the declared body, read or not (#388). expect(bodyFields(op)).toEqual({ requestBody: undefined, requestBodyEncoding: undefined, - requestBodyRequired: undefined, - hasFlag: false, + requestBodyRequired: true, + hasFlag: true, }); expect(op.queryParams.map((p) => p.name)).toEqual(['name']); expect(spec.webhooks?.[0].requestBody).toBeUndefined(); @@ -476,11 +477,12 @@ describe('request body media types are matched by essence (issue #278)', () => { it('sets no requestBody for a body under an unread media type and says so once, naming the operation and the key', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { op, inlineModels } = createPet({ 'application/xml': { schema: NAME_SCHEMA } }); + // The required flag stays with the declared body, read or not (#388). expect(bodyFields(op)).toEqual({ requestBody: undefined, requestBodyEncoding: undefined, - requestBodyRequired: undefined, - hasFlag: false, + requestBodyRequired: true, + hasFlag: true, }); // The body is still carried, typed under its media type, in `requestBodyContents`. expect(inlineModels.map((m) => m.name)).toEqual(['CreatePetApplicationXmlRequest']); diff --git a/test/parser/request-body-required.test.ts b/test/parser/request-body-required.test.ts index 5e90537e..7aa46025 100644 --- a/test/parser/request-body-required.test.ts +++ b/test/parser/request-body-required.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect } from 'vitest'; -import { resolve, dirname } from 'node:path'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { resolve, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { parseSpec } from '../../src/parser/parse.js'; import { extractOperations } from '../../src/parser/operations.js'; import type { Operation } from '../../src/ir/types.js'; @@ -129,16 +131,18 @@ describe('request body required (issue #213)', () => { expect(Object.keys(post)).not.toContain('requestBodyRequired'); }); - it('is absent when a declared body yields no IR body', () => { + it('is absent only when a declared body declares no media type', () => { // No `content` at all: nothing to type, so nothing to require. const contentless = singleOperation(withBody({ required: true })); expect(contentless.requestBody).toBeUndefined(); expect(Object.keys(contentless)).not.toContain('requestBodyRequired'); - // JSON without a schema is dropped by the parser; the flag follows the body. + // JSON without a schema yields no `requestBody`, but the body is still + // carried in `requestBodyContents`, and the flag stays with it (#388). const schemaless = singleOperation(withBody({ required: true, content: { 'application/json': {} } })); expect(schemaless.requestBody).toBeUndefined(); - expect(Object.keys(schemaless)).not.toContain('requestBodyRequired'); + expect(schemaless.requestBodyContentTypes).toEqual(['application/json']); + expect(schemaless.requestBodyRequired).toBe(true); }); it('does not leak into the body type', () => { @@ -250,4 +254,100 @@ describe('request body required (issue #213)', () => { expect(op.requestBodyRequired).toBe(true); }); }); + + describe('a body carried in requestBodyContents alone (issue #388)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('keeps the flag on the operation, as a webhook keeps it (issue repro)', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requestBody = { + required: true, + content: { 'application/xml': { schema: { type: 'object', properties: { name: { type: 'string' } } } } }, + }; + const dir = mkdtempSync(join(tmpdir(), 'oagen-388-')); + try { + const file = join(dir, 'required.json'); + writeFileSync( + file, + JSON.stringify({ + openapi: '3.1.0', + info: { title: 'required', version: '1' }, + paths: { '/pets': { post: { operationId: 'createPet', requestBody, responses: ok } } }, + webhooks: { petAdopted: { post: { requestBody, responses: {} } } }, + }), + ); + const { services, webhooks } = await parseSpec(file); + const sites = [...services.flatMap(({ operations }) => operations), ...(webhooks ?? [])]; + const projected = sites.map((site) => ({ + name: site.name, + requestBody: site.requestBody, + requestBodyContents: site.requestBodyContents?.map(({ type }) => type), + requestBodyContentTypes: site.requestBodyContentTypes, + requestBodyRequired: site.requestBodyRequired, + })); + expect(projected).toMatchInlineSnapshot(` + [ + { + "name": "createPet", + "requestBody": undefined, + "requestBodyContentTypes": [ + "application/xml", + ], + "requestBodyContents": [ + { + "kind": "model", + "name": "CreatePetApplicationXmlRequest", + }, + ], + "requestBodyRequired": true, + }, + { + "name": "petAdopted", + "requestBody": undefined, + "requestBodyContentTypes": [ + "application/xml", + ], + "requestBodyContents": [ + { + "kind": "model", + "name": "PetAdoptedApplicationXmlPayload", + }, + ], + "requestBodyRequired": true, + }, + ] + `); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('is false, and present, when the document leaves such a body optional', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const op = singleOperation(withBody({ content: { 'text/csv': { schema: { type: 'string' } } } })); + expect(bodyFields(op)).toEqual({ + requestBody: undefined, + requestBodyEncoding: undefined, + requestBodyRequired: false, + hasFlag: true, + }); + expect(op.requestBodyContentTypes).toEqual(['text/csv']); + }); + + it('is read after $ref resolution', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const xml = { required: true, content: { 'application/xml': { schema: noteSchema } } }; + const op = singleOperation(withBody({ $ref: '#/components/requestBodies/Note' }), { Note: xml }); + expect(op.requestBody).toBeUndefined(); + expect(op.requestBodyRequired).toBe(true); + }); + + it('is absent when the body declares no media type', () => { + const empty = singleOperation(withBody({ required: true, content: {} })); + expect(empty.requestBodyContentTypes).toBeUndefined(); + expect(Object.keys(empty)).not.toContain('requestBodyRequired'); + }); + }); }); From 2cfa7b0c38b897e8a465fde081b9c8e267bde728 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 10:21:47 -0400 Subject: [PATCH 28/59] fix(parser): set a webhook's required flag only beside a declared media type buildWebhook set `requestBodyRequired: true` from `body?.required` whether or not the body declared a media type, while buildOperation, since the parent commit, sets the flag exactly when requestBodyContentTypes is. With `requestBody: { required: true, content: {} }` (or `{ required: true }`) on both, createPet had no flag and petAdopted had `requestBodyRequired: true`, though the docs say an operation keeps its flag "as a webhook payload does". The webhook now reads the flag only beside requestBodyContentTypes and keeps its true-only value, so the two agree on when the flag is present; an operation still says `false` for a body the document leaves optional. No `oagen diff` output moves: diffWebhookBody compares the flag only while both sides carry a requestBody, which a body declaring no media type never has. The Webhook.requestBodyRequired JSDoc, its line in ir-types.md, the Request Body Required section and the emitter contract now give the rule. A test pins the review's documents through parseSpec: neither createPet nor petAdopted carries the flag or requestBodyContentTypes. The comment above bothCarryBody in diffOperations said the parser sets the required flag beside the body, so it goes with it. Since the parent commit the flag also sits on a body carried in requestBodyContents alone; the comment now says that flag is not compared either. Not marked breaking, as the parent commit is not: no name, type or `oagen diff` classification changes. A webhook whose body declares no media type loses a flag that sat beside no payload and no media type an emitter could offer. Addresses review findings 1 and 2 on #474 and the inline thread on src/parser/operations.ts. --- docs/architecture/emitter-contract.md | 2 +- docs/architecture/ir-types.md | 4 ++-- src/differ/operations.ts | 7 +++--- src/ir/types.ts | 8 ++++++- src/parser/webhooks.ts | 3 ++- test/parser/request-body-required.test.ts | 27 +++++++++++++++++++++++ 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/docs/architecture/emitter-contract.md b/docs/architecture/emitter-contract.md index e7bf2c58..213ede43 100644 --- a/docs/architecture/emitter-contract.md +++ b/docs/architecture/emitter-contract.md @@ -111,7 +111,7 @@ Emitters should handle these additional variants introduced for open-source read - **HTTP methods:** `head`, `options`, `trace` — HEAD returns no body, OPTIONS/TRACE are informational - **Cookie params:** Optional `cookieParams` array on operations that use cookie parameters - **Content parameters:** Optional `contentType: string` on `Parameter` — the `content` key a `content`-declared parameter's value is serialized under, as written (`application/json`, `application/json; charset=utf-8`), present exactly when the parameter is declared with `content` and the map is read; serialize such a value in that media type (JSON-encode an `application/json` or `+json` value) instead of by `style`/`explode`. Optional `contentTypes: string[]` beside it lists every media type the `content` map declares, as written, read or not: a map none of whose media types is read keeps its media type there, and its value, a string, is sent as written -- **Webhooks:** Optional `webhooks: Webhook[]` on `ApiSpec` — the document's top-level `webhooks` map (OpenAPI 3.1), one entry per HTTP method of each key, present only when the document declares at least one. A webhook is not an SDK method: it has no path, belongs to no `Service` and gets no resolved operation name. Each carries the payload (`requestBody`, with `requestBodyEncoding`, `requestBodyContentType`, `requestBodyDescription` and the `requestBodyContentTypes` / `requestBodyContents` lists read as an operation's are, and `requestBodyRequired` present, and `true`, only when the payload is required), the `headerParams`, `queryParams` and `cookieParams` the request carries, the `responses` the sender expects back, and `summary`, `description`, `externalDocs`, `deprecated`, `tags` and `security` as an operation has them; generate a payload type (the model is already in `spec.models`) or a typed handler per event, keyed by `name` + `httpMethod` +- **Webhooks:** Optional `webhooks: Webhook[]` on `ApiSpec` — the document's top-level `webhooks` map (OpenAPI 3.1), one entry per HTTP method of each key, present only when the document declares at least one. A webhook is not an SDK method: it has no path, belongs to no `Service` and gets no resolved operation name. Each carries the payload (`requestBody`, with `requestBodyEncoding`, `requestBodyContentType`, `requestBodyDescription` and the `requestBodyContentTypes` / `requestBodyContents` lists read as an operation's are, and `requestBodyRequired` present, and `true`, only when the payload is required and, as on an operation, declares at least one media type), the `headerParams`, `queryParams` and `cookieParams` the request carries, the `responses` the sender expects back, and `summary`, `description`, `externalDocs`, `deprecated`, `tags` and `security` as an operation has them; generate a payload type (the model is already in `spec.models`) or a typed handler per event, keyed by `name` + `httpMethod` - **Named unions:** Optional `variants: TypeRef[]` and `compositionKind: 'oneOf' | 'anyOf'` on `Model` (present together) for a component schema that is itself a `oneOf`/`anyOf`: emit such a model as a union of `variants`, not as a data class — `fields` holds only the inline variants' properties merged as optional fields. `discriminator` is set on a named union that is discriminated — a `oneOf` or `anyOf` with an explicit `discriminator:` keyword, or a `oneOf` without the keyword whose variants are all nameable and pin distinct string constants on one property; an `anyOf` is never discriminated implicitly — as it is on an allOf+oneOf dispatcher, so an emitter that keys dispatchers on it emits one for such a union too; its `mapping` values are IR model names. A `$ref` to a named union that admits `null` resolves to `nullable` - **Extensions:** Optional `extensions: Record` on `Operation`, `Model`, `Field` and `Enum` — the `x-*` keys of the object the node was built from, each under the key as written with its value verbatim, less the keys the parser reads into typed fields (an Operation Object's `x-pagination`, `x-oagen-async`, `x-mutually-exclusive-parameter-groups` and `x-mutually-exclusive-body-groups`; a Schema Object's `x-enum-descriptions` and `x-enumDescriptions`), present only when at least one key remains. Read the keys the SDK knows (`x-idempotent: true` on a `POST` that is safe to retry, `x-since` for an `@since` tag); the values come from the document unvalidated, so escape one before writing it into generated source, as a `description` is escaped. No other node carries them (see [Extensions](ir-types.md#extensions)) - **Enum descriptions:** Optional `description: string` on `Enum` — the enum schema's own `description`, a component enum's or the inline schema's an enum was lifted from, present only with non-whitespace content; emit it as the enum's doc comment diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index ca7ecc16..8b31f318 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -524,7 +524,7 @@ A body that is itself an inline enum, or an array of one, mints no model: it is ### Request Body Required -`requestBodyRequired` carries the Request Body Object's `required` flag, so an emitter can make the body argument mandatory or optional to match the document. It is read after a `$ref` into `components/requestBodies` is resolved, so a referenced body's flag counts. OpenAPI defaults the flag to `false`, so a body the document does not mark required is `false` here — an explicit `required: false` and an omitted flag are the same thing, and only a literal `true` makes a body required. The field is present exactly when `requestBodyContentTypes` is — whenever the body declares at least one media type — whatever the encoding or the body's shape (an inline object, a bare `$ref`, a `oneOf`, a schema-less binary or text body), and whether or not one of its media types is read as `requestBody`: a body carried in `requestBodyContents` alone (see Request Body Encoding above) keeps its flag, as a webhook payload does, so an emitter that offers it can tell whether the caller must send it. The field is therefore set beside every `requestBody` but does not imply one. An operation without a body, or whose body declares no media type (no `content`, or an empty map), has no entry. +`requestBodyRequired` carries the Request Body Object's `required` flag, so an emitter can make the body argument mandatory or optional to match the document. It is read after a `$ref` into `components/requestBodies` is resolved, so a referenced body's flag counts. OpenAPI defaults the flag to `false`, so a body the document does not mark required is `false` here — an explicit `required: false` and an omitted flag are the same thing, and only a literal `true` makes a body required. The field is present exactly when `requestBodyContentTypes` is — whenever the body declares at least one media type — whatever the encoding or the body's shape (an inline object, a bare `$ref`, a `oneOf`, a schema-less binary or text body), and whether or not one of its media types is read as `requestBody`: a body carried in `requestBodyContents` alone (see Request Body Encoding above) keeps its flag, as a webhook payload does, so an emitter that offers it can tell whether the caller must send it. The field is therefore set beside every `requestBody` but does not imply one. An operation without a body, or whose body declares no media type (no `content`, or an empty map), has no entry, and neither has a webhook whose payload declares none (see Webhook below). While both specs carry a `requestBody`, the differ reports a flip as `requestBodyRequiredChanged` on an `operation-modified` change: optional → required is breaking (callers must now send a body), required → optional is additive. A body appearing or disappearing is reported as `requestBodyChanged` alone. A body changing type also sets `requestBodyChanged`, and if its required flag flips in the same change, both flags are set. A body change is breaking, except when a body appears with `requestBodyRequired` not `true`: an optional body only gives callers an argument they may leave out, so it is additive, the same as `param-added` for an optional parameter. A body appearing as required, a body disappearing and a changed body type stay breaking, and as with every other flag, one breaking flag makes the whole `operation-modified` change breaking (an optional body added alongside a required parameter is breaking). While both specs declare a body, the media types it may be sent as (`requestBodyContentTypes`) are compared by essence as a response entry's are and reported as `requestBodyContentTypesChanged` with `requestBodyContentTypeChange` beside it: a media type callers could send that is gone, or a secondary media type whose body changed type, is breaking; one they can newly send is additive (see `oagen diff` in the CLI reference). A body changing encoding — `requestBodyEncoding`, the wire format it is serialized as (see Request Body Encoding above) — while both specs declare one sets `requestBodyEncodingChanged`, with `oldRequestBodyEncoding`/`newRequestBodyEncoding` beside it, and is breaking when the new document no longer accepts the representation the old document's body was read from — that media type gone from `requestBodyContentTypes`, or its shape changed (compared as `retyped` is) — since an SDK built for the old document keeps serializing and labelling the body as that document said; it is additive when the new document still accepts it, as when a media type the parser prefers is added beside it (a body without media-type lists reads as breaking). A body moving from `application/json` to `application/x-www-form-urlencoded` sets both flags — the JSON media type is removed and the form one added — while a media type changing essence within one encoding (`application/json` → `application/vnd.api+json`) sets `requestBodyContentTypesChanged` alone. A body that appears or disappears takes its encoding with it and is `requestBodyChanged` alone; one that changes type and encoding at once sets `requestBodyChanged` and `requestBodyEncodingChanged`. A webhook payload's encoding is compared through the same function (see Webhook below). @@ -657,7 +657,7 @@ interface Webhook { requestBodyEncoding?: "json" | "form-data" | "form-urlencoded" | "binary" | "text"; requestBodyContentType?: string; // The essence of the media type the payload was read from, as on Operation; present exactly when requestBody is - requestBodyRequired?: boolean; // Present, and true, only when the body is `required` + requestBodyRequired?: boolean; // Present, and true, only when the body is `required` and declares a media type (only when requestBodyContentTypes is, as on Operation) requestBodyDescription?: string; // The Request Body Object's description, else the picked schema's own, as on Operation (only when one has content) requestBodyContentTypes?: string[]; // Media types the payload declares, in document order (only when any exist) requestBodyContents?: RequestBodyContent[]; // A type per media type above, same order (only when any exist) diff --git a/src/differ/operations.ts b/src/differ/operations.ts index dfe28a24..b6590050 100644 --- a/src/differ/operations.ts +++ b/src/differ/operations.ts @@ -116,9 +116,10 @@ export function diffOperations( const responseChanged = !typeRefsEqual(oldOp.response, newOp.response) || successGainedOrLost; const requestBodyChanged = !requestBodiesEqual(oldOp.requestBody, newOp.requestBody); // The required flag and the encoding are compared only while both sides - // carry a body: a body appearing or disappearing is already - // `requestBodyChanged`, and the parser sets both beside the body, so - // they go with it. + // carry a body (`requestBody`): a body appearing or disappearing is + // already `requestBodyChanged`, so it takes both with it. The parser also + // sets the flag on a body carried in `requestBodyContents` alone (one + // under `application/xml` alone, say); that flag is not compared either. const bothCarryBody = !!oldOp.requestBody && !!newOp.requestBody; // An absent flag reads as optional, as it does in OpenAPI. const requestBodyRequiredChanged = diff --git a/src/ir/types.ts b/src/ir/types.ts index 3c842443..9c4ce188 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -179,7 +179,13 @@ export interface Webhook { * exactly when `requestBody` is. */ requestBodyContentType?: string; - /** Present, and `true`, only when the document marks the request body `required`. */ + /** + * Present, and `true`, only when the document marks the request body + * `required` and the body declares a media type (`requestBodyContentTypes` + * is set), the presence rule `Operation.requestBodyRequired` follows: a + * payload carried in `requestBodyContents` alone keeps it, and a body with + * no `content`, or an empty map, has no entry. + */ requestBodyRequired?: boolean; /** * Doc text for the payload, read as `Operation.requestBodyDescription` diff --git a/src/parser/webhooks.ts b/src/parser/webhooks.ts index 03ab2be5..a7748424 100644 --- a/src/parser/webhooks.ts +++ b/src/parser/webhooks.ts @@ -981,7 +981,8 @@ function buildWebhook( ...(requestBody ? { requestBody } : {}), ...(requestBodyEncoding ? { requestBodyEncoding } : {}), ...(requestBodyContentType ? { requestBodyContentType } : {}), - ...(body?.required === true ? { requestBodyRequired: true } : {}), + // Set only when the body declares a media type, as an operation's flag is. + ...(requestBodyContentTypes && body?.required === true ? { requestBodyRequired: true } : {}), ...(requestBodyDescription !== undefined ? { requestBodyDescription } : {}), // Every media type the body declares, present only when there is one, as on an operation. ...(requestBodyContentTypes ? { requestBodyContentTypes, requestBodyContents } : {}), diff --git a/test/parser/request-body-required.test.ts b/test/parser/request-body-required.test.ts index 7aa46025..245f445e 100644 --- a/test/parser/request-body-required.test.ts +++ b/test/parser/request-body-required.test.ts @@ -349,5 +349,32 @@ describe('request body required (issue #213)', () => { expect(empty.requestBodyContentTypes).toBeUndefined(); expect(Object.keys(empty)).not.toContain('requestBodyRequired'); }); + + it('is absent on the webhook too when the body declares no media type, as on the operation (#388 review)', async () => { + for (const requestBody of [{ required: true, content: {} }, { required: true }]) { + const dir = mkdtempSync(join(tmpdir(), 'oagen-388-')); + try { + const file = join(dir, 'required.json'); + writeFileSync( + file, + JSON.stringify({ + openapi: '3.1.0', + info: { title: 'required', version: '1' }, + paths: { '/pets': { post: { operationId: 'createPet', requestBody, responses: ok } } }, + webhooks: { petAdopted: { post: { requestBody, responses: {} } } }, + }), + ); + const { services, webhooks } = await parseSpec(file); + const sites = [...services.flatMap(({ operations }) => operations), ...(webhooks ?? [])]; + expect(sites.map(({ name }) => name)).toEqual(['createPet', 'petAdopted']); + for (const site of sites) { + expect(site.requestBodyContentTypes, site.name).toBeUndefined(); + expect(Object.keys(site), site.name).not.toContain('requestBodyRequired'); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + }); }); }); From 3144ce334a1e4a2ccfa9258c17e013898ef0dc97 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 13:56:59 -0400 Subject: [PATCH 29/59] fix(parser)!: name the service from the first tag that has a name An empty first tag was already skipped, so tags ['', 'Notes'] landed in Notes. A first tag that was only whitespace (' ') or only punctuation ('-') was not: extractTags kept every string but '', and inferServiceName PascalCased the first one whatever came out, so the operation was grouped under a service named "". `oagen diff` from ['Notes'] to [' ', 'Notes'] then reported service-added "" and service-removed Notes, and exited 2. extractTags now drops a blank entry, read by presentText as blank text is everywhere else, on operations and webhooks alike. The service is named from the first entry of that list whose PascalCase name is not empty (serviceTag), so ['-', 'Notes'] lands in Notes while Operation.tags keeps the '-'. An operation none of whose tags has a name is untagged: it is named from its path and counts toward the API root, through the same helper. The Operation.tags JSDoc, ir-types.md, cli.md and the generator guide said the service is named from tags[0]; they now give the new rule. Closes #412 BREAKING CHANGE: an operation whose first tag is blank or has no ASCII letter or digit is no longer grouped under a service named "". It moves to the service its first tag with a name gives, or to its path-named service when no tag has one, and the parameter and header models named after its service move with it (Filter -> NotesFilter). Whitespace-only entries no longer appear in Operation.tags or Webhook.tags. --- docs/architecture/ir-types.md | 6 +-- docs/cli.md | 2 +- .../references/generator-guide.md | 2 +- src/ir/types.ts | 11 ++--- src/parser/operations.ts | 38 +++++++++++----- test/differ/security-tags.test.ts | 28 ++++++++++++ test/parser/operations.test.ts | 45 +++++++++++++++++++ test/parser/webhooks.test.ts | 9 ++++ 8 files changed, 119 insertions(+), 22 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 8b31f318..f905a459 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -340,7 +340,7 @@ An override that cannot be applied is reported as an `[oagen] Warning:` and igno ## Service & Operation -Service names are derived from OpenAPI tags when present — the first usable tag on an operation (`Operation.tags[0]`: the first entry that is a non-empty string, so `['', 'notes']` and `[42, 'notes']` both name `Notes`) is converted to PascalCase (e.g., `tags: ["multi-factor-auth"]` → service `MultiFactorAuth`). When no usable tag is present, the parser falls back to a path segment (e.g., `/organizations/{id}` → `Organizations`), skipping the API root the document's untagged operations sit under. Each untagged path contributes its leading literal segments through the last API marker — `api`, `rest`, or a version such as `v1`, `2.0` or `2010-04-01` — and the root is the longest marker-terminated prefix shared (compared verbatim) by every contribution that no other contribution extends, so a document whose paths all start with `/api/v1/` groups `/api/v1/notes` under `Notes` rather than collapsing the whole API into one `Api` service, and `/wiki/rest/api/content` lands in `Content`. A path that stops short of that root — `/api/openapi.json` or `/api/admin/users` beside `/api/v1/notes` — skips only its own `api` (`OpenapiJson`, `Admin`) and leaves the root at `api/v1` for the rest, so `/api/v1/notes` stays `Notes`; only a second version, `/api/v2/…` beside `/api/v1/…`, shortens the root to `api`, which keeps the versions apart as `V1` and `V2`. The service is named from the first literal segment after the root, so `/v1/notes` and `/v1/{teamId}/notes` share `Notes`, and a custom method written after a colon (`/v1/pets:batchGet`, `/v1/pets/{petId}:adopt`; only a segment's one colon outside a parameter's braces starts one, so `file-{file_path:path}` and `urn:pet:1` are one segment each, and `{name}:a:b` is one literal segment rather than a parameter, so `/v1/{name}:a:b` names `NameAB`) is read as its segment followed by the verb, so both land in `Pets`, while the verb itself never names a service, so `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}` (the slash-written `/v1/{name}/cancel` names `Cancel`); a path with nothing literal after the root (`/api` itself, `/api/{id}`, `/api/v1:reset`) keeps its first segment, `Api`. A path with no marker at all (`/health`, `/openapi.json`) sits outside the root, keeps its own first segment, and does not switch the root off for the rest of the document. A shared prefix that carries no marker is kept whole, since a module prefix every path shares (`/user_management/users`, `/user_management/invitations` → `UserManagement`) is the service, not noise; and `/v1/…` beside `/v2/…` has no shared root, so the versions stay separate services. Tagged operations and path items without an operation take no part. The path-prefix split that carves a tagged service into one per first segment (`/organizations` and `/organization_domains` both tagged `organizations` → `Organizations` and `OrganizationDomains`) is decided over the tag's own operations alone, from the grouping before any service is split: a tag whose operations all sit under one first segment keeps them together even when that segment is not the tag, so `/api/v1/notes` tagged `notes` stays in `Notes`, not `Api`, whatever untagged operations `Notes` picked up by path. An untagged operation keeps the service its path named — it is never moved by a split, and it neither causes nor prevents one — so `/notes` and `/api/v1/notes` meet in `Notes` instead of the latter moving to `Api`, and the collision rules below tell their names apart. A split-off part named like an existing service — another tag's, or a path-named one — joins it, and the operations it brings do not re-split that service. Fallback operation names skip the root and the service segment the same way, so `/api/v1/notes` is `Notes.list`, never `Notes.listV1Notes`; when two path-named operations still share a name in one service and their paths differ only by parameters, the parameters qualify them (`/v1/notes` → `Notes.list`, `/v1/{teamId}/notes` → `Notes.listByTeamId`; see the collision rules under Operation below). Without a root — a root-less document, or a path outside the root — a path-named service is the path's first segment (`Default` when that segment is a parameter) and the fallback name skips the service segment alone, up to those collision rules: an untagged `/{tenant}/users` is `Default.listUsers`, and `/{id}` is `Default.get`. A tagged operation's service is its tag whatever the root, subject only to the path-prefix split above: `/api/notes` tagged `notes` is `Notes.listNotes`. Emitters can further remap service names via an overlay (e.g., `MultiFactorAuth` → `Mfa`) to match existing SDK class names. +Service names are derived from OpenAPI tags when present — the first tag on an operation that still has a name once converted to PascalCase names it (e.g., `tags: ["multi-factor-auth"]` → service `MultiFactorAuth`). `Operation.tags` drops every entry that is not a string with non-whitespace content, so `['', 'notes']`, `[' ', 'notes']` and `[42, 'notes']` all name `Notes`; an entry with no ASCII letter or digit, which converts to nothing, stays in `Operation.tags` but is passed over, so `['-', 'notes']` names `Notes` too. When no tag has a name, the operation counts as untagged and the parser falls back to a path segment (e.g., `/organizations/{id}` → `Organizations`), skipping the API root the document's untagged operations sit under. Each untagged path contributes its leading literal segments through the last API marker — `api`, `rest`, or a version such as `v1`, `2.0` or `2010-04-01` — and the root is the longest marker-terminated prefix shared (compared verbatim) by every contribution that no other contribution extends, so a document whose paths all start with `/api/v1/` groups `/api/v1/notes` under `Notes` rather than collapsing the whole API into one `Api` service, and `/wiki/rest/api/content` lands in `Content`. A path that stops short of that root — `/api/openapi.json` or `/api/admin/users` beside `/api/v1/notes` — skips only its own `api` (`OpenapiJson`, `Admin`) and leaves the root at `api/v1` for the rest, so `/api/v1/notes` stays `Notes`; only a second version, `/api/v2/…` beside `/api/v1/…`, shortens the root to `api`, which keeps the versions apart as `V1` and `V2`. The service is named from the first literal segment after the root, so `/v1/notes` and `/v1/{teamId}/notes` share `Notes`, and a custom method written after a colon (`/v1/pets:batchGet`, `/v1/pets/{petId}:adopt`; only a segment's one colon outside a parameter's braces starts one, so `file-{file_path:path}` and `urn:pet:1` are one segment each, and `{name}:a:b` is one literal segment rather than a parameter, so `/v1/{name}:a:b` names `NameAB`) is read as its segment followed by the verb, so both land in `Pets`, while the verb itself never names a service, so `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}` (the slash-written `/v1/{name}/cancel` names `Cancel`); a path with nothing literal after the root (`/api` itself, `/api/{id}`, `/api/v1:reset`) keeps its first segment, `Api`. A path with no marker at all (`/health`, `/openapi.json`) sits outside the root, keeps its own first segment, and does not switch the root off for the rest of the document. A shared prefix that carries no marker is kept whole, since a module prefix every path shares (`/user_management/users`, `/user_management/invitations` → `UserManagement`) is the service, not noise; and `/v1/…` beside `/v2/…` has no shared root, so the versions stay separate services. Tagged operations and path items without an operation take no part. The path-prefix split that carves a tagged service into one per first segment (`/organizations` and `/organization_domains` both tagged `organizations` → `Organizations` and `OrganizationDomains`) is decided over the tag's own operations alone, from the grouping before any service is split: a tag whose operations all sit under one first segment keeps them together even when that segment is not the tag, so `/api/v1/notes` tagged `notes` stays in `Notes`, not `Api`, whatever untagged operations `Notes` picked up by path. An untagged operation keeps the service its path named — it is never moved by a split, and it neither causes nor prevents one — so `/notes` and `/api/v1/notes` meet in `Notes` instead of the latter moving to `Api`, and the collision rules below tell their names apart. A split-off part named like an existing service — another tag's, or a path-named one — joins it, and the operations it brings do not re-split that service. Fallback operation names skip the root and the service segment the same way, so `/api/v1/notes` is `Notes.list`, never `Notes.listV1Notes`; when two path-named operations still share a name in one service and their paths differ only by parameters, the parameters qualify them (`/v1/notes` → `Notes.list`, `/v1/{teamId}/notes` → `Notes.listByTeamId`; see the collision rules under Operation below). Without a root — a root-less document, or a path outside the root — a path-named service is the path's first segment (`Default` when that segment is a parameter) and the fallback name skips the service segment alone, up to those collision rules: an untagged `/{tenant}/users` is `Default.listUsers`, and `/{id}` is `Default.get`. A tagged operation's service is its tag whatever the root, subject only to the path-prefix split above: `/api/notes` tagged `notes` is `Notes.listNotes`. Emitters can further remap service names via an overlay (e.g., `MultiFactorAuth` → `Mfa`) to match existing SDK class names. `Service.description` comes from the document's top-level `tags` list, matched by name: a tag entry applies to the service whose PascalCase name its `name` converts to, so `user-management`, `User Management`, and `user_management` all map to `UserManagement`. A service whose own name is a declared tag is described by that tag alone — its description when it has one, nothing otherwise — so another tag's wording is never borrowed for it (operations tagged `api_keys` that the path-prefix split merges into `UserManagement` do not label it "API keys"). A service whose own name is not declared, which is what the split produces when it carves `OrganizationDomains` out of operations tagged `organizations`, inherits the description of the first described tag its operations carry. The key is omitted when neither rule yields a description, including for path-named services whose operations have no tag. Malformed `tags` values (a non-array, `null` entries, non-string names) are skipped rather than failing the parse. @@ -402,7 +402,7 @@ interface Operation { injectIdempotencyKey: boolean; // true when the operation declares an Idempotency-Key header parameter, which is then left out of headerParams deprecated?: boolean; // Present, and true, only when the Operation Object says deprecated: true async?: boolean; // The x-oagen-async extension, carried as the document writes it (see Async marker below) - tags?: string[]; // The operation's tags in document order, entries that are not non-empty strings dropped, repeats folded; only when at least one remains; the service is named from [0] + tags?: string[]; // The operation's tags in document order, entries that are not strings with non-whitespace content dropped, repeats folded; only when at least one remains; the service is named from the first entry that has a name once PascalCased security?: SecurityRequirementSet[]; // Per-operation requirements; undefined inherits ApiSpec.security parameterGroups?: ParameterGroup[]; // Mutually-exclusive parameter groups from x-mutually-exclusive-parameter-groups; see ParameterGroup in src/ir/types.ts } @@ -428,7 +428,7 @@ type SecurityRequirementSet = SecurityRequirement[]; To resolve what an operation accepts, take `op.security ?? spec.security`; a result of `undefined` means the document declares no requirements at all. The same list shape applies to `ApiSpec.security`. -The differ compares security on what a request actually carries, resolved the same way on each side (`[]` when neither the node nor its document declares a list), and reports a difference as `securityChanged: true` on the `operation-modified` or `webhook-modified` change with `oldSecurity`/`newSecurity` beside it, present exactly when the flag is. A node that declares no list on either side inherits twice and is not reported: a moved document default is the document-level `security-changed` change's to report, so a document that moves to what an operation already declared (its override vanishing with it) is no change to that operation. Lists are compared as sets of alternatives, each a set of schemes with scopes as sets, so order and repetition do not count, and `security: []`, a lone anonymous alternative (`[{}]`) and nothing declared anywhere are the same thing: no authentication. The classification is read from the node's side of the wire, by one rule applied in each direction (`classifySecurityChange` in `src/differ/security.ts`): for an operation, a caller built for the old list must still satisfy the new one — breaking when some old alternative satisfies no new one (a scheme or scope now required, an alternative removed, an anonymous alternative withdrawn), additive when every old alternative still passes (an alternative added, a scheme or scope no longer needed, authentication made optional); for a webhook, the handler built for the old list must still find what it checked for in whatever the sender now presents — breaking when some new alternative carries no old one (a credential the sender stops presenting, an alternative it may now use that the old list never offered), additive when every new alternative still carries some old one (a credential the sender starts carrying beside the old ones, an alternative withdrawn). The document-level change is read from each side that inherits the default in both documents — the caller's when an operation does, the receiver's when a webhook does, each matched across the two documents as its own change is (an operation by name within its service, a webhook by name and method) — and is breaking if any such side breaks. No other node reads both defaults: one that declares its own list on either side is compared on its own, as above, and one added or removed is reported as such. When nothing inherits the default in both documents, no request reads both defaults, so the change is additive. Every security change is mirrored into `behaviorChanges` as a `security-changed` entry naming the node (`document`, `operation` or `webhook`) with the two lists and the security change's own classification, beside the deprecation and parameter-default entries collected there. `tags` differing — compared in document order, an absent key as an empty list — is `tagsChanged: true` on the same changes with `oldTags`/`newTags` beside it, additive always and not mirrored into `behaviorChanges`. That flag can only ever carry an edit that leaves the operation in a service of the same name, because operations are matched by name within a service: the parser groups an operation by `tags[0]` (the first usable entry) and then the path-prefix split (see Service above), so a first-tag change that the split files back under the same path-named service is a `tagsChanged`, while one that moves the operation to a service of another name leaves nothing to match, and the diff reports the move as `operation-removed` plus `operation-added` across the two services, or `service-removed` plus `service-added` when a service goes or comes with it — breaking, as a moved method is. A webhook belongs to no service, so every edit to its `tags` is a `tagsChanged`. +The differ compares security on what a request actually carries, resolved the same way on each side (`[]` when neither the node nor its document declares a list), and reports a difference as `securityChanged: true` on the `operation-modified` or `webhook-modified` change with `oldSecurity`/`newSecurity` beside it, present exactly when the flag is. A node that declares no list on either side inherits twice and is not reported: a moved document default is the document-level `security-changed` change's to report, so a document that moves to what an operation already declared (its override vanishing with it) is no change to that operation. Lists are compared as sets of alternatives, each a set of schemes with scopes as sets, so order and repetition do not count, and `security: []`, a lone anonymous alternative (`[{}]`) and nothing declared anywhere are the same thing: no authentication. The classification is read from the node's side of the wire, by one rule applied in each direction (`classifySecurityChange` in `src/differ/security.ts`): for an operation, a caller built for the old list must still satisfy the new one — breaking when some old alternative satisfies no new one (a scheme or scope now required, an alternative removed, an anonymous alternative withdrawn), additive when every old alternative still passes (an alternative added, a scheme or scope no longer needed, authentication made optional); for a webhook, the handler built for the old list must still find what it checked for in whatever the sender now presents — breaking when some new alternative carries no old one (a credential the sender stops presenting, an alternative it may now use that the old list never offered), additive when every new alternative still carries some old one (a credential the sender starts carrying beside the old ones, an alternative withdrawn). The document-level change is read from each side that inherits the default in both documents — the caller's when an operation does, the receiver's when a webhook does, each matched across the two documents as its own change is (an operation by name within its service, a webhook by name and method) — and is breaking if any such side breaks. No other node reads both defaults: one that declares its own list on either side is compared on its own, as above, and one added or removed is reported as such. When nothing inherits the default in both documents, no request reads both defaults, so the change is additive. Every security change is mirrored into `behaviorChanges` as a `security-changed` entry naming the node (`document`, `operation` or `webhook`) with the two lists and the security change's own classification, beside the deprecation and parameter-default entries collected there. `tags` differing — compared in document order, an absent key as an empty list — is `tagsChanged: true` on the same changes with `oldTags`/`newTags` beside it, additive always and not mirrored into `behaviorChanges`. That flag can only ever carry an edit that leaves the operation in a service of the same name, because operations are matched by name within a service: the parser groups an operation by its first tag with a name (`tags[0]` unless it has no ASCII letter or digit) and then the path-prefix split (see Service above), so a first-tag change that the split files back under the same path-named service is a `tagsChanged`, while one that moves the operation to a service of another name leaves nothing to match, and the diff reports the move as `operation-removed` plus `operation-added` across the two services, or `service-removed` plus `service-added` when a service goes or comes with it — breaking, as a moved method is. A webhook belongs to no service, so every edit to its `tags` is a `tagsChanged`. ```typescript interface SuccessResponse { diff --git a/docs/cli.md b/docs/cli.md index 7a08dc6e..8b637102 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -84,7 +84,7 @@ Every URL in that comparison — `baseUrl`, each `url` template and each resolve **Security.** What a request must carry is compared on the document and on every operation and webhook. `ApiSpec.security` — the default every operation and webhook that declares none of its own inherits — differing between the two documents is one `security-changed` change, with `oldSecurity`/`newSecurity` beside it (`[]` when a document declares none; `security: []` and an undeclared list both mean no authentication). An operation or webhook that declares its own list on either side is compared on what it actually requires — its own list, else that document's — and reported with `securityChanged: true` on its `operation-modified` or `webhook-modified` change, with `oldSecurity`/`newSecurity` beside it; one that inherits on both sides is not reported again for a moved default. Lists are compared as sets of alternatives, each a set of schemes with scopes as sets, so order and repetition are not changes. The classification is read from the node's side of the wire. For an operation, a caller built for the old list must still satisfy the new one: breaking when some old alternative satisfies no new one (a scheme or scope now required, an alternative removed, an anonymous alternative withdrawn), additive otherwise (an alternative added, a scheme or scope no longer needed, authentication made optional). For a webhook, a handler built for the old list must still find what it checked for in whatever the sender now presents: breaking when some new alternative carries no old one (a credential the sender stops presenting, an alternative it may now use that the old list never offered), additive otherwise (a credential the sender starts carrying beside the old ones, an alternative withdrawn). The document-level change is read from each side that inherits the default in both documents — the caller's when an operation does, the receiver's when a webhook does, each matched across the two documents as its own change is (an operation by name within its service, a webhook by name and method) — and is breaking if any such side breaks. No other node reads both defaults: one that declares its own list on either side is compared on its own, as above, and one added or removed is reported as such. When nothing inherits the default in both documents (say the only operation that did now declares the old default as its own list), no request reads both defaults, so the change is additive. A change whose only content is security is still reported, and a breaking change on the same node keeps the change breaking. Every security change is mirrored into `behaviorChanges` as a `security-changed` entry naming the node (`document`, `operation` or `webhook`), copying the two lists and carrying the security change's own classification, which stays additive when the owning change is breaking for another reason. -**Tags.** Operations are matched by name within a service, and the parser groups an operation into a service by `tags[0]` — the first usable entry, non-empty strings only, repeats folded (then the path-prefix split, see [Service naming](architecture/ir-types.md#service--operation)), so a `tags` edit is reported by where the operation ends up. When it stays in a service of the same name — any edit that leaves the first entry alone, a first tag respelled to the same service name (`pets` → `Pets`), or a first tag changed to one the split files back under the same path-named service — it is `tagsChanged: true` on the `operation-modified` change, with `oldTags`/`newTags` beside it (`[]` for none; the lists are compared in document order, so a reorder after the first entry counts). When the first entry moves it to a service of another name, there is no `operation-modified` to carry a flag: the diff reports the move, `operation-removed` from the old service (breaking) and `operation-added` to the new one (additive), or `service-removed` (breaking) and `service-added` (additive) when a service goes or comes with it — so swapping `['Pets', 'Store']` to `['Store', 'Pets']` on a document's only operation exits 2. A webhook belongs to no service, so every edit to its `tags` is `tagsChanged` on its `webhook-modified` change. `tagsChanged` is always additive: tags group and document a method and change nothing a caller compiles against. A change whose only content is such a tags edit is still reported (so the command exits 1), and tags are not mirrored into `behaviorChanges`. +**Tags.** Operations are matched by name within a service, and the parser groups an operation into a service by its first tag that has a name — `tags[0]`, blank and non-string entries dropped and repeats folded, unless it has no ASCII letter or digit (`-`), which names nothing and is passed over (then the path-prefix split, see [Service naming](architecture/ir-types.md#service--operation)), so a `tags` edit is reported by where the operation ends up. When it stays in a service of the same name — any edit that leaves the first entry with a name alone (`['Pets']` → `['-', 'Pets']` included), a first tag respelled to the same service name (`pets` → `Pets`), or a first tag changed to one the split files back under the same path-named service — it is `tagsChanged: true` on the `operation-modified` change, with `oldTags`/`newTags` beside it (`[]` for none; the lists are compared in document order, so a reorder after the first entry counts). When the first entry moves it to a service of another name, there is no `operation-modified` to carry a flag: the diff reports the move, `operation-removed` from the old service (breaking) and `operation-added` to the new one (additive), or `service-removed` (breaking) and `service-added` (additive) when a service goes or comes with it — so swapping `['Pets', 'Store']` to `['Store', 'Pets']` on a document's only operation exits 2. A webhook belongs to no service, so every edit to its `tags` is `tagsChanged` on its `webhook-modified` change. `tagsChanged` is always additive: tags group and document a method and change nothing a caller compiles against. A change whose only content is such a tags edit is still reported (so the command exits 1), and tags are not mirrored into `behaviorChanges`. **Extensions.** A node's `extensions` — the `x-*` keys the operation, model, field or enum was built with (see [Extensions](architecture/ir-types.md#extensions)) — differing between the two specs is reported and is additive: a key added, removed or given another value, compared deeply with key order aside, an absent key and an empty object both reading as none. It is `extensionsChanged: true` on the `operation-modified`, `model-modified` or `enum-modified` change, with `oldExtensions`/`newExtensions` beside it — each present only when the node had extensions on that side — and a `field-extensions-changed` entry carrying the same two values in a `model-modified` change's `fieldChanges`. A list envelope's fields (`Operation.pagination.envelopeFields`) are compared as a model's fields are (see List envelopes below), so an extension change on one — a scalar companion such as `has_more`, which no model carries — is a `field-extensions-changed` entry in the `operation-modified` change's `envelopeFieldChanges`; a companion object's model is compared like any model, so its fields report under `model-modified`. The flag is present only when the extensions differ, unlike `deprecatedChanged`, so a change report without an extension change keeps its shape. Extensions are not shape: a change to them is never a `field-type-changed`, `param-type-changed` or retyped body, a body comparison that reads a model or enum ref through to its shape ignores them as it ignores documentation, and a breaking change on the same node keeps the change breaking. A change whose only content is an extension change is still reported (so the command exits 1), since an emitter may derive behavior from a key it knows. Each is mirrored into `behaviorChanges` as an `extensions-changed` entry naming the node (`operation`, `model`, `field`, `enum` or `envelope-field`, the last with the operation and field names) and copying `oldExtensions`/`newExtensions` from the change. A parameter, response header, webhook, request body or response object carries no `extensions`, so none is compared there. diff --git a/skills/generate-emitter/references/generator-guide.md b/skills/generate-emitter/references/generator-guide.md index e1399a19..e84b9c78 100644 --- a/skills/generate-emitter/references/generator-guide.md +++ b/skills/generate-emitter/references/generator-guide.md @@ -166,7 +166,7 @@ Use the shared utilities from `@workos/oagen` (`toPascalCase`, `toSnakeCase`, `t - `injectIdempotencyKey` — `true` when the spec declares an `Idempotency-Key` header parameter on the operation, which is then left out of `headerParams`: generate and send the header yourself - `deprecated?` — present, and `true`, only when the Operation Object says `deprecated: true` - `async?` — the Operation Object's `x-oagen-async` extension, carried as the document writes it (no test or coercion, so a non-boolean value such as `'yes'` arrives as that string); test `=== true` - - `tags?` — the operation's `tags` in document order, present only when at least one usable entry remains; for grouping or doc comments (the parser already named the service from the first tag) + - `tags?` — the operation's `tags` in document order, present only when at least one usable entry remains; for grouping or doc comments (the parser already named the service from the first tag that has a name) - `security?` — per-operation `SecurityRequirementSet[]`; `undefined` inherits `ApiSpec.security` - `servers?` — set only when the operation (or its Path Item) declares servers that differ from the document's; send its requests to `op.servers ?? spec.servers` - `parameterGroups?` — mutually-exclusive parameter groups (`name`, `optional`, `variants`, `wrapperName?`) for sum-type rendering; the grouped parameters stay in their location arrays diff --git a/src/ir/types.ts b/src/ir/types.ts index 9c4ce188..1ff5b8b5 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -542,11 +542,12 @@ export interface Operation { async?: boolean; /** * The operation's `tags`, in document order, with every entry that is not - * a non-empty string dropped and a repeated entry folded onto its first - * occurrence. Present only when at least one remains. The parser names the - * service from `tags[0]` — the first usable entry — so the two never - * disagree; the list is kept so an emitter can group or document the - * method by every tag. + * a string with non-whitespace content dropped and a repeated entry folded + * onto its first occurrence. Present only when at least one remains. The + * parser names the service from the first entry that still has a name once + * PascalCased (`tags[0]`, unless it has no ASCII letter or digit, like + * `-`), so the two never disagree; the list is kept so an emitter can group + * or document the method by every tag. */ tags?: string[]; /** diff --git a/src/parser/operations.ts b/src/parser/operations.ts index adfe1662..0c513322 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -576,10 +576,11 @@ export function extractOperations( const op = pathItem[method]; if (!op) continue; - // The first usable tag — `Operation.tags[0]`, read by the same helper — - // so the service and the list the IR carries never disagree, and an - // entry that is not a string (`42`) never reaches PascalCasing. - const tag = extractTags(op.tags)?.[0]; + // The first tag with a name (`serviceTag`), read from the list + // `Operation.tags` carries, so the service is named from one of its + // entries, and an entry that is not a string (`42`) never reaches + // PascalCasing. + const tag = serviceTag(op.tags); const apiRootLength = tag ? 0 : apiRootLengthOf(path, apiRoot); const serviceName = inferServiceName(path, tag, apiRootLength); const pathModelBaseName = pathModelBaseNames.get(`${method} ${path}`) ?? derivePathModelBaseName(path, method); @@ -896,10 +897,10 @@ function pathRootLength(segments: readonly string[]): number { function documentApiRoot(paths: Record): string[] { const contributions: string[][] = []; for (const [path, pathItem] of Object.entries(paths)) { - // Same read as the service naming above: a first usable tag names the service. + // Same read as the service naming above: a tag with a name names the service. const hasUntaggedOperation = HTTP_METHODS.some((method) => { const op = pathItem[method]; - return Boolean(op) && extractTags(op?.tags) === undefined; + return Boolean(op) && serviceTag(op?.tags) === undefined; }); if (!hasUntaggedOperation) continue; const segments = pathSegments(path); @@ -976,7 +977,19 @@ function serviceSegmentIndex(path: string, apiRootLength: number): number { } /** - * The service an operation belongs to: its first tag, else a path segment. + * The tag an operation's service is named from: the first entry of the + * `tags` the IR carries (`extractTags`) that still has a name once + * PascalCased, so `['-', 'Notes']` lands in `Notes` while `tags` keeps the + * `-`. `undefined` when no entry has one: the operation is then named from + * its path, as an untagged one is. + */ +function serviceTag(tags: unknown): string | undefined { + return extractTags(tags)?.find((tag) => toPascalCase(tag) !== ''); +} + +/** + * The service an operation belongs to: its first tag with a name + * (`serviceTag`), else a path segment. * * The path fallback takes the first literal segment after the API root the * document sits under (`apiRootLength`, see documentApiRoot): `/widgets/{id}/sub` @@ -3030,16 +3043,17 @@ export function presentText(text: string | undefined): string | undefined { /** * An operation's `tags` as the IR carries them, on an `Operation` and on a * `Webhook` alike: the list in document order with every entry that is not a - * non-empty string dropped and a repeated entry folded onto its first - * occurrence, or `undefined` when nothing remains (or the keyword is not a - * list), so the key is omitted rather than set to `[]`. The service an - * operation is grouped under is named from `[0]` of this same list. + * string with non-whitespace content (`presentText`) dropped and a repeated + * entry folded onto its first occurrence, or `undefined` when nothing remains + * (or the keyword is not a list), so the key is omitted rather than set to + * `[]`. The service an operation is grouped under is named from the first + * entry of this same list that has a name (`serviceTag`). */ export function extractTags(tags: unknown): string[] | undefined { if (!Array.isArray(tags)) return undefined; const kept: string[] = []; for (const tag of tags) { - if (typeof tag === 'string' && tag !== '' && !kept.includes(tag)) kept.push(tag); + if (typeof tag === 'string' && presentText(tag) !== undefined && !kept.includes(tag)) kept.push(tag); } return kept.length > 0 ? kept : undefined; } diff --git a/test/differ/security-tags.test.ts b/test/differ/security-tags.test.ts index cc58b8b2..640e2236 100644 --- a/test/differ/security-tags.test.ts +++ b/test/differ/security-tags.test.ts @@ -924,6 +924,34 @@ describe('a first-tag change that renames the service is a service move, not a t ]); expect(summary).toEqual({ added: 0, removed: 0, modified: 1, breaking: 0, additive: 1, behaviorChanges: 0 }); }); + + it('moves nothing for a first tag that is blank or has no name, since the service is the next tag (issue #412)', async () => { + const notes = async (tags: string[]) => + parseSpec( + await writeSpec('notes.json', document({ '/notes': { get: { operationId: 'listNotes', tags, responses } } })), + ); + const before = await notes(['Notes']); + + // A whitespace-only tag is dropped from `tags` as an empty one is: no change at all. + expect(diffSpecs(before, await notes([' ', 'Notes'])).changes).toEqual([]); + + // `-` stays in `tags` but names no service, so `listNotes` is still matched in `Notes`. + const dash = await notes(['-', 'Notes']); + expect(dash.services.map((s) => s.name)).toEqual(['Notes']); + const { changes, summary } = diffSpecs(before, dash); + expect(changes).toMatchObject([ + { + kind: 'operation-modified', + serviceName: 'Notes', + operationName: 'listNotes', + tagsChanged: true, + oldTags: ['Notes'], + newTags: ['-', 'Notes'], + classification: 'additive', + }, + ]); + expect(summary).toEqual({ added: 0, removed: 0, modified: 1, breaking: 0, additive: 1, behaviorChanges: 0 }); + }); }); describe('the new diff types are part of the package’s public surface', () => { diff --git a/test/parser/operations.test.ts b/test/parser/operations.test.ts index e0c2436c..0fa37062 100644 --- a/test/parser/operations.test.ts +++ b/test/parser/operations.test.ts @@ -3160,3 +3160,48 @@ describe('extractOperations – Operation.tags (issue #273)', () => { expect(services.map((s) => s.name).sort()).toEqual(['Labels', 'Notes']); }); }); + +describe('extractOperations – a first tag with no name (issue #412)', () => { + const responses = { '204': { description: 'No content' } }; + const layout = (paths: Parameters[0]) => + extractOperations(paths).services.map(({ name, operations }) => ({ + name, + operations: operations.map((op) => `${op.name} ${JSON.stringify(op.tags)}`), + })); + const notes = (tags: string[]) => layout({ '/notes': { get: { operationId: 'listNotes', tags, responses } } }); + + it('names the service from the first tag that has a name after PascalCasing, as an empty first tag does', () => { + expect(notes(['', 'Notes'])).toEqual([{ name: 'Notes', operations: ['listNotes ["Notes"]'] }]); + expect(notes([' ', 'Notes'])).toEqual([{ name: 'Notes', operations: ['listNotes ["Notes"]'] }]); + expect(notes(['-', 'Notes'])).toEqual([{ name: 'Notes', operations: ['listNotes ["-","Notes"]'] }]); + }); + + it('drops a whitespace-only tag from Operation.tags, as it drops an empty one', () => { + expect(notes(['Notes', '\t', 'Labels', ' \n '])).toEqual([ + { name: 'Notes', operations: ['listNotes ["Notes","Labels"]'] }, + ]); + // Nothing left: path-named, as if untagged, with no `tags` key. + expect(notes([' ', '\t'])).toEqual([{ name: 'Notes', operations: ['listNotes undefined'] }]); + const { services } = extractOperations({ '/notes': { get: { operationId: 'listNotes', tags: [' '], responses } } }); + expect(services[0].operations[0]).not.toHaveProperty('tags'); + }); + + it('keeps a tag with no name in Operation.tags without naming the service from it', () => { + expect(notes(['-'])).toEqual([{ name: 'Notes', operations: ['listNotes ["-"]'] }]); + expect(notes(['-', '#', 'multi-factor-auth'])).toEqual([ + { name: 'MultiFactorAuth', operations: ['listNotes ["-","#","multi-factor-auth"]'] }, + ]); + }); + + it('treats an operation whose tags name nothing as untagged when finding the API root', () => { + expect( + layout({ + '/api/v1/notes': { get: { operationId: 'listNotes', tags: ['-'], responses } }, + '/api/v1/labels': { get: { operationId: 'listLabels', tags: [' '], responses } }, + }), + ).toEqual([ + { name: 'Notes', operations: ['listNotes ["-"]'] }, + { name: 'Labels', operations: ['listLabels undefined'] }, + ]); + }); +}); diff --git a/test/parser/webhooks.test.ts b/test/parser/webhooks.test.ts index 5d86a9c4..1d2706b6 100644 --- a/test/parser/webhooks.test.ts +++ b/test/parser/webhooks.test.ts @@ -808,6 +808,15 @@ describe('webhooks', () => { expect(warn).not.toHaveBeenCalled(); }); + it('drops a whitespace-only tag, as an operation does, and keeps one with no name (issue #412)', () => { + const { webhooks } = extractWebhooks({ + petAdopted: { post: { tags: [' ', 'Pets', '\t', '-'], responses: {} } }, + petUpdated: { post: { tags: [' '], responses: {} } }, + }); + expect(webhooks[0].tags).toEqual(['Pets', '-']); + expect(webhooks[1]).not.toHaveProperty('tags'); + }); + it('yields one webhook per method and skips a method that is not an object', () => { const { webhooks } = extractWebhooks({ petUpdated: { post: { responses: {} }, put: null, delete: { responses: {} } }, From 7d9f6fc5e3357fdf2ab5386bc701d8f90918c2da Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 10:31:52 -0400 Subject: [PATCH 30/59] docs(ir): say the path names the service when no tag has a name The Operation.tags JSDoc covered only an operation with a tag that has a name, then said "so the two never disagree". With `tags: ['-']` on `GET /notes`, the IR keeps `tags: ["-"]` but the service is the path-named `Notes`, so an emitter author could wrongly read a present `tags` as "the service is the PascalCase of one of its entries". Say, in the review's words, that the service comes from the path when no entry has a name, as for an untagged operation: - the Operation.tags JSDoc in src/ir/types.ts ("so the two never disagree" dropped) - the `tags?` bullet in the generator guide - the `tags?` line of the Operation listing in ir-types.md, which had the same gap; its Service naming paragraph already said so Behavior is unchanged and already pinned: the #412 describe in test/parser/operations.test.ts asserts `['-']` gives `Notes` with `tags: ["-"]`. Addresses review finding 1 on #475 and its inline thread on src/ir/types.ts:541. --- docs/architecture/ir-types.md | 2 +- skills/generate-emitter/references/generator-guide.md | 2 +- src/ir/types.ts | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index f905a459..3409edd0 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -402,7 +402,7 @@ interface Operation { injectIdempotencyKey: boolean; // true when the operation declares an Idempotency-Key header parameter, which is then left out of headerParams deprecated?: boolean; // Present, and true, only when the Operation Object says deprecated: true async?: boolean; // The x-oagen-async extension, carried as the document writes it (see Async marker below) - tags?: string[]; // The operation's tags in document order, entries that are not strings with non-whitespace content dropped, repeats folded; only when at least one remains; the service is named from the first entry that has a name once PascalCased + tags?: string[]; // The operation's tags in document order, entries that are not strings with non-whitespace content dropped, repeats folded; only when at least one remains; the service is named from the first entry that has a name once PascalCased, or from the path when none has one security?: SecurityRequirementSet[]; // Per-operation requirements; undefined inherits ApiSpec.security parameterGroups?: ParameterGroup[]; // Mutually-exclusive parameter groups from x-mutually-exclusive-parameter-groups; see ParameterGroup in src/ir/types.ts } diff --git a/skills/generate-emitter/references/generator-guide.md b/skills/generate-emitter/references/generator-guide.md index e84b9c78..c06cf2dc 100644 --- a/skills/generate-emitter/references/generator-guide.md +++ b/skills/generate-emitter/references/generator-guide.md @@ -166,7 +166,7 @@ Use the shared utilities from `@workos/oagen` (`toPascalCase`, `toSnakeCase`, `t - `injectIdempotencyKey` — `true` when the spec declares an `Idempotency-Key` header parameter on the operation, which is then left out of `headerParams`: generate and send the header yourself - `deprecated?` — present, and `true`, only when the Operation Object says `deprecated: true` - `async?` — the Operation Object's `x-oagen-async` extension, carried as the document writes it (no test or coercion, so a non-boolean value such as `'yes'` arrives as that string); test `=== true` - - `tags?` — the operation's `tags` in document order, present only when at least one usable entry remains; for grouping or doc comments (the parser already named the service from the first tag that has a name) + - `tags?` — the operation's `tags` in document order, present only when at least one usable entry remains; for grouping or doc comments (the parser already named the service from the first tag that has a name, or from the path when none has one, as for an untagged operation) - `security?` — per-operation `SecurityRequirementSet[]`; `undefined` inherits `ApiSpec.security` - `servers?` — set only when the operation (or its Path Item) declares servers that differ from the document's; send its requests to `op.servers ?? spec.servers` - `parameterGroups?` — mutually-exclusive parameter groups (`name`, `optional`, `variants`, `wrapperName?`) for sum-type rendering; the grouped parameters stay in their location arrays diff --git a/src/ir/types.ts b/src/ir/types.ts index 1ff5b8b5..d30db3fd 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -546,8 +546,9 @@ export interface Operation { * onto its first occurrence. Present only when at least one remains. The * parser names the service from the first entry that still has a name once * PascalCased (`tags[0]`, unless it has no ASCII letter or digit, like - * `-`), so the two never disagree; the list is kept so an emitter can group - * or document the method by every tag. + * `-`), or from the path when none has one, as for an untagged operation; + * the list is kept so an emitter can group or document the method by every + * tag. */ tags?: string[]; /** From 09585084ffbce2ff7845e5cba306e76867818207 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 14:13:00 -0400 Subject: [PATCH 31/59] fix(parser)!: keep a plural segment and its singular as two words of a qualifier Since #321, appendQualifier dropped a qualifier word whose previous word was the same noun (sameNoun), not only the same word. The written deeper context of `/x/notes/{id}/note/tags` is `NotesNote`, so it read as `Notes`, the context of `/x/notes/{id}/tags`, and the two paths were numbered `listTags` and `listTags2`. Inside a qualifier only the same word is now a repeat, so the two are `listTagsNotesNote` and `listTagsNotes`. sameNoun still compares the qualifier's leading words against the end of the name (`/org/aliases/{id}/aliases` stays `listAliasesById`), and the word the name ends with is not added right after such an overlap, so `listNotes` + `NoteNotes` stays `listNotes`, never `listNotesNotes`. Closes #377 --- docs/architecture/ir-types.md | 2 +- src/parser/operations.ts | 24 +++++++----- test/parser/fallback-operation-names.test.ts | 39 ++++++++++++++++++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 3409edd0..b7bc5b48 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -352,7 +352,7 @@ An operation's `callbacks` are not read; a `paths` operation that declares any i An operation carries its OpenAPI `summary` and `description` in two fields, each read from the Operation Object and, when the operation declares none with content, from the Path Item it sits on (the `paths` entry itself, or the `components/pathItems` item a `$ref` entry leads to; see [Path Item references](#path-item-references)) — OpenAPI defines a Path Item's `summary` and `description` as applying to every operation on it. The two fall back one by one: an operation with a `summary` of its own and no `description` shows its own `summary` over the Path Item's `description`, and an operation with both shows only its own. `Operation.description` is the combined doc text of the two resolved texts: `summary + "\n\n" + description` when both have content, or whichever one has content (a summary identical to the description is not repeated). A `summary` or `description` that is not a string, or is empty or whitespace-only, is treated as absent at either level — `summary: "List pets"` with `description: ""` yields `description: "List pets"`, and `summary: ""` on an operation takes the Path Item's — and text that has content is kept verbatim, surrounding whitespace included. An emitter that prints `description` as the doc comment therefore sees the same text whether the document put the text on the operation or on its Path Item, and whether or not it used a summary. `Operation.summary` is the resolved `summary` alone and is present only when one of the two levels has content, so an emitter that wants a one-line label (and the description without it) reads `summary` rather than guessing whether the first paragraph of `description` is one. When a doc URL base is configured, relative links are expanded in `summary` exactly as in `description`. -An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a path-named operation of another verb takes the name it has without the action rule (`POST /pets/bulk-delete` beside `DELETE /pets/bulk-delete` is `createBulkDelete`), numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary, each word compared by its singular form): one the name already ends with is not repeated, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`, and one that repeats a word itself is collapsed, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action that shares its name with a path-named operation of another shape yields: it takes back the collection it dropped (`POST /pets/update` beside `PUT /pets/{id}` is `updatePets`, and `update` stays with the PUT) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural; members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a path-named operation of another verb takes the name it has without the action rule (`POST /pets/bulk-delete` beside `DELETE /pets/bulk-delete` is `createBulkDelete`), numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action that shares its name with a path-named operation of another shape yields: it takes back the collection it dropped (`POST /pets/update` beside `PUT /pets/{id}` is `updatePets`, and `update` stays with the PUT) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural; members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ```typescript interface Service { diff --git a/src/parser/operations.ts b/src/parser/operations.ts index 0c513322..788af122 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -2192,28 +2192,34 @@ function numberedName(name: string, n: number): string { * Append a qualifier to an operation name without repeating what the name * already says. A qualifier never contributes the same word twice in a row: * the deeper context of `/api/analytics/analytics/teams` is - * `AnalyticsAnalytics`, which joins as `listTeamsAnalytics`. The words that - * remain are then joined where they overlap the end of the name: `listNotes` - * + `Notes` and `listOAuth2Tokens` + `OAuth2Tokens` stay as they are, and - * `listTeams` + `TeamsAnalytics` is `listTeamsAnalytics`. Words are compared - * on the camelCase boundary, case-insensitively and as nouns (sameNoun), so + * `AnalyticsAnalytics`, which joins as `listTeamsAnalytics`. Only the same + * word is a repeat there, compared on the camelCase boundary and + * case-insensitively: a plural segment and its singular are two words, so + * `listTags` + `NotesNote`, the deeper context of `/x/notes/{id}/note/tags` + * as written, is `listTagsNotesNote`, told from the `listTagsNotes` of + * `/x/notes/{id}/tags`. The words that remain are then joined where they + * overlap the end of the name: `listNotes` + `Notes` and `listOAuth2Tokens` + * + `OAuth2Tokens` stay as they are, and `listTeams` + `TeamsAnalytics` is + * `listTeamsAnalytics`. That overlap compares words as nouns (sameNoun), so * a noun the name ends with is not repeated in the other number either: * `listNotes` + `Note` and `listAliases` + `Alias`, the deeper contexts of * `/orgs/notes/{id}/notes` and `/org/aliases/{id}/aliases`, stay `listNotes` - * and `listAliases`. The name's own words are never touched. + * and `listAliases`. Nor is the word the name ends with added right after + * such an overlap: `listNotes` + `NoteNotes` stays `listNotes`, never + * `listNotesNotes`. The name's own words are never touched. */ function appendQualifier(name: string, qualifier: string | null): string { if (!qualifier) return name; const have = splitWords(name).map((w) => w.toLowerCase()); - const words = splitWords(qualifier).filter( - (w, i, all) => i === 0 || !sameNoun(w.toLowerCase(), all[i - 1].toLowerCase()), - ); + const words = splitWords(qualifier).filter((w, i, all) => i === 0 || w.toLowerCase() !== all[i - 1].toLowerCase()); const want = words.map((w) => w.toLowerCase()); // The longest run of words that both ends the name and starts the qualifier. let overlap = Math.min(have.length, want.length); while (overlap > 0 && !want.slice(0, overlap).every((w, i) => sameNoun(w, have[have.length - overlap + i]))) { overlap--; } + // Past a run in the other number (`Note` against `listNotes`), the next word may be the name's last word itself. + if (want[overlap] === have[have.length - 1]) overlap++; const rest = words.slice(overlap); return rest.length === 0 ? name : toCamelCase([name, ...rest].join('_')); } diff --git a/test/parser/fallback-operation-names.test.ts b/test/parser/fallback-operation-names.test.ts index cc73be19..3cc37847 100644 --- a/test/parser/fallback-operation-names.test.ts +++ b/test/parser/fallback-operation-names.test.ts @@ -683,6 +683,45 @@ describe('plural path names (issue #268)', () => { ]); }); + it('keeps a plural segment and its singular as two words of a qualifier (issue #377)', () => { + const x = (op: object) => ({ ...op, tags: ['X'] }); + // The issue repro. Singular, the deeper contexts are `NoteNote`, one word + // twice in a row, and `Note`: both `listTagsNote`, which tells nothing + // apart. As written they are `NotesNote` and `Notes`: two segments, + // not a repeat, so the plural tells the paths apart. + expect( + names({ + '/x/notes/{id}/note/tags': { get: x(item('id')) }, + '/x/notes/{id}/tags': { get: x(item('id')) }, + }), + ).toEqual([ + 'listTagsNotesNote GET /x/notes/{id}/note/tags', + 'listTagsNotes GET /x/notes/{id}/tags', + ]); + // The same when the name ends with the plural: `Notes` overlaps it and + // `Note`, the next segment, is kept. + expect( + names({ + '/x/notes/note/notes': { get: x(ok) }, + '/x/notes/notes': { get: x(ok) }, + }), + ).toEqual([ + 'listNotesNote GET /x/notes/note/notes', + 'listNotes GET /x/notes/notes', + ]); + // Still never the same word twice in a row: `NoteNotes` overlaps + // `listNotes` with `Note`, and its `Notes` would repeat the name's last word. + expect( + names({ + '/x/notes/{id}/notes/notes': { get: x(item('id')) }, + '/x/y/notes': { get: x(ok) }, + }), + ).toEqual([ + 'listNotes GET /x/notes/{id}/notes/notes', + 'listNotesY GET /x/y/notes', + ]); + }); + it('keeps a deeper segment as written when the plural is what tells two paths apart (review)', () => { // One segment deeper than the issue's repro: the singular `Pet` would // give both paths `getToyPet`, so the plural path keeps its spelling. From 18af40494d966c3a51b8cb3f666c026dde910179 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 14:28:07 -0400 Subject: [PATCH 32/59] fix(parser)!: restore an item's service noun as written when the singular is taken Since #321, the service noun a path-named item operation gets back is singular: `Note` for `/api/notes/{noteId}`. When `getNote`, the name that gives, belongs to an operation outside the colliding group (an operationId on `POST /api/notes/pinned`), the service-noun pass skipped it and nothing fell back to the plural. The plural pass returns nothing for a path whose resource is the service segment, so beside `operationId: get` the item was numbered `get2`. Before #321 it was `getNotes`. A new qualifier now runs right after the service-noun pass. An item still colliding because its singular service noun gives the name of an operation outside the group takes the noun as written, as the deeper-context pass does for `getToyCats` beside `operationId: getToyCat`. The item becomes `getNotes`, and it is still numbered when `getNotes` is taken too. It runs as a pass of its own and does not replace the singular inside the service-noun pass. There, the item's `Notes` could match a collection's `Notes` in the same group, and the pass would tell neither apart. So `DELETE /api/notes/{noteId}` stays `delete` once `DELETE /api/{noteId}/notes` beside it has taken `deleteNotes`. inferServiceNoun takes a `singularItem` flag, as inferResourceFromPath and inferDeeperContext do. ir-types.md and pipeline.md now state the rule. Closes #376 BREAKING CHANGE: a path-named operation on the service's own item path (`GET /api/notes/{noteId}` in `Notes`) is renamed when it collides with an operationId and an operation outside the group already holds its singular name (`getNote`). Such an operation was numbered (`get2`). It is now named with the service noun as written (`getNotes`), the name it had before #321. --- docs/architecture/ir-types.md | 2 +- docs/architecture/pipeline.md | 2 +- src/parser/operations.ts | 29 +++++++++-- test/parser/fallback-operation-names.test.ts | 51 ++++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index b7bc5b48..1dcc8b7c 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -352,7 +352,7 @@ An operation's `callbacks` are not read; a `paths` operation that declares any i An operation carries its OpenAPI `summary` and `description` in two fields, each read from the Operation Object and, when the operation declares none with content, from the Path Item it sits on (the `paths` entry itself, or the `components/pathItems` item a `$ref` entry leads to; see [Path Item references](#path-item-references)) — OpenAPI defines a Path Item's `summary` and `description` as applying to every operation on it. The two fall back one by one: an operation with a `summary` of its own and no `description` shows its own `summary` over the Path Item's `description`, and an operation with both shows only its own. `Operation.description` is the combined doc text of the two resolved texts: `summary + "\n\n" + description` when both have content, or whichever one has content (a summary identical to the description is not repeated). A `summary` or `description` that is not a string, or is empty or whitespace-only, is treated as absent at either level — `summary: "List pets"` with `description: ""` yields `description: "List pets"`, and `summary: ""` on an operation takes the Path Item's — and text that has content is kept verbatim, surrounding whitespace included. An emitter that prints `description` as the doc comment therefore sees the same text whether the document put the text on the operation or on its Path Item, and whether or not it used a summary. `Operation.summary` is the resolved `summary` alone and is present only when one of the two levels has content, so an emitter that wants a one-line label (and the description without it) reads `summary` rather than guessing whether the first paragraph of `description` is one. When a doc URL base is configured, relative links are expanded in `summary` exactly as in `description`. -An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a path-named operation of another verb takes the name it has without the action rule (`POST /pets/bulk-delete` beside `DELETE /pets/bulk-delete` is `createBulkDelete`), numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action that shares its name with a path-named operation of another shape yields: it takes back the collection it dropped (`POST /pets/update` beside `PUT /pets/{id}` is `updatePets`, and `update` stays with the PUT) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural; members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a path-named operation of another verb takes the name it has without the action rule (`POST /pets/bulk-delete` beside `DELETE /pets/bulk-delete` is `createBulkDelete`), numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action that shares its name with a path-named operation of another shape yields: it takes back the collection it dropped (`POST /pets/update` beside `PUT /pets/{id}` is `updatePets`, and `update` stays with the PUT) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural; members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ```typescript interface Service { diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index 7a5ed903..c75e718b 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -65,7 +65,7 @@ A literal after a `{param}` that does not look plural is a singleton of that ite An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /pets/list-all` → `listAll`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. A literal carrying another method's verb is not an action: after a `{param}` it is a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`). And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/bulk-delete` → `bulkDelete`, `POST /users/bulk-update` → `bulkUpdate`, `POST /v1/pets:batchGet` → `batchGet`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. Only a segment's one colon outside a parameter's braces starts a custom method. A colon inside braces is the parameter's wherever they sit, so `GET /v1/files/file-{file_path:path}` reads one segment and is `listFileFilePathPath`, while `POST /v1/files/file-{file_path:path}:download` is `createDownload`; and AIP-136 appends a single verb, so a segment with several colons outside braces is one literal, even when a parameter opens it: `GET /things/urn:pet:1` is `listUrnPet1`, and `GET /v1/pets/{petId}:adopt:now` is `listPetIdAdoptNow`, so `GET /v1/pets/{petId}` beside it stays `get`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. -Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2`). An action that shares its name with a path-named CRUD or singleton operation yields first: `update` stays with `PUT /pets/{id}`, and `POST /pets/update` takes back the collection it dropped, `updatePets` (`createPets` for `POST /pets/create` beside `POST /pets`, `listAllPets` for `GET /pets/list-all` beside `GET /pets/all`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`, `createUpdate` for `POST /store/pets/update` beside `PUT /store/pets`). Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`). A path-named operation of another verb that would share an action name takes the name it has without the action rule: the verb the segment carries keeps the action, so `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` beside it is `createBulkDelete`; with no method carrying the verb, POST keeps it and `PUT /pets/bulk-delete` beside it is `updateBulkDelete`; `PUT /users/{id}/update-password` keeps `updatePasswordUser` and a POST beside it is the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2`). An action that shares its name with a path-named CRUD or singleton operation yields first: `update` stays with `PUT /pets/{id}`, and `POST /pets/update` takes back the collection it dropped, `updatePets` (`createPets` for `POST /pets/create` beside `POST /pets`, `listAllPets` for `GET /pets/list-all` beside `GET /pets/all`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`, `createUpdate` for `POST /store/pets/update` beside `PUT /store/pets`). Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`). A path-named operation of another verb that would share an action name takes the name it has without the action rule: the verb the segment carries keeps the action, so `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` beside it is `createBulkDelete`; with no method carrying the verb, POST keeps it and `PUT /pets/bulk-delete` beside it is `updateBulkDelete`; `PUT /users/{id}/update-password` keeps `updatePasswordUser` and a POST beside it is the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ## Stage 2: Emit (`src/engine/`) diff --git a/src/parser/operations.ts b/src/parser/operations.ts index 788af122..0f3aa1b3 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -1585,7 +1585,12 @@ function inferAction( * the same one. This is what tells a path-named operation from an * operationId it collides with, which is never renamed and which the * parameters above cannot touch: `GET /api/notes` beside - * `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`; + * `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`. + * Singular for an item, as its resource would be: + * `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`. + * Then an item still colliding because an operation outside the group + * has that singular name takes the noun as written, as a deeper + * segment does: `getNotes` beside `operationId: getNote` as well; * 6. for a path-named item operation whose singular name none of those * could tell apart — `GET /store/pets/{id}` is `getPet`, and so is * `operationId: getPet` on `/store/featured/{id}`, or path-named @@ -1765,6 +1770,20 @@ function disambiguateOperationNames( // qualifyGroup skips the pass. return inferServiceNoun(op.path, apiRootLengthOf(op)); }, + (group) => { + // An item the pass above left colliding, because its singular noun + // gives it the name of an operation outside the group, takes the noun + // as written, as a deeper segment does: `/api/notes/{noteId}` beside + // `operationId: get` and `operationId: getNote` is `getNotes`. Not in + // the pass above, where the item's `Notes` could match a collection's + // beside it and tell neither apart. + const outside = namesOutside(group); + return (op) => { + const root = apiRootLengthOf(op); + const taken = outside.has(appendQualifier(op.name, inferServiceNoun(op.path, root))); + return taken ? inferServiceNoun(op.path, root, false) : null; + }; + }, ]; for (const qualifier of qualifiers) { @@ -2308,14 +2327,16 @@ function inferByParamsSuffix(path: string, shared: ReadonlySet): string * `Note` for the item `/api/notes/{id}`. Null when the last literal segment * past the API root is a different word (`/api/notes/{id}/tags`, whose `Tags` * the plain name already carries) or when nothing literal follows the root - * (`/api/v1/{id}`). + * (`/api/v1/{id}`). `singularItem` false keeps an item's noun as the path + * spells it (`Notes` for `/api/notes/{id}`), for the service-noun qualifier + * when the singular gives a name already taken (disambiguateOperationNames). */ -function inferServiceNoun(path: string, apiRootLength: number): string | null { +function inferServiceNoun(path: string, apiRootLength: number, singularItem = true): string | null { const segments = pathSegments(path); let last = segments.length - 1; while (last >= apiRootLength && isParameterSegment(segments[last])) last--; if (last < apiRootLength || last !== serviceSegmentIndex(path, apiRootLength)) return null; - return toPascalCase(pathSegmentNoun(segments, last)); + return toPascalCase(singularItem ? pathSegmentNoun(segments, last) : segments[last]); } /** diff --git a/test/parser/fallback-operation-names.test.ts b/test/parser/fallback-operation-names.test.ts index 3cc37847..684b429d 100644 --- a/test/parser/fallback-operation-names.test.ts +++ b/test/parser/fallback-operation-names.test.ts @@ -856,6 +856,57 @@ describe('plural path names (issue #268)', () => { 'get GET /api/{teamId}/notes/{noteId}', ]); }); + + it('restores the service noun as written when the singular is a name outside the group (issue #376)', () => { + const params = (...names: string[]) => ({ + ...json, + parameters: names.map((name) => ({ name, in: 'path' as const, required: true, schema: { type: 'string' } })), + }); + // The issue repro, untagged. `Note` would give `/api/notes/{noteId}` the + // name of `operationId: getNote`, and nothing else tells it from + // `operationId: get`, so the noun comes back as written, as a deeper + // segment does beside an operationId. + const repro = { + '/api/notes/{noteId}': { get: params('noteId') }, + '/api/{teamId}/notes/{noteId}': { get: { ...params('teamId', 'noteId'), operationId: 'get' } }, + '/api/notes/pinned': { post: { ...json, operationId: 'getNote' } }, + }; + expect(services(repro)).toEqual([ + { + name: 'Notes', + operations: [ + 'getNotes: GET /api/notes/{noteId}', + 'get: GET /api/{teamId}/notes/{noteId}', + 'getNote: POST /api/notes/pinned', + ], + }, + ]); + // The same names whatever the document order. + const reversed = Object.fromEntries(Object.entries(repro).reverse()); + expect(names(reversed)).toEqual(names(repro)); + // The plural taken as well: the number goes on the plain name, never on + // a name another operation had. + expect(names({ ...repro, '/api/notes/archived': { post: { ...json, operationId: 'getNotes' } } })).toEqual([ + 'getNotes POST /api/notes/archived', + 'getNote POST /api/notes/pinned', + 'get2 GET /api/notes/{noteId}', + 'get GET /api/{teamId}/notes/{noteId}', + ]); + // Only on what the singular left colliding: once the collection has + // taken `deleteNotes`, the item no longer collides and keeps its name, + // rather than meeting the collection's `Notes` and being numbered. + expect( + names({ + '/api/notes/{noteId}': { delete: params('noteId') }, + '/api/{noteId}/notes': { delete: params('noteId') }, + '/api/notes/pinned': { post: { ...json, operationId: 'deleteNote' } }, + }), + ).toEqual([ + 'deleteNote POST /api/notes/pinned', + 'delete DELETE /api/notes/{noteId}', + 'deleteNotes DELETE /api/{noteId}/notes', + ]); + }); }); /** From 22bf72a165fb7b171d9095b88b132af99999af2e Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 14:53:37 -0400 Subject: [PATCH 33/59] fix(parser)!: keep a nested collection's name when nothing tells it from the service path Since #338, the service's own path restores its noun beside an operationId and joins a path-named operation of the same noun that already has the name it makes, counting on a later pass to tell the two apart: `/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, and `ByNoteId` then parts them. nameIsFree allowed the join whether or not any later pass could. Beside `/notes/notes`, which has nothing `/notes` lacks, the pair went to the numeric pass, which keeps the name for the lexically first path, so the nested collection that had `listNotes` became `listNotes2`. nameIsFree now joins a holder only when a later pass would tell it from the member by a name no other operation has (`apart` in disambiguateOperationNames): one of the qualifiers after the first, run on the two alone, or the plural the holder can still take back. Otherwise the name counts as taken, and the service path is numbered on its plain name: `list2: GET /notes`, with `listNotes` left on `/notes/notes`. The same holds when the name that would part them is taken (`operationId: listNotesByNoteId`). Joins a later pass settles are unchanged: `listNotesByNoteId`, `getNoteByChildId`, `listNotesArchive`, `listNotesV1`, `getNotes`. ir-types.md and pipeline.md now state the condition. Closes #402 BREAKING CHANGE: when only a number would tell a service's own path from a path-named operation of its noun that holds the name the service noun gives it, the service path is now numbered on its plain name (`list2: GET /notes`) and the other operation keeps its name (`listNotes: GET /notes/notes`). Before, the two shared the name and the other operation was numbered (`listNotes2`). --- docs/architecture/ir-types.md | 2 +- docs/architecture/pipeline.md | 2 +- src/parser/operations.ts | 103 +++++++++++----- test/parser/path-named-qualifiers.test.ts | 143 ++++++++++++++++++++++ 4 files changed, 217 insertions(+), 33 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 1dcc8b7c..3c83bd6b 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -352,7 +352,7 @@ An operation's `callbacks` are not read; a `paths` operation that declares any i An operation carries its OpenAPI `summary` and `description` in two fields, each read from the Operation Object and, when the operation declares none with content, from the Path Item it sits on (the `paths` entry itself, or the `components/pathItems` item a `$ref` entry leads to; see [Path Item references](#path-item-references)) — OpenAPI defines a Path Item's `summary` and `description` as applying to every operation on it. The two fall back one by one: an operation with a `summary` of its own and no `description` shows its own `summary` over the Path Item's `description`, and an operation with both shows only its own. `Operation.description` is the combined doc text of the two resolved texts: `summary + "\n\n" + description` when both have content, or whichever one has content (a summary identical to the description is not repeated). A `summary` or `description` that is not a string, or is empty or whitespace-only, is treated as absent at either level — `summary: "List pets"` with `description: ""` yields `description: "List pets"`, and `summary: ""` on an operation takes the Path Item's — and text that has content is kept verbatim, surrounding whitespace included. An emitter that prints `description` as the doc comment therefore sees the same text whether the document put the text on the operation or on its Path Item, and whether or not it used a summary. `Operation.summary` is the resolved `summary` alone and is present only when one of the two levels has content, so an emitter that wants a one-line label (and the description without it) reads `summary` rather than guessing whether the first paragraph of `description` is one. When a doc URL base is configured, relative links are expanded in `summary` exactly as in `description`. -An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a path-named operation of another verb takes the name it has without the action rule (`POST /pets/bulk-delete` beside `DELETE /pets/bulk-delete` is `createBulkDelete`), numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action that shares its name with a path-named operation of another shape yields: it takes back the collection it dropped (`POST /pets/update` beside `PUT /pets/{id}` is `updatePets`, and `update` stays with the PUT) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural; members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a path-named operation of another verb takes the name it has without the action rule (`POST /pets/bulk-delete` beside `DELETE /pets/bulk-delete` is `createBulkDelete`), numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action that shares its name with a path-named operation of another shape yields: it takes back the collection it dropped (`POST /pets/update` beside `PUT /pets/{id}` is `updatePets`, and `update` stays with the PUT) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural; members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ```typescript interface Service { diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index c75e718b..1fd93d69 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -65,7 +65,7 @@ A literal after a `{param}` that does not look plural is a singleton of that ite An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /pets/list-all` → `listAll`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. A literal carrying another method's verb is not an action: after a `{param}` it is a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`). And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/bulk-delete` → `bulkDelete`, `POST /users/bulk-update` → `bulkUpdate`, `POST /v1/pets:batchGet` → `batchGet`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. Only a segment's one colon outside a parameter's braces starts a custom method. A colon inside braces is the parameter's wherever they sit, so `GET /v1/files/file-{file_path:path}` reads one segment and is `listFileFilePathPath`, while `POST /v1/files/file-{file_path:path}:download` is `createDownload`; and AIP-136 appends a single verb, so a segment with several colons outside braces is one literal, even when a parameter opens it: `GET /things/urn:pet:1` is `listUrnPet1`, and `GET /v1/pets/{petId}:adopt:now` is `listPetIdAdoptNow`, so `GET /v1/pets/{petId}` beside it stays `get`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. -Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2`). An action that shares its name with a path-named CRUD or singleton operation yields first: `update` stays with `PUT /pets/{id}`, and `POST /pets/update` takes back the collection it dropped, `updatePets` (`createPets` for `POST /pets/create` beside `POST /pets`, `listAllPets` for `GET /pets/list-all` beside `GET /pets/all`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`, `createUpdate` for `POST /store/pets/update` beside `PUT /store/pets`). Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`). A path-named operation of another verb that would share an action name takes the name it has without the action rule: the verb the segment carries keeps the action, so `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` beside it is `createBulkDelete`; with no method carrying the verb, POST keeps it and `PUT /pets/bulk-delete` beside it is `updateBulkDelete`; `PUT /users/{id}/update-password` keeps `updatePasswordUser` and a POST beside it is the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2`). An action that shares its name with a path-named CRUD or singleton operation yields first: `update` stays with `PUT /pets/{id}`, and `POST /pets/update` takes back the collection it dropped, `updatePets` (`createPets` for `POST /pets/create` beside `POST /pets`, `listAllPets` for `GET /pets/list-all` beside `GET /pets/all`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`, `createUpdate` for `POST /store/pets/update` beside `PUT /store/pets`). Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`). A path-named operation of another verb that would share an action name takes the name it has without the action rule: the verb the segment carries keeps the action, so `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` beside it is `createBulkDelete`; with no method carrying the verb, POST keeps it and `PUT /pets/bulk-delete` beside it is `updateBulkDelete`; `PUT /users/{id}/update-password` keeps `updatePasswordUser` and a POST beside it is the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ## Stage 2: Emit (`src/engine/`) diff --git a/src/parser/operations.ts b/src/parser/operations.ts index 0f3aa1b3..fb60971b 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -1538,12 +1538,15 @@ function inferAction( * a name another operation had with the number then landing on it * (`/org/members/archived` beside `/org/archived_members` is never * `listArchivedMembers2`, and `/api/notes` beside `operationId: listNotes` is - * never `listNotes2`) — unless every operation that has it is path-named and - * names the same noun, which the member then joins (nameIsFree): `/notes` - * restoring `Notes` beside `/notes/{noteId}/notes` is `listNotes` with the - * nested collection `listNotesByNoteId`; and members whose proposals say the - * same thing take one spelling and stay one group for the next pass, numbered - * on it when nothing after tells them apart. A qualifier never puts the + * never `listNotes2`) — unless every operation that has it is path-named, + * names the same noun and is one a later pass tells from the member, which + * the member then joins (nameIsFree): `/notes` restoring `Notes` beside + * `/notes/{noteId}/notes` is `listNotes` with the nested collection + * `listNotesByNoteId`, while `/notes/notes`, whose path has nothing `/notes` + * lacks, keeps `listNotes` and `/notes` is numbered on its plain name, + * `list2`; and members whose proposals say the same thing take one spelling + * and stay one group for the next pass, numbered on it when nothing after + * tells them apart. A qualifier never puts the * same word twice in a row into a name, whether the name already ends with it * (`listNotes` never becomes `listNotesNotes`) or the qualifier repeats it * itself (`/api/analytics/analytics/teams` → `listTeamsAnalytics`), compared @@ -1552,9 +1555,10 @@ function inferAction( * 1. the resource noun (inferResourceFromPath); for the service's own * path, whose plain name left that noun off, the service noun * (inferServiceNoun) when a path-named operation outside the group - * already carries it under the name it would make, so that `/notes` - * beside `operationId: list` joins `/notes/{noteId}/notes` at - * `listNotes` and the parameter then tells them apart; + * already carries it under the name it would make and a later pass + * can tell the two apart, so that `/notes` beside `operationId: list` + * joins `/notes/{noteId}/notes` at `listNotes` and the parameter then + * tells them apart; * 2. the path segments between the service and the resource * (inferDeeperContext), each singular when a path parameter follows it * (pathSegmentNoun): `GET /users/{id}/legacy/factors` next to @@ -1653,7 +1657,7 @@ function disambiguateOperationNames( }; /** One pass on one group, through the rule every pass shares (qualifyGroup). */ const qualify = (group: Operation[], proposal: (op: Operation) => string) => - qualifyGroup(group, operations, pathNamed, nounOf, proposal, rename); + qualifyGroup(group, operations, pathNamed, nounOf, apart, proposal, rename); // One path first: the qualifiers below tell paths apart and leave a name // shared on one path alone, so that sharing is settled before they run. @@ -1676,12 +1680,14 @@ function disambiguateOperationNames( // `/notes/{noteId}/notes` as `listNotes` — so the two take one spelling // (nameIsFree) and the parameters tell them apart, `listNotes` and // `listNotesByNoteId`, rather than the nested collection keeping the - // name the service's own path restores beside an operationId. + // name the service's own path restores beside an operationId. Only + // when a later pass can (apart): `/notes/notes` has nothing `/notes` + // lacks, and joined the two would only be numbered. const noun = inferServiceNoun(op.path, root); if (noun === null) return null; const name = appendQualifier(op.name, noun); const held = operations.some((other) => !group.includes(other) && pathNamed.has(other) && other.name === name); - return held && nameIsFree(group, operations, pathNamed, nounOf, op, name) ? noun : null; + return held && nameIsFree(group, operations, pathNamed, nounOf, apart, op, name) ? noun : null; }, (group) => { // Singular, like the resource of an item operation, unless that @@ -1786,13 +1792,6 @@ function disambiguateOperationNames( }, ]; - for (const qualifier of qualifiers) { - for (const group of collidingGroups(operations)) { - const words = qualifier(group); - qualify(group, (op) => appendQualifier(op.name, words(op))); - } - } - // The plural a member could take back: none for an operationId-named // operation, and for a path-named one only while its name is still the one // it was built with — no qualifier above has extended it. Like every pass, @@ -1807,6 +1806,38 @@ function disambiguateOperationNames( if (op.name !== fallbackOperationName(op.httpMethod, op.path, root, actions)) return null; return pluralFallbackName(op.httpMethod, op.path, root, actions); }; + + // Whether a later pass would tell `op`, once it has `name`, from `other`, + // a path-named operation of its noun that has it — what joining `other` + // there counts on (nameIsFree). One of the qualifiers after the first, + // which proposes such a join, run on the two alone, must give one of them + // a name no other operation has (`listNotesByNoteId` for + // `/notes/{noteId}/notes` beside `/notes`), or the plural must, which + // `other` can still take back and `op`, its name extended, cannot. + // Otherwise the two could only be numbered, the number landing on the name + // one of them had: `/notes/notes` has nothing `/notes` lacks, and + // `/notes/archive/notes` nothing but a `listNotesArchive` an operationId + // may hold. + const apart = (op: Operation, other: Operation, name: string): boolean => { + const free = (next: string) => next !== name && !operations.some((x) => x !== op && x !== other && x.name === next); + const plural = pluralOf(other); + return ( + (plural !== null && free(plural)) || + qualifiers.slice(1).some((qualifier) => { + const words = qualifier([op, other]); + const [own, theirs] = [appendQualifier(name, words(op)), appendQualifier(name, words(other))]; + return own !== theirs && (free(own) || free(theirs)); + }) + ); + }; + + for (const qualifier of qualifiers) { + for (const group of collidingGroups(operations)) { + const words = qualifier(group); + qualify(group, (op) => appendQualifier(op.name, words(op))); + } + } + for (const group of collidingGroups(operations)) { // Like every qualifier, only when it tells the group apart: beside an // operationId, which has no plural, or beside a path that spells the @@ -2146,6 +2177,7 @@ function qualifyGroup( operations: readonly Operation[], pathNamed: ReadonlySet, nounOf: (op: Operation) => string | null, + apart: (op: Operation, other: Operation, name: string) => boolean, proposal: (op: Operation) => string, rename: (op: Operation, name: string) => void, ): void { @@ -2157,7 +2189,7 @@ function qualifyGroup( } if (clusters.size <= 1) return; for (const [name, members] of clusters) { - if (!members.every((op) => nameIsFree(group, operations, pathNamed, nounOf, op, name))) continue; + if (!members.every((op) => nameIsFree(group, operations, pathNamed, nounOf, apart, op, name))) continue; for (const op of members) { if (pathNamed.has(op)) rename(op, name); } @@ -2166,22 +2198,26 @@ function qualifyGroup( /** * Whether `name` is free for `op`, a member of `group`: no operation outside - * the group has it, or every operation that has it is path-named and names - * the noun `op` names (`nounOf`, compared as sameNoun compares) — the nested - * collection of the service's own word, `/notes/{noteId}/notes` as - * `listNotes` beside `/notes`. Such a name the member takes and joins the - * group of, and the passes after tell the two apart by what does differ - * between their paths: `listNotes` and `listNotesByNoteId`, `getNote` and - * `getNoteByChildId`. A name an operationId-named operation has is never - * free, nor is one a path-named operation of another noun has: - * `listArchivedMembers` on `/org/archived_members` beside the `Archived` of - * `/org/members/archived`, which nothing after could tell apart. + * the group has it, or every operation that has it is path-named, names the + * noun `op` names (`nounOf`, compared as sameNoun compares) and is one a + * later pass tells from `op` (`apart`) — the nested collection of the + * service's own word, `/notes/{noteId}/notes` as `listNotes` beside `/notes`. + * Such a name the member takes and joins the group of, and the passes after + * tell the two apart by what does differ between their paths: `listNotes` + * and `listNotesByNoteId`, `getNote` and `getNoteByChildId`. A name an + * operationId-named operation has is never free, nor is one a path-named + * operation of another noun has: `listArchivedMembers` on + * `/org/archived_members` beside the `Archived` of `/org/members/archived`, + * which nothing after could tell apart; nor `listNotes` on `/notes/notes` + * beside `/notes`, whose path has nothing `/notes` lacks: joined, the two + * would only be numbered, the number landing on the name one of them had. */ function nameIsFree( group: readonly Operation[], operations: readonly Operation[], pathNamed: ReadonlySet, nounOf: (op: Operation) => string | null, + apart: (op: Operation, other: Operation, name: string) => boolean, op: Operation, name: string, ): boolean { @@ -2189,7 +2225,12 @@ function nameIsFree( return operations.every((other) => { if (group.includes(other) || other.name !== name) return true; const otherNoun = pathNamed.has(other) ? nounOf(other) : null; - return noun !== null && otherNoun !== null && sameNoun(otherNoun.toLowerCase(), noun.toLowerCase()); + return ( + noun !== null && + otherNoun !== null && + sameNoun(otherNoun.toLowerCase(), noun.toLowerCase()) && + apart(op, other, name) + ); }); } diff --git a/test/parser/path-named-qualifiers.test.ts b/test/parser/path-named-qualifiers.test.ts index ca5642d2..776328c1 100644 --- a/test/parser/path-named-qualifiers.test.ts +++ b/test/parser/path-named-qualifiers.test.ts @@ -239,6 +239,149 @@ describe('path-named qualifiers (issue #288)', () => { }); }); + it('joins the nested collection only when a later pass tells the two apart (issue #402)', () => { + // The issue repro. `/notes/notes` has nothing `/notes` lacks for a later + // pass to read — no parameter, no root word, no segment between the + // service and the resource, no plural — so joined at `listNotes` the two + // would be numbered, and the nested collection that had the name would + // be `listNotes2`. The name counts as taken instead, and the service's + // own path is numbered on its plain name beside the operationId. + const repro: Paths = { + '/notes': { get: json }, + '/notes/notes': { get: json }, + '/notes/{noteId}/revisions': { get: withId('list', 'noteId') }, + }; + expect(services(repro)).toEqual([ + { + name: 'Notes', + operations: ['list2: GET /notes', 'listNotes: GET /notes/notes', 'list: GET /notes/{noteId}/revisions'], + }, + ]); + expect(names(reversed(repro))).toEqual(names(repro)); + // The name `/notes/notes` has without the operationId, which adding it leaves alone. + expect(names({ '/notes': { get: json }, '/notes/notes': { get: json } })).toEqual({ + 'GET /notes': 'Notes.list', + 'GET /notes/notes': 'Notes.listNotes', + }); + // The same under an API root, for another method, and for a segment + // between the service and the resource that repeats the noun the name + // already ends with. + expect( + names({ + '/api/notes': { get: json }, + '/api/notes/notes': { get: json }, + '/api/notes/{noteId}/revisions': { get: withId('list', 'noteId') }, + }), + ).toEqual({ + 'GET /api/notes': 'Notes.list2', + 'GET /api/notes/notes': 'Notes.listNotes', + 'GET /api/notes/{noteId}/revisions': 'Notes.list', + }); + expect( + names({ + '/notes': { put: json }, + '/notes/notes': { put: json }, + '/notes/{noteId}/revisions': { put: withId('update', 'noteId') }, + }), + ).toEqual({ + 'PUT /notes': 'Notes.update2', + 'PUT /notes/notes': 'Notes.updateNotes', + 'PUT /notes/{noteId}/revisions': 'Notes.update', + }); + expect( + names({ + '/notes': { get: json }, + '/notes/notes/notes': { get: json }, + '/notes/{noteId}/revisions': { get: withId('list', 'noteId') }, + }), + ).toEqual({ + 'GET /notes': 'Notes.list2', + 'GET /notes/notes/notes': 'Notes.listNotes', + 'GET /notes/{noteId}/revisions': 'Notes.list', + }); + // One holder no later pass can tell from `/notes` is enough to keep the + // name taken, while the parameter still tells the other holder apart. + expect( + names({ + '/notes': { get: json }, + '/notes/notes': { get: json }, + '/notes/{noteId}/notes': { get: op('noteId') }, + '/notes/{noteId}/revisions': { get: withId('list', 'noteId') }, + }), + ).toEqual({ + 'GET /notes': 'Notes.list2', + 'GET /notes/notes': 'Notes.listNotes', + 'GET /notes/{noteId}/notes': 'Notes.listNotesByNoteId', + 'GET /notes/{noteId}/revisions': 'Notes.list', + }); + // Whatever a later pass reads still joins, as before: a segment between + // the service and the resource, a root word only one of them skipped, or + // the plural only the nested item can take back. + expect( + names({ + '/notes': { get: json }, + '/notes/archive/notes': { get: json }, + '/notes/{noteId}/revisions': { get: withId('list', 'noteId') }, + }), + ).toEqual({ + 'GET /notes': 'Notes.listNotes', + 'GET /notes/archive/notes': 'Notes.listNotesArchive', + 'GET /notes/{noteId}/revisions': 'Notes.list', + }); + expect( + names({ + '/api/notes': { get: json }, + '/api/v1/notes/notes': { get: json }, + '/api/notes/{noteId}/revisions': { get: withId('list', 'noteId') }, + }), + ).toEqual({ + 'GET /api/notes': 'Notes.listNotes', + 'GET /api/v1/notes/notes': 'Notes.listNotesV1', + 'GET /api/notes/{noteId}/revisions': 'Notes.list', + }); + expect( + names({ + '/notes/{noteId}': { get: op('noteId') }, + '/notes/notes/{noteId}': { get: op('noteId') }, + '/notes/{noteId}/revisions/{revisionId}': { get: withId('get', 'noteId', 'revisionId') }, + }), + ).toEqual({ + 'GET /notes/{noteId}': 'Notes.getNote', + 'GET /notes/notes/{noteId}': 'Notes.getNotes', + 'GET /notes/{noteId}/revisions/{revisionId}': 'Notes.get', + }); + // But not when the name that pass would give is taken: beside + // `operationId: listNotesByNoteId` the parameter has nothing left to give + // `/notes/{noteId}/notes`, which keeps `listNotes`, and beside + // `operationId: getNotes` the nested item keeps `getNote`. + expect( + names({ + '/notes': { get: json }, + '/notes/{noteId}/notes': { get: op('noteId') }, + '/api/v1/{orgId}/notes': { get: withId('list', 'orgId') }, + '/notes/{noteId}/pinned': { post: withId('listNotesByNoteId', 'noteId') }, + }), + ).toEqual({ + 'GET /notes': 'Notes.list2', + 'GET /notes/{noteId}/notes': 'Notes.listNotes', + 'GET /api/v1/{orgId}/notes': 'Notes.list', + 'POST /notes/{noteId}/pinned': 'Notes.listNotesByNoteId', + }); + expect( + names({ + '/notes/{noteId}': { get: op('noteId') }, + '/notes/notes/{noteId}': { get: op('noteId') }, + '/notes/{noteId}/revisions/{revisionId}': { get: withId('get', 'noteId', 'revisionId') }, + '/notes/pinned/{noteId}': { get: withId('getNotes', 'noteId') }, + }), + ).toEqual({ + 'GET /notes/{noteId}': 'Notes.get2', + 'GET /notes/notes/{noteId}': 'Notes.getNote', + 'GET /notes/{noteId}/revisions/{revisionId}': 'Notes.get', + 'GET /notes/pinned/{noteId}': 'Notes.getNotes', + }); + }); + it('skips a By name an operationId holds, and numbers the plain name only when nothing is left', () => { expect( names({ From a90eef8de49917b8d2a1bc0b47219349b04848f5 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 15:17:00 -0400 Subject: [PATCH 34/59] fix(parser)!: tell two singletons apart by the parent's plural rather than a number Since #324, a literal after a `{param}` is a singleton named after the singular parent: `GET /shop/pets/{id}/status` is `getPetStatus`, and so is `GET /shop/pet/{id}/status`. The qualifiers read a singleton's path without its parent (qualifierPath), so none of them tells the two apart, and the plural pass gave a singleton no plural (pluralFallbackName). One of the two was numbered, `getPetStatus2`, where #268 keeps the plural for item operations (`getToyPets` and `getToyPet`). The plural pass now also proposes a singleton's name with its parent as the path writes it (writtenParentName, `getPetsStatus`). It does so when another path-named singleton of the colliding group writes the parent otherwise, and only while the singleton's name is still the one it was built with. The two are `getPetsStatus` and `getPetStatus` in either document order. As with an item's plural, the name is skipped when another operation has it, and singletons that write the parent alike take it together and are numbered on it. Beside an operationId or an operation of another shape a singleton still has no plural, so `getPetStatus2` beside `operationId: getPetStatus` is unchanged. inferSingleton and inferItemParent take a flag that keeps the parent as written. pipeline.md and ir-types.md now state the exception. Closes #381 BREAKING CHANGE: a path-named singleton whose path writes the parent plural, beside a path-named singleton that writes it singular (`GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status`), is now `getPetsStatus` rather than `getPetStatus2`. When the plural path sorted first (`/shop/Pets/{id}/status`), it had the plain name and the singular path had the number. Now the plural path is `getPetsStatus` and the singular path keeps `getPetStatus`. --- docs/architecture/ir-types.md | 2 +- docs/architecture/pipeline.md | 2 +- src/parser/operations.ts | 86 +++++++++-- test/parser/fallback-action-segments.test.ts | 144 +++++++++++++++++++ 4 files changed, 222 insertions(+), 12 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 3c83bd6b..01d6449d 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -352,7 +352,7 @@ An operation's `callbacks` are not read; a `paths` operation that declares any i An operation carries its OpenAPI `summary` and `description` in two fields, each read from the Operation Object and, when the operation declares none with content, from the Path Item it sits on (the `paths` entry itself, or the `components/pathItems` item a `$ref` entry leads to; see [Path Item references](#path-item-references)) — OpenAPI defines a Path Item's `summary` and `description` as applying to every operation on it. The two fall back one by one: an operation with a `summary` of its own and no `description` shows its own `summary` over the Path Item's `description`, and an operation with both shows only its own. `Operation.description` is the combined doc text of the two resolved texts: `summary + "\n\n" + description` when both have content, or whichever one has content (a summary identical to the description is not repeated). A `summary` or `description` that is not a string, or is empty or whitespace-only, is treated as absent at either level — `summary: "List pets"` with `description: ""` yields `description: "List pets"`, and `summary: ""` on an operation takes the Path Item's — and text that has content is kept verbatim, surrounding whitespace included. An emitter that prints `description` as the doc comment therefore sees the same text whether the document put the text on the operation or on its Path Item, and whether or not it used a summary. `Operation.summary` is the resolved `summary` alone and is present only when one of the two levels has content, so an emitter that wants a one-line label (and the description without it) reads `summary` rather than guessing whether the first paragraph of `description` is one. When a doc URL base is configured, relative links are expanded in `summary` exactly as in `description`. -An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a path-named operation of another verb takes the name it has without the action rule (`POST /pets/bulk-delete` beside `DELETE /pets/bulk-delete` is `createBulkDelete`), numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action that shares its name with a path-named operation of another shape yields: it takes back the collection it dropped (`POST /pets/update` beside `PUT /pets/{id}` is `updatePets`, and `update` stays with the PUT) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural; members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a path-named operation of another verb takes the name it has without the action rule (`POST /pets/bulk-delete` beside `DELETE /pets/bulk-delete` is `createBulkDelete`), numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action that shares its name with a path-named operation of another shape yields: it takes back the collection it dropped (`POST /pets/update` beside `PUT /pets/{id}` is `updatePets`, and `update` stays with the PUT) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural, except that a singleton beside another path-named singleton that writes the parent otherwise keeps the parent as written, its name carrying the parent singular and its qualifiers reading the path without it, so that the plural is all that tells the two apart (`GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, while beside `operationId: getPetStatus` alone it is `getPetStatus2`); members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ```typescript interface Service { diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index 1fd93d69..0ec3b3bc 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -65,7 +65,7 @@ A literal after a `{param}` that does not look plural is a singleton of that ite An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /pets/list-all` → `listAll`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. A literal carrying another method's verb is not an action: after a `{param}` it is a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`). And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/bulk-delete` → `bulkDelete`, `POST /users/bulk-update` → `bulkUpdate`, `POST /v1/pets:batchGet` → `batchGet`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. Only a segment's one colon outside a parameter's braces starts a custom method. A colon inside braces is the parameter's wherever they sit, so `GET /v1/files/file-{file_path:path}` reads one segment and is `listFileFilePathPath`, while `POST /v1/files/file-{file_path:path}:download` is `createDownload`; and AIP-136 appends a single verb, so a segment with several colons outside braces is one literal, even when a parameter opens it: `GET /things/urn:pet:1` is `listUrnPet1`, and `GET /v1/pets/{petId}:adopt:now` is `listPetIdAdoptNow`, so `GET /v1/pets/{petId}` beside it stays `get`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. -Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2`). An action that shares its name with a path-named CRUD or singleton operation yields first: `update` stays with `PUT /pets/{id}`, and `POST /pets/update` takes back the collection it dropped, `updatePets` (`createPets` for `POST /pets/create` beside `POST /pets`, `listAllPets` for `GET /pets/list-all` beside `GET /pets/all`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`, `createUpdate` for `POST /store/pets/update` beside `PUT /store/pets`). Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`). A path-named operation of another verb that would share an action name takes the name it has without the action rule: the verb the segment carries keeps the action, so `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` beside it is `createBulkDelete`; with no method carrying the verb, POST keeps it and `PUT /pets/bulk-delete` beside it is `updateBulkDelete`; `PUT /users/{id}/update-password` keeps `updatePasswordUser` and a POST beside it is the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2` beside `operationId: getPetStatus`). The exception is a singleton beside another fallback singleton that writes the parent otherwise. A singleton's name carries its parent singular, and its qualifiers read the path without the parent, so the parent's plural is all that tells the two apart, and the one that writes it plural keeps it as written: `GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, as the items `GET /shop/pets/{id}/toys/{toyId}` and `GET /shop/pet/{id}/toys/{toyId}` are `getToyPets` and `getToyPet`. That name is numbered when another operation has it, and singletons that write the parent alike share it as fallbacks share a plural (`/shop/Pets/{id}/status` and `/shop/pets/{id}/status` beside `/shop/pet/{id}/status` are `getPetsStatus`, `getPetsStatus2` and `getPetStatus`). An action that shares its name with a path-named CRUD or singleton operation yields first: `update` stays with `PUT /pets/{id}`, and `POST /pets/update` takes back the collection it dropped, `updatePets` (`createPets` for `POST /pets/create` beside `POST /pets`, `listAllPets` for `GET /pets/list-all` beside `GET /pets/all`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`, `createUpdate` for `POST /store/pets/update` beside `PUT /store/pets`). Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`). A path-named operation of another verb that would share an action name takes the name it has without the action rule: the verb the segment carries keeps the action, so `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` beside it is `createBulkDelete`; with no method carrying the verb, POST keeps it and `PUT /pets/bulk-delete` beside it is `updateBulkDelete`; `PUT /users/{id}/update-password` keeps `updatePasswordUser` and a POST beside it is the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ## Stage 2: Emit (`src/engine/`) diff --git a/src/parser/operations.ts b/src/parser/operations.ts index fb60971b..dcf5d251 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -1228,6 +1228,22 @@ function pluralFallbackName(method: HttpMethod, path: string, apiRootLength: num return toCamelCase(`${inferVerb(method, path)}_${resource}`); } +/** + * The name fallbackOperationName gives a singleton (inferSingleton) with its + * parent as the path spells it rather than singular: `getPetsStatus` for + * `GET /shop/pets/{id}/status`, whose name is `getPetStatus`, and that name + * itself for a parent spelled singular, `GET /shop/pet/{id}/status`. `null` + * for every other shape, an action among them. disambiguateOperationNames + * falls back to it before a numeric suffix when the parent's plural is all + * that tells two singletons apart. `actions` is fallbackOperationName's. + */ +function writtenParentName(method: HttpMethod, path: string, apiRootLength: number, actions = true): string | null { + if (actions && inferAction(method, path, apiRootLength)) return null; + const singleton = inferSingleton(path, apiRootLength, false); + if (singleton === null) return null; + return appendQualifier(appendQualifier(inferVerb(method, path), singleton.parent), singleton.resource); +} + /** * The CRUD verb each HTTP method takes on one item and on a collection. Only * GET's differ: `get` for one item, `list` for a collection. @@ -1405,11 +1421,13 @@ function itemParentIndex(segments: readonly string[], apiRootLength: number): nu /** * The parent of a path's last segment (itemParentIndex) as a noun, singular * since the parameter addresses one of it: `Pet` for `/pets/{petId}/adopt`. - * `null` when there is none. + * `null` when there is none. `singular` false keeps it as the path spells it + * (`Pets`), for writtenParentName. */ -function inferItemParent(segments: readonly string[], apiRootLength: number): string | null { +function inferItemParent(segments: readonly string[], apiRootLength: number, singular = true): string | null { const index = itemParentIndex(segments, apiRootLength); - return index < 0 ? null : toPascalCase(singularizePathSegment(segments[index])); + if (index < 0) return null; + return toPascalCase(singular ? singularizePathSegment(segments[index]) : segments[index]); } /** @@ -1421,13 +1439,18 @@ function inferItemParent(segments: readonly string[], apiRootLength: number): st * `createOrderStatus` for `POST /orders/{id}/status`. A POST on a singleton * that does not end in `s` is an action instead (inferAction), so * `/users/{id}/avatar` is `getUserAvatar` for a GET but `avatarUser` for a - * POST; give the POST an `operationId`. + * POST; give the POST an `operationId`. `singularParent` false keeps the + * parent as the path spells it (`Pets`), for writtenParentName. */ -function inferSingleton(path: string, apiRootLength = 0): { parent: string | null; resource: string } | null { +function inferSingleton( + path: string, + apiRootLength = 0, + singularParent = true, +): { parent: string | null; resource: string } | null { const segments = pathSegments(path); if (!isSingletonSegment(segments)) return null; return { - parent: inferItemParent(segments, apiRootLength), + parent: inferItemParent(segments, apiRootLength, singularParent), resource: toPascalCase(segments[segments.length - 1]), }; } @@ -1613,7 +1636,17 @@ function inferAction( * which is which. Members that share a plural some other member does * not take it together and are numbered on it: beside `/store/pet/{id}` * or an operationId's `getPet`, those two are `getPets` and `getPets2`, - * each a variant of its twin rather than of the operationId; + * each a variant of its twin rather than of the operationId. A + * singleton has one only beside another path-named singleton that + * writes its parent otherwise: the name carries the parent singular and + * the qualifiers read the path without it (qualifierPath), so the + * parent's plural is all that tells the two apart, and the singleton + * that writes it plural takes its name with the parent as written + * (writtenParentName) — `GET /shop/pets/{id}/status` beside + * `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, + * as `getToyPets` and `getToyPet` are. Beside an operationId or an + * operation of another shape it has none and is numbered + * (`getPetStatus2`); * 7. a numeric suffix (`listNotes2`, `listNotes3`, …), when no pass above * has a free name that tells the group apart, so an emitter never * sees two methods with one name in one class. A name that already @@ -1807,6 +1840,33 @@ function disambiguateOperationNames( return pluralFallbackName(op.httpMethod, op.path, root, actions); }; + // The plural of a singleton in `group`: its name with the parent as the + // path writes it (writtenParentName), `getPetsStatus` for + // `/shop/pets/{id}/status`, while its name is still the one it was built + // with. Only when another path-named singleton of the group writes the + // parent otherwise (`/shop/pet/{id}/status`): the qualifiers read a + // singleton's path without its parent (qualifierPath), so its plural is + // all that tells the two apart. Only path-named singletons take part (an + // operationId-named member does not, as in the deeper-context pass), so + // beside an operationId or an operation of another shape a singleton keeps + // its name and is numbered, as before. + const parentAsWritten = (group: Operation[]): ((op: Operation) => string | null) => { + const written = new Map(); + for (const op of group) { + if (!pathNamed.has(op)) continue; + const root = apiRootLengthOf(op); + const actions = !demoted.has(op); + const name = writtenParentName(op.httpMethod, op.path, root, actions); + if (name !== null && op.name === fallbackOperationName(op.httpMethod, op.path, root, actions)) { + written.set(op, name); + } + } + return (op) => { + const own = written.get(op); + return own !== undefined && own !== op.name && [...written.values()].some((name) => name !== own) ? own : null; + }; + }; + // Whether a later pass would tell `op`, once it has `name`, from `other`, // a path-named operation of its noun that has it — what joining `other` // there counts on (nameIsFree). One of the qualifiers after the first, @@ -1849,8 +1909,12 @@ function disambiguateOperationNames( // numbered below, `getPets` and `getPets2` — the names such a document // produced before item resources were singularised. Two groups never // share a plural: it is the group's singular name with the resource as - // written, so one plural means one singular name, and one group. - qualify(group, (op) => pluralOf(op) ?? op.name); + // written, so one plural means one singular name, and one group. A + // singleton's parent as written follows the same rule + // (parentAsWritten): `/shop/Pets/{id}/status` and `/shop/pets/{id}/status` + // beside `/shop/pet/{id}/status` are `getPetsStatus` and `getPetsStatus2`. + const respelled = parentAsWritten(group); + qualify(group, (op) => pluralOf(op) ?? respelled(op) ?? op.name); } const taken = new Set(operations.map((op) => op.name)); @@ -2314,7 +2378,9 @@ function sameNoun(a: string, b: string): boolean { * before the segment: `getPetStatus` on `/shop/rescue/pets/{id}/status` is * read as `/shop/rescue/status`, whose resource `Status` the name ends with, * so the same `Rescue` tells it from `/shop/pets/{id}/status` rather than - * `Pet` coming back as `getPetStatusPet`. Every other operation is read + * `Pet` coming back as `getPetStatusPet`. The parent's own spelling is left + * to the plural pass (writtenParentName), so `/shop/pets/{id}/status` beside + * `/shop/pet/{id}/status` is `getPetsStatus`. Every other operation is read * whole, as is an action that gave its name up on its path * (demoteSamePathNames; `actions` false), whose name is the next shape's. */ diff --git a/test/parser/fallback-action-segments.test.ts b/test/parser/fallback-action-segments.test.ts index 5acb70bd..ec0ee8cf 100644 --- a/test/parser/fallback-action-segments.test.ts +++ b/test/parser/fallback-action-segments.test.ts @@ -414,6 +414,150 @@ describe('fallback names on an action or singleton segment (issue #271)', () => } }); + it('keeps the parent as written when its plural is what tells two singletons apart (issue #381)', async () => { + // The issue repro: every operation tagged `Shop`, returning 200 with a + // JSON string. A singleton's qualifiers read its path without the parent + // (`/shop/status` for both), so the plural is all that tells + // `/shop/pets/{id}/status` from `/shop/pet/{id}/status`, as it tells the + // two items apart. + const jsonString = { + '200': { description: 'ok', content: { 'application/json': { schema: { type: 'string' } } } }, + }; + const op = (...ids: string[]) => ({ + tags: ['Shop'], + responses: jsonString, + parameters: ids.map((name) => ({ name, in: 'path' as const, required: true, schema: { type: 'string' } })), + }); + const repro = { + '/shop/pets/{id}/status': { get: op('id') }, + '/shop/pet/{id}/status': { get: op('id') }, + '/shop/pets/{id}/toys/{toyId}': { get: op('id', 'toyId') }, + '/shop/pet/{id}/toys/{toyId}': { get: op('id', 'toyId') }, + }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const spec = await parseSpec( + await writeSpec('repro.json', { openapi: '3.1.0', info: { title: 'repro', version: '1' }, paths: repro }), + ); + expect( + spec.services.flatMap(({ operations }) => + operations.map((o) => `${o.name}: ${o.httpMethod.toUpperCase()} ${o.path}`), + ), + ).toEqual([ + 'getPetsStatus: GET /shop/pets/{id}/status', + 'getPetStatus: GET /shop/pet/{id}/status', + 'getToyPets: GET /shop/pets/{id}/toys/{toyId}', + 'getToyPet: GET /shop/pet/{id}/toys/{toyId}', + ]); + // The same names whatever the document order. + const reversed = Object.fromEntries(Object.entries(repro).reverse()); + expect(names(reversed)).toEqual(names(repro)); + // A path-derived name is refined silently. + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + + // The parent is the literal nearest the parameter, wherever it sits, and + // a segment that repeats the parent's noun is not added again + // (appendQualifier): `/org/statuses/{id}/status` is `getStatuses`. + expect( + names({ + '/shop/owners/{ownerId}/pets/{id}/status': { get: item('id') }, + '/shop/owners/{ownerId}/pet/{id}/status': { get: item('id') }, + '/org/statuses/{id}/status': { get: item('id') }, + '/org/status/{id}/status': { get: item('id') }, + }), + ).toEqual([ + 'Org.getStatus GET /org/status/{id}/status', + 'Org.getStatuses GET /org/statuses/{id}/status', + 'Shop.getPetStatus GET /shop/owners/{ownerId}/pet/{id}/status', + 'Shop.getPetsStatus GET /shop/owners/{ownerId}/pets/{id}/status', + ]); + // PUT and PATCH on one path share the parent as written. + expect( + names({ + '/shop/pets/{id}/status': { put: item('id'), patch: item('id') }, + '/shop/pet/{id}/status': { put: item('id') }, + }), + ).toEqual([ + 'Shop.updatePetStatus PUT /shop/pet/{id}/status', + 'Shop.updatePetsStatus PATCH /shop/pets/{id}/status', + 'Shop.updatePetsStatus PUT /shop/pets/{id}/status', + ]); + // Singletons that write the parent alike take it together and are + // numbered on it, as items that share a plural are; two that would only + // take one spelling are numbered on the plain name, since it would say + // nothing about which is which. + expect( + names({ + '/shop/Pets/{id}/status': { get: item('id') }, + '/shop/pets/{id}/status': { get: item('id') }, + '/shop/pet/{id}/status': { get: item('id') }, + }), + ).toEqual([ + 'Shop.getPetsStatus GET /shop/Pets/{id}/status', + 'Shop.getPetStatus GET /shop/pet/{id}/status', + 'Shop.getPetsStatus2 GET /shop/pets/{id}/status', + ]); + expect( + names({ + '/shop/Pets/{id}/status': { get: item('id') }, + '/shop/pets/{id}/status': { get: item('id') }, + }), + ).toEqual([ + 'Shop.getPetStatus GET /shop/Pets/{id}/status', + 'Shop.getPetStatus2 GET /shop/pets/{id}/status', + ]); + }); + + it('gives a singleton its parent as written only beside another singleton, and only when that name is free (issue #381)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + // An operationId joining the pair keeps the plain name; the plural path + // still takes its plural and the singular path is numbered, as for + // `getPets` and `getPet2` beside `operationId: getPet`. + expect( + names({ + '/shop/featured/{id}': { get: { ...item('id'), operationId: 'getPetStatus' } }, + '/shop/pets/{id}/status': { get: item('id') }, + '/shop/pet/{id}/status': { get: item('id') }, + }), + ).toEqual([ + 'Shop.getPetStatus GET /shop/featured/{id}', + 'Shop.getPetStatus2 GET /shop/pet/{id}/status', + 'Shop.getPetsStatus GET /shop/pets/{id}/status', + ]); + // The parent as written is taken: the number goes on the plain name. + expect( + names({ + '/shop/all/{id}': { get: { ...item('id'), operationId: 'getPetsStatus' } }, + '/shop/pets/{id}/status': { get: item('id') }, + '/shop/pet/{id}/status': { get: item('id') }, + }), + ).toEqual([ + 'Shop.getPetsStatus GET /shop/all/{id}', + 'Shop.getPetStatus GET /shop/pet/{id}/status', + 'Shop.getPetStatus2 GET /shop/pets/{id}/status', + ]); + // Beside an operation of another shape the plural is not what tells the + // paths apart, so the singleton has none to keep and is numbered, as it + // is beside an operationId alone. + expect( + names({ + '/shop/pet-status/{id}': { get: item('id') }, + '/shop/pets/{id}/status': { get: item('id') }, + }), + ).toEqual([ + 'Shop.getPetStatus GET /shop/pet-status/{id}', + 'Shop.getPetStatus2 GET /shop/pets/{id}/status', + ]); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + it('reads a custom method after a colon as the segment it is written on followed by the verb', () => { expect( names({ From 19e234547ca0559b0e358383a618daf0816e8240 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 15:28:16 -0400 Subject: [PATCH 35/59] fix(parser)!: skip a plural another operation has in the last naming pass Review on #479. The plural pass checked its proposals with nameIsFree. That check lets a member join a path-named operation of its noun when a later qualifier would tell the two apart (#402). But the plural pass is the last one before the numeric suffix, so no qualifier was left to do it. The joined pair was numbered, and the number landed on the name the other operation had. With the singleton's parent as written from the previous commit, `/shop/pets/{id}/status` beside `/shop/pet/{id}/status` and `/shop/archive/pets-status/{id}/status` (`getPetsStatus`) became `getPetsStatus2`. The item plural did the same. `PUT /shop/pets/{id}` beside `PUT /shop/pet/{id}` and `PUT /shop/archive/pets` (`updatePets`) was `updatePets2`. Beside `PUT /shop/rescue/pets`, which sorts after it, the item took `updatePets` and the collection was renumbered `updatePets2`. The plural pass now joins nothing: it does not take a proposal that any operation outside the group has. pipeline.md and ir-types.md already state this rule ("if no operation outside its group has that name"). The member is numbered on its own name instead: `getPetStatus2`, `updatePet2`. BREAKING CHANGE: a path-named item operation whose plural is already held by a path-named operation of its noun outside its group is now numbered on its singular name (`updatePet2`), not on the plural (`updatePets2`). That other operation keeps its name where it used to be renumbered (`updatePets` for `PUT /shop/rescue/pets`, which was `updatePets2`). --- src/parser/operations.ts | 13 +++++-- test/parser/fallback-action-segments.test.ts | 14 +++++++ test/parser/fallback-operation-names.test.ts | 41 ++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/parser/operations.ts b/src/parser/operations.ts index dcf5d251..9c5380ff 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -1902,9 +1902,13 @@ function disambiguateOperationNames( // Like every qualifier, only when it tells the group apart: beside an // operationId, which has no plural, or beside a path that spells the // resource singular (`/store/pet/{id}`). Members that would all take the - // same plural are left to the numeric suffix. A plural is blocked only by - // a name outside the group (`operationId: getPets`); members that share - // one some other member does not (`/store/Pets/{id}` and + // same plural are left to the numeric suffix. A plural is blocked by any + // name outside the group, an operationId's (`operationId: getPets`) or a + // path-named operation's (`PUT /shop/archive/pets` beside + // `PUT /shop/pets/{id}`): no qualifier is left to tell a member from an + // operation whose name it joined (nameIsFree, `apart`), so the join would + // only put a number on the name that operation had. Members that share a + // plural some other member does not (`/store/Pets/{id}` and // `/store/pets/{id}` beside `/store/pet/{id}`) take it together and are // numbered below, `getPets` and `getPets2` — the names such a document // produced before item resources were singularised. Two groups never @@ -1914,7 +1918,8 @@ function disambiguateOperationNames( // (parentAsWritten): `/shop/Pets/{id}/status` and `/shop/pets/{id}/status` // beside `/shop/pet/{id}/status` are `getPetsStatus` and `getPetsStatus2`. const respelled = parentAsWritten(group); - qualify(group, (op) => pluralOf(op) ?? respelled(op) ?? op.name); + const proposal = (op: Operation) => pluralOf(op) ?? respelled(op) ?? op.name; + qualifyGroup(group, operations, pathNamed, nounOf, () => false, proposal, rename); } const taken = new Set(operations.map((op) => op.name)); diff --git a/test/parser/fallback-action-segments.test.ts b/test/parser/fallback-action-segments.test.ts index ec0ee8cf..211e4ab0 100644 --- a/test/parser/fallback-action-segments.test.ts +++ b/test/parser/fallback-action-segments.test.ts @@ -540,6 +540,20 @@ describe('fallback names on an action or singleton segment (issue #271)', () => 'Shop.getPetStatus GET /shop/pet/{id}/status', 'Shop.getPetStatus2 GET /shop/pets/{id}/status', ]); + // Taken by a path-named operation too, even one `Archive` would tell + // apart: no qualifier runs after this pass, so joining it would only + // number the name it had (`getPetsStatus2`). + expect( + names({ + '/shop/archive/pets-status/{id}/status': { get: item('id') }, + '/shop/pets/{id}/status': { get: item('id') }, + '/shop/pet/{id}/status': { get: item('id') }, + }), + ).toEqual([ + 'Shop.getPetsStatus GET /shop/archive/pets-status/{id}/status', + 'Shop.getPetStatus GET /shop/pet/{id}/status', + 'Shop.getPetStatus2 GET /shop/pets/{id}/status', + ]); // Beside an operation of another shape the plural is not what tells the // paths apart, so the singleton has none to keep and is numbered, as it // is beside an operationId alone. diff --git a/test/parser/fallback-operation-names.test.ts b/test/parser/fallback-operation-names.test.ts index 684b429d..38b000c6 100644 --- a/test/parser/fallback-operation-names.test.ts +++ b/test/parser/fallback-operation-names.test.ts @@ -629,6 +629,47 @@ describe('plural path names (issue #268)', () => { ]); }); + it('falls back to a numeric suffix when a path-named operation outside the group has the plural (#381 review)', () => { + // `PUT /shop/archive/pets` is `updatePets`. `Archive` would tell it from + // `/shop/pets/{id}`, but no qualifier runs after the plural, so joining + // it would only number the name it had (`updatePets2`). + expect( + names({ + '/shop/pets/{id}': { put: item('id') }, + '/shop/pet/{id}': { put: item('id') }, + '/shop/archive/pets': { put: ok }, + }), + ).toEqual([ + 'updatePets PUT /shop/archive/pets', + 'updatePet PUT /shop/pet/{id}', + 'updatePet2 PUT /shop/pets/{id}', + ]); + expect( + names({ + '/shop/featured/{id}': { put: { ...item('id'), operationId: 'updatePet' } }, + '/shop/pets/{id}': { put: item('id') }, + '/shop/archive/pets': { put: ok }, + }), + ).toEqual([ + 'updatePets PUT /shop/archive/pets', + 'updatePet PUT /shop/featured/{id}', + 'updatePet2 PUT /shop/pets/{id}', + ]); + // Nor does the item take the name from an operation that sorts after it, + // which the numeric pass would then renumber `updatePets2`. + expect( + names({ + '/shop/pets/{id}': { put: item('id') }, + '/shop/pet/{id}': { put: item('id') }, + '/shop/rescue/pets': { put: ok }, + }), + ).toEqual([ + 'updatePet PUT /shop/pet/{id}', + 'updatePet2 PUT /shop/pets/{id}', + 'updatePets PUT /shop/rescue/pets', + ]); + }); + it('singularises a deeper segment only when a parameter follows it, and keeps the rest as written', () => { // `cats` in `/store/cats/toys/{toyId}` addresses no one cat, so it stays // plural, exactly as a collection resource does; `legacy` is not plural. From f73fb7ff31a8c74f99fbc2451ac6b5ff0b29ddec Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 16:14:18 -0400 Subject: [PATCH 36/59] fix(parser)!: name an action the same whatever else the document declares Since #324, two path-named operations that would share a name were kept apart by renaming one of them, and which one depended on what else the document declares. demoteSamePathNames gave an action name on one path to the method whose verb the segment carries, else to POST, and renamed the rest. yieldActionNames renamed an action beside a CRUD operation of its name. Both renamed an operation that had held its name alone, so adding a sibling moved that name onto the new operation: `POST /pets/bulk-delete` went from `bulkDelete` to `createBulkDelete` when `DELETE /pets/bulk-delete` was added, `POST /pets/create` from `create` to `createPets` beside `POST /pets`, and `POST /pets/update` from `update` to `updatePets` beside `PUT /pets/{id}`. `oagen diff` read each addition as breaking. Both rules now hold whether or not the other operation is declared: - A literal is one verb's action, decided by the literal alone: the verb it carries (segmentCarriesVerb), the first method's in HTTP_METHODS order when it carries several, else POST's. inferAction reads no action for a method of another verb, so `POST /pets/bulk-delete` is `createBulkDelete` and `POST /users/{id}/update-password` the singleton `createUserUpdatePassword`, with or without the DELETE or PUT. demoteSamePathNames is left with an operationId on the path. - An action yields when its method gives the path without the verb the action's name (verblessName): `POST /pets` is `create`, so `POST /pets/create` is `createPets`; `GET /pets/all` is `listAll`, so `GET /pets/list-all` is `listAllPets`; `GET /pets/{id}` is `get`, so `GET /pets/get` is `getPets`. PUT and PATCH on one path share the name they yield to. An action that meets a CRUD or singleton operation of its name any other way still yields to it. The qualifier renames that predate #324 (`listTeams` becoming `listTeamsById` beside an added `GET /users/teams`) are unchanged. pipeline.md and ir-types.md state the new rules. Closes #379 BREAKING CHANGE: a path-named POST on a literal that carries another method's verb is no longer an action: `POST /pets/bulk-delete`, `POST /users/bulk-update` and `POST /v1/pets:batchGet` are `createBulkDelete`, `createBulkUpdate` and `createBatchGet` (were `bulkDelete`, `bulkUpdate` and `batchGet`), and `POST /pets/update` is `createUpdate`. An action whose name its method gives the path without the verb takes back its collection even alone: `POST /pets/create` is `createPets`, `GET /pets/list-all` is `listAllPets` and `HEAD /pets/check-health` is `checkHealthPets` (were `create`, `listAll` and `checkHealth`). --- docs/architecture/ir-types.md | 2 +- docs/architecture/pipeline.md | 7 +- src/parser/operations.ts | 222 ++++++++++++------- test/parser/fallback-action-segments.test.ts | 188 +++++++++++++++- test/parser/fallback-operation-names.test.ts | 6 +- test/parser/tagged-service-split.test.ts | 4 +- 6 files changed, 328 insertions(+), 101 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 01d6449d..5682281f 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -352,7 +352,7 @@ An operation's `callbacks` are not read; a `paths` operation that declares any i An operation carries its OpenAPI `summary` and `description` in two fields, each read from the Operation Object and, when the operation declares none with content, from the Path Item it sits on (the `paths` entry itself, or the `components/pathItems` item a `$ref` entry leads to; see [Path Item references](#path-item-references)) — OpenAPI defines a Path Item's `summary` and `description` as applying to every operation on it. The two fall back one by one: an operation with a `summary` of its own and no `description` shows its own `summary` over the Path Item's `description`, and an operation with both shows only its own. `Operation.description` is the combined doc text of the two resolved texts: `summary + "\n\n" + description` when both have content, or whichever one has content (a summary identical to the description is not repeated). A `summary` or `description` that is not a string, or is empty or whitespace-only, is treated as absent at either level — `summary: "List pets"` with `description: ""` yields `description: "List pets"`, and `summary: ""` on an operation takes the Path Item's — and text that has content is kept verbatim, surrounding whitespace included. An emitter that prints `description` as the doc comment therefore sees the same text whether the document put the text on the operation or on its Path Item, and whether or not it used a summary. `Operation.summary` is the resolved `summary` alone and is present only when one of the two levels has content, so an emitter that wants a one-line label (and the description without it) reads `summary` rather than guessing whether the first paragraph of `description` is one. When a doc URL base is configured, relative links are expanded in `summary` exactly as in `description`. -An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a path-named operation of another verb takes the name it has without the action rule (`POST /pets/bulk-delete` beside `DELETE /pets/bulk-delete` is `createBulkDelete`), numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action that shares its name with a path-named operation of another shape yields: it takes back the collection it dropped (`POST /pets/update` beside `PUT /pets/{id}` is `updatePets`, and `update` stays with the PUT) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural, except that a singleton beside another path-named singleton that writes the parent otherwise keeps the parent as written, its name carrying the parent singular and its qualifiers reading the path without it, so that the plural is all that tells the two apart (`GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, while beside `operationId: getPetStatus` alone it is `getPetStatus2`); members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a literal is one verb's action whichever methods the document declares on its path — the verb it carries, the first method's in HTTP method order when it carries several, else POST's — so a method of another verb is never read as an action on it and declaring that method takes no name from an operation already there (`POST /pets/bulk-delete` is `createBulkDelete`, beside `DELETE /pets/bulk-delete`'s `bulkDelete` or not, and `POST /v1/pets:batchGet` is `createBatchGet`); a path-named operation that would share an `operationId`'s name on its path takes the name it has without the action rule, numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action yields its name when an operation of its method would have it on the path without the action's verb (`POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`), whether or not that operation is declared, so declaring it never renames the action, and when it shares its name with a path-named operation of another shape: it takes back the collection it dropped (`POST /pets/create` is `createPets` and `PUT /pets/update` is `updatePets`, while `create` and `update` stay with `POST /pets` and `PUT /pets/{id}`) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural, except that a singleton beside another path-named singleton that writes the parent otherwise keeps the parent as written, its name carrying the parent singular and its qualifiers reading the path without it, so that the plural is all that tells the two apart (`GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, while beside `operationId: getPetStatus` alone it is `getPetStatus2`); members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ```typescript interface Service { diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index 0ec3b3bc..a5e99661 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -56,16 +56,17 @@ If an `operationId` is present, it takes precedence. Names are inferred from HTT | POST | `/store/pets/{id}/photos` | `createPhotos` | | POST | `/accounts/users/{id}/ban` | `banUser` | | DELETE | `/users/bulk-delete` | `bulkDelete` | -| POST | `/store/pets/bulk-delete` | `bulkDeletePets` | +| DELETE | `/store/pets/bulk-delete` | `bulkDeletePets` | +| POST | `/store/pets/bulk-delete` | `createBulkDelete` | | PUT | `/users/{id}/update-password` | `updatePasswordUser` | The collection vs. instance distinction is determined by whether the path ends with a `{param}` segment. The resource is the last literal segment, dropped when it is the segment the service was named from — compared by position, so `GET /teams/{id}/teams` in `Teams` is `listTeams`, the teams under one team, while `GET /teams` is `list` — and singularised for an instance operation (`pets` → `Pet`, `api_keys` → `ApiKey`, `addresses` → `Address`, `aliases` → `Alias`, `analyses` → `Analysis`; `status` and other words that do not look plural are kept). The same rule spells every other path segment a name takes: a segment a `{param}` follows is singular, any other is kept as written — and the stemmer's limits reach those segments too (`sms/{smsId}` between the service and the resource contributes `Sm`, `canvas/{id}` contributes `Canva`). A literal after a `{param}` that does not look plural is a singleton of that item: one thing about one pet, read with the item verb (`get`, not `list`) and named after the method's verb, the singular parent and the segment — `GET /store/pets/{id}/status` → `getPetStatus`, `DELETE /users/{id}/avatar` → `deleteUserAvatar`, `PUT` → `updateUserAvatar`, `HEAD` → `checkUserAvatar`, and `POST /orders/{id}/status` → `createOrderStatus`. The parent is kept even when it is the first segment, as it is for an action: `GET /pets/{id}/status` in `Pets` is `getPetStatus`. A plural literal after a `{param}` (`/users/{id}/photos`, `/orgs/{id}/api_keys`) is a nested collection: `listPhotos`, `createApiKeys`. An irregular plural that `singularizePathSegment` does not recognise (`children`, `people`, `data`) does not look plural, so `GET /pets/{id}/children` is the singleton `getPetChildren`; give it an `operationId`. -An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /pets/list-all` → `listAll`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. A literal carrying another method's verb is not an action: after a `{param}` it is a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`). And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/bulk-delete` → `bulkDelete`, `POST /users/bulk-update` → `bulkUpdate`, `POST /v1/pets:batchGet` → `batchGet`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. Only a segment's one colon outside a parameter's braces starts a custom method. A colon inside braces is the parameter's wherever they sit, so `GET /v1/files/file-{file_path:path}` reads one segment and is `listFileFilePathPath`, while `POST /v1/files/file-{file_path:path}:download` is `createDownload`; and AIP-136 appends a single verb, so a segment with several colons outside braces is one literal, even when a parameter opens it: `GET /things/urn:pet:1` is `listUrnPet1`, and `GET /v1/pets/{petId}:adopt:now` is `listPetIdAdoptNow`, so `GET /v1/pets/{petId}` beside it stays `get`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. +An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /store/pets/list-all` → `listAllPets`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/health-check` → `healthCheck`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). A literal is one verb's action, whichever methods the document declares on its path: the verb it carries — the first method's in `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE` order when it carries several — and POST's when it carries none. A literal carrying another method's verb is therefore no action, after a `{param}` a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`, `POST /users/{id}/update-password` → `createUserUpdatePassword`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`, `POST /pets/bulk-delete` → `createBulkDelete`, `POST /users/bulk-update` → `createBulkUpdate`, `POST /v1/pets:batchGet` → `createBatchGet`), whether or not the method whose verb it carries is declared, so declaring that method takes no name from an operation already there. An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. Only a segment's one colon outside a parameter's braces starts a custom method. A colon inside braces is the parameter's wherever they sit, so `GET /v1/files/file-{file_path:path}` reads one segment and is `listFileFilePathPath`, while `POST /v1/files/file-{file_path:path}:download` is `createDownload`; and AIP-136 appends a single verb, so a segment with several colons outside braces is one literal, even when a parameter opens it: `GET /things/urn:pet:1` is `listUrnPet1`, and `GET /v1/pets/{petId}:adopt:now` is `listPetIdAdoptNow`, so `GET /v1/pets/{petId}` beside it stays `get`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. -Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2` beside `operationId: getPetStatus`). The exception is a singleton beside another fallback singleton that writes the parent otherwise. A singleton's name carries its parent singular, and its qualifiers read the path without the parent, so the parent's plural is all that tells the two apart, and the one that writes it plural keeps it as written: `GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, as the items `GET /shop/pets/{id}/toys/{toyId}` and `GET /shop/pet/{id}/toys/{toyId}` are `getToyPets` and `getToyPet`. That name is numbered when another operation has it, and singletons that write the parent alike share it as fallbacks share a plural (`/shop/Pets/{id}/status` and `/shop/pets/{id}/status` beside `/shop/pet/{id}/status` are `getPetsStatus`, `getPetsStatus2` and `getPetStatus`). An action that shares its name with a path-named CRUD or singleton operation yields first: `update` stays with `PUT /pets/{id}`, and `POST /pets/update` takes back the collection it dropped, `updatePets` (`createPets` for `POST /pets/create` beside `POST /pets`, `listAllPets` for `GET /pets/list-all` beside `GET /pets/all`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create` beside `POST /store/pets/{id}`, `createUpdate` for `POST /store/pets/update` beside `PUT /store/pets`). Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`). A path-named operation of another verb that would share an action name takes the name it has without the action rule: the verb the segment carries keeps the action, so `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` beside it is `createBulkDelete`; with no method carrying the verb, POST keeps it and `PUT /pets/bulk-delete` beside it is `updateBulkDelete`; `PUT /users/{id}/update-password` keeps `updatePasswordUser` and a POST beside it is the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2` beside `operationId: getPetStatus`). The exception is a singleton beside another fallback singleton that writes the parent otherwise. A singleton's name carries its parent singular, and its qualifiers read the path without the parent, so the parent's plural is all that tells the two apart, and the one that writes it plural keeps it as written: `GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, as the items `GET /shop/pets/{id}/toys/{toyId}` and `GET /shop/pet/{id}/toys/{toyId}` are `getToyPets` and `getToyPet`. That name is numbered when another operation has it, and singletons that write the parent alike share it as fallbacks share a plural (`/shop/Pets/{id}/status` and `/shop/pets/{id}/status` beside `/shop/pet/{id}/status` are `getPetsStatus`, `getPetsStatus2` and `getPetStatus`). An action yields first when an operation of its method would have its name on the path without the action's verb — `POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`, and `GET /pets/{id}`, one item, is `get` — whether or not that operation is declared, so declaring it never renames the action: `create` stays with `POST /pets`, and `POST /pets/create` takes back the collection it dropped, `createPets` (`updatePets` for `PUT /pets/update`, `listAllPets` for `GET /pets/list-all`, `getPets` for `GET /pets/get`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`; `createCreate` for `POST /store/pets/create`). An action that shares its name with a path-named CRUD or singleton operation any other way yields to it the same way. Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`), and since a literal is one verb's action (see above), two methods never share an action name: `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` is `createBulkDelete`, beside each other or not, and `PUT /users/{id}/update-password` is `updatePasswordUser` and a POST on it the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ## Stage 2: Emit (`src/engine/`) diff --git a/src/parser/operations.ts b/src/parser/operations.ts index 9c5380ff..32e4d5ef 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -1193,9 +1193,10 @@ function inferOperationName( * `GET /companies/verification/{id}/verification-status` is * `getVerificationStatus`. * - * `actions` false skips the first shape, for an operation that gave an - * action name up to another method on its path (demoteSamePathNames): - * `POST /pets/bulk-delete` is then `createBulkDelete`. + * `actions` false skips the first shape, for an action that gave its name + * up to an operationId on its path (demoteSamePathNames) or to the CRUD + * operation that could have it (yieldActionNames): + * `POST /store/pets/{id}/create` is then `createPetCreate`. */ function fallbackOperationName(method: HttpMethod, path: string, apiRootLength: number, actions = true): string { const action = actions ? inferAction(method, path, apiRootLength) : null; @@ -1304,9 +1305,8 @@ function methodVerbs(method: HttpMethod): string[] { * and `batch-get` for a GET, `update-password` for a PUT or PATCH, and not * `bulk-delete` for a POST. `list` and `check` count only as the first word * (LEADING_ONLY_VERBS), so `wish-list`, `allow-list` and `health-check` carry - * no verb. Such a segment is the action for that method (inferAction) and - * keeps the action name when another method on the path would take it too - * (demoteSamePathNames). + * no verb. Such a segment is the action for that method (inferAction), and + * for no method of another verb, declared beside it or not. */ function segmentCarriesVerb(method: HttpMethod, path: string): boolean { const segments = pathSegments(path); @@ -1474,28 +1474,38 @@ function inferSingleton( * `GET /pets/update` is `listUpdate` and `GET /pets/last-update` is * `listLastUpdate`; * - a POST on a literal with no parameter before it whose last word is any - * CRUD verb (CRUD_VERBS): `POST /pets/bulk-delete`, `POST /users/bulk-update`, - * `POST /v1/pets:batchGet`. Such a literal is otherwise read as a - * collection (`/pets/search` is `createSearch`), which would make it - * `createBulkDelete`. The last word only, so `/pets/delete-candidates` - * stays a collection, and a POST only, since a verb that is not the - * method's own is not what the method does. + * CRUD verb (CRUD_VERBS): `POST /pets/health-check`. Such a literal is + * otherwise read as a collection (`/pets/search` is `createSearch`). The + * last word only, so `/pets/delete-candidates` stays a collection, and a + * POST only, since a verb that is not the method's own is not what the + * method does. + * + * A segment is one verb's action, whichever methods the document declares + * on it: the verb it carries (segmentCarriesVerb), the first method's in + * HTTP_METHODS order when it carries several, else POST's. A method of + * another verb is never read as an action on it, so declaring the method + * whose action it is takes no name from an operation already there: + * `POST /pets/bulk-delete`, `POST /users/bulk-update` and + * `POST /v1/pets:batchGet` carry DELETE's, PUT's and GET's verbs and are + * `createBulkDelete`, `createBulkUpdate` and `createBatchGet`, and + * `POST /users/{id}/update-password` is the singleton + * `createUserUpdatePassword`, beside that method or not. * * The verb is the segment itself. The resource is the singular parent * (inferItemParent) when the segment follows a parameter — `adopt` + `Pet` — * and otherwise the collection the segment sits under, as * inferResourceFromPath reads the path without the segment, so - * `/pets/bulk-delete` in `Pets` is the bare `bulkDelete` and - * `/store/pets/bulk-delete` is `bulkDeletePets`. The caller leaves the + * `DELETE /pets/bulk-delete` in `Pets` is the bare `bulkDelete` and + * `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. The caller leaves the * resource off when the verb already ends with it (`restart-id-verification` * under `verification` is `restartIdVerification`, see appendQualifier). * * A custom method written after a colon is read as the segment it is written * on followed by the verb (see pathSegments): `/pets/{petId}:adopt` is * `{petId}` then `adopt`, so it is the action `adoptPet` exactly as - * `/pets/{petId}/adopt` is; `/pets:bulkDelete` is read as `/pets/bulkDelete` - * and is `bulkDelete`; `/pets:search` is read as `/pets/search` and stays a - * `create`. + * `/pets/{petId}/adopt` is; `DELETE /pets:bulkDelete` is read as + * `/pets/bulkDelete` and is `bulkDelete`; `/pets:search` is read as + * `/pets/search` and stays a `create`. */ function inferAction( method: HttpMethod, @@ -1512,6 +1522,8 @@ function inferAction( const ownVerb = segmentCarriesVerb(method, path); const verbPhrase = method === 'post' && !afterParam && words.length > 0 && CRUD_VERBS.has(words[words.length - 1]); if (!postAction && !ownVerb && !verbPhrase) return null; + const owner = HTTP_METHODS.find((m) => segmentCarriesVerb(m, path)) ?? 'post'; + if (inferVerb(owner, path) !== inferVerb(method, path)) return null; const resource = afterParam ? inferItemParent(segments, apiRootLength) @@ -1532,9 +1544,11 @@ function inferAction( * resolved first (demoteSamePathNames), before the qualifiers below, since * every consumer that keys a service's operations by name — the differ among * them — would otherwise see one of the two. An action that shares a name - * with a path-named operation of another shape yields next - * (yieldActionNames), so `update` stays with `PUT /pets/{id}` and - * `POST /pets/update` is `updatePets`. A name any pass gives an operation is + * with a path-named operation of another shape, or whose name one would have + * on the path without the action's verb, yields next (yieldActionNames), + * whether or not that operation is declared, so `update` stays with + * `PUT /pets/{id}` and `PUT /pets/update` is `updatePets` in a document + * with the item or without it. A name any pass gives an operation is * checked against every name the service holds, and once every pass has run * the one-path rule is checked once more (numberSamePathNames), since a * rename can land on a name an operation on the same path already has. @@ -1695,8 +1709,9 @@ function disambiguateOperationNames( // One path first: the qualifiers below tell paths apart and leave a name // shared on one path alone, so that sharing is settled before they run. const demoted = demoteSamePathNames(operations, pathNamed, serviceName, apiRootLengthOf, rename); - // Then an action beside a CRUD or singleton operation of its name: the - // plain name stays with the latter, which the qualifiers would rename. + // Then an action beside a CRUD or singleton operation of its name, or one + // whose name such an operation would have, declared or not: the plain name + // stays with the latter, which the qualifiers would rename. for (const op of yieldActionNames(operations, pathNamed, demoted, apiRootLengthOf, rename)) demoted.add(op); // Each qualifier, given the group, is the word or words a path-named @@ -1969,26 +1984,25 @@ function disambiguateOperationNames( * both are path-named and their methods take the same verb (inferVerb): PUT * and PATCH are both `update`, so `/pets/{id}` has one `updatePet` for the * two, as it always has. Any other sharing hides one of the two from every - * consumer that keys a service's operations by name, such as the differ, and - * an action name is where it arises: `bulk-delete` is the action of a DELETE - * (its own verb) and of a POST (a CRUD verb last), so both would be - * `bulkDelete`. + * consumer that keys a service's operations by name, such as the differ. + * Two path-named operations of different verbs never share an action name, + * since a segment is one verb's action whichever methods the document + * declares on it (inferAction): `POST /pets/bulk-delete` is + * `createBulkDelete`, beside `DELETE /pets/bulk-delete`'s `bulkDelete` or + * not. What is left is an operationId on the path. * - * The name stays with one verb, and every path-named operation of another - * verb takes the name it has without the action rule (fallbackOperationName - * with `actions` false): `POST /pets/bulk-delete` beside a DELETE is - * `createBulkDelete`, and `POST /users/{id}/update-password` beside a PUT is - * the singleton `createUserUpdatePassword`. The verb that keeps the name: an - * operationId-named operation's, since that name is the author's and a - * path-named sibling steps aside whatever its verb; else the verb the segment - * carries (segmentCarriesVerb), `delete` for `bulk-delete`, the first - * method's in HTTP_METHODS order when it carries two; else `create`, POST - * being the only other method an action is read on (inferAction); else, for - * a shared name that is no action (PUT and PATCH `updatePet`), the verb of - * the first method in HTTP_METHODS order. When the name an operation falls back to is - * still held on the path by an operationId or by another verb — path-named - * PATCH `updatePet` beside `operationId: updatePet` on the PUT has no action - * to drop — a numeric suffix free in the service is added, silently, as the + * An operationId-named operation keeps its name, since that name is the + * author's, and every path-named operation that shares it steps aside + * whatever its verb, taking the name it has without the action rule + * (fallbackOperationName with `actions` false): `POST /pets/{id}/adopt` + * beside `operationId: adoptPet` on the GET is the singleton + * `createPetAdopt`. Should two path-named operations of different verbs share + * a name on one path — the fallback names give none — the first in + * HTTP_METHODS order keeps it and the other steps aside the same way. When + * the name an operation falls back to is still held on the path by an + * operationId or by another verb — path-named PATCH `updatePet` beside + * `operationId: updatePet` on the PUT has no action to drop — a numeric + * suffix free in the service is added, silently, as the * numeric pass adds one and spelled as it spells it (numberedName: * `updatePet2`, and `updateTop10No2` for a PATCH on `/store/top10/{id}` * beside `operationId: updateTop10`). Two operationId-named operations on @@ -2011,8 +2025,6 @@ function demoteSamePathNames( const demoted = new Set(); const taken = new Set(operations.map((op) => op.name)); const verbOf = (op: Operation) => inferVerb(op.httpMethod, op.path); - // The verb that keeps a shared action name among path-named operations. - const claim = (op: Operation) => (segmentCarriesVerb(op.httpMethod, op.path) ? 0 : op.httpMethod === 'post' ? 1 : 2); for (const onPath of operationsByPath(operations).values()) { const byName = new Map(); @@ -2030,8 +2042,8 @@ function demoteSamePathNames( rename(op, next); taken.add(next); } - const keeper = - explicit.length > 0 ? null : [...sharing].sort((a, b) => claim(a) - claim(b) || methodOrder(a, b))[0]; + // `sharing` is in HTTP_METHODS order (operationsByPath). + const keeper = explicit.length > 0 ? null : sharing[0]; const keptVerb = keeper === null ? null : verbOf(keeper); for (const op of sharing) { if (!pathNamed.has(op) || verbOf(op) === keptVerb) continue; @@ -2053,29 +2065,35 @@ function demoteSamePathNames( } /** - * Keep the plain CRUD name with the CRUD operation when an action takes it. - * `POST /pets/update` is the action `update` — its collection `pets` is the - * service segment, which a name leaves off — and so is `PUT /pets/{id}`. Left - * to the qualifiers, the pair would be told apart by `By`, which - * renames the PUT to `updateById`, and code calling `update` would then - * reach `POST /pets/update`. So before the qualifiers run, a path-named - * action that shares its name with a path-named operation of another shape — - * a CRUD operation or a singleton, for which inferAction is null, a demoted - * action among them — is renamed alone. It takes back the collection it - * dropped, the service noun of the path the action segment sits on - * (inferServiceNoun of qualifierPath): `updatePets`, `createPets` for - * `POST /pets/create` beside `POST /pets`, `listAllPets` for - * `GET /pets/list-all` beside `GET /pets/all`. When that adds nothing — - * the name already ends with it (`POST /store/pets/{id}/create` is - * `createPet`, as `POST /store/pets/{id}` is) or there is none - * (`POST /store/pets/update` is `updatePets`, as `PUT /store/pets` is) — it - * takes the name it has without the action rule (`createPetCreate`, - * `createUpdate`) and is read from then on - * as that shape, like a demoted action (demoteSamePathNames). An action - * beside an operationId of its name is left to the numeric pass - * (`adoptPet2`), and two actions of one name to the qualifiers - * (`adoptPetRescue`). Groups come in name order and the renames depend only - * on each operation's own path, so the result is deterministic. Returns the + * Keep the plain CRUD name with the CRUD operation an action could take it + * from. `PUT /pets/update` is the action `update` — its collection `pets` is + * the service segment, which a name leaves off — and so is `PUT /pets/{id}`. + * Left to the qualifiers, the pair would be told apart by `By`, which + * renames the PUT on the item to `updateById`, and code calling `update` + * would then reach `PUT /pets/update`. Renaming the action only once the + * other is declared would not do either: a document that adds `PUT /pets/{id}` + * would hand it the action's name, and `oagen diff` would read the addition + * as the action changing path. So before the qualifiers run, a path-named + * action is renamed alone, whether or not the other operation is declared, + * when its name is the one its method gives the path without the verb its + * segment starts with (verblessName): `update` for `PUT /pets/update`, as + * `PUT /pets` is. So is any other path-named action that shares its name + * with a path-named operation of another shape — a CRUD operation or a + * singleton, for which inferAction is null, a demoted action among them. It + * takes back the collection it dropped, the service noun of the path the + * action segment sits on (inferServiceNoun of qualifierPath): `updatePets`, + * `createPets` for `POST /pets/create` (`POST /pets` is `create`), + * `listAllPets` for `GET /pets/list-all` (`GET /pets/all` is `listAll`). + * When that adds nothing — the name already ends with it, or there is none, + * as for `POST /store/pets/{id}/create` (`createPet`, as + * `POST /store/pets/{id}` is) and `POST /store/pets/create` (`createPets`, as + * `POST /store/pets` is) — it takes the name it has without the action rule + * (`createPetCreate`, `createCreate`) and is read from then on as that + * shape, like a demoted action (demoteSamePathNames). An action beside an + * operationId of its name is left to the numeric pass (`adoptPet2`), and two + * actions of one name to the qualifiers (`adoptPetRescue`). Actions come in + * name order, then path order, and the renames depend only on each + * operation's own path, so the result is deterministic. Returns the * operations that fell back to their non-action name. */ function yieldActionNames( @@ -2088,26 +2106,66 @@ function yieldActionNames( const fellBack: Operation[] = []; const isAction = (op: Operation) => pathNamed.has(op) && !demoted.has(op) && inferAction(op.httpMethod, op.path, apiRootLengthOf(op)) !== null; + const meeting = new Set( + collidingGroups(operations) + .filter((group) => group.some((op) => pathNamed.has(op) && !isAction(op))) + .flatMap((group) => group.filter(isAction)), + ); + const yielding = operations.filter( + (op) => meeting.has(op) || (isAction(op) && verblessName(op.httpMethod, op.path, apiRootLengthOf(op)) === op.name), + ); + yielding.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) || comparePaths(a, b) || methodOrder(a, b)); // Every name the service holds, kept current as actions are renamed, so a // rename never lands on a name another operation already has — least of // all one on the action's own path, which no later pass looks at again. const taken = new Set(operations.map((op) => op.name)); - for (const group of collidingGroups(operations)) { - if (!group.some((op) => pathNamed.has(op) && !isAction(op))) continue; - for (const op of group.filter(isAction)) { - const root = apiRootLengthOf(op); - const qualified = appendQualifier(op.name, inferServiceNoun(qualifierPath(op, root), root)); - const plain = fallbackOperationName(op.httpMethod, op.path, root, false); - const next = - [qualified, plain].find((name) => name !== op.name && !taken.has(name)) ?? suffixedName(plain, taken); - rename(op, next); - taken.add(next); - if (next !== qualified) fellBack.push(op); - } + // The name each path and verb yielded to, which PUT and PATCH on one path share. + const yieldedOnPath = new Map(); + for (const op of yielding) { + const root = apiRootLengthOf(op); + const qualified = appendQualifier(op.name, inferServiceNoun(qualifierPath(op, root), root)); + const plain = fallbackOperationName(op.httpMethod, op.path, root, false); + const onPath = `${inferVerb(op.httpMethod, op.path)} ${op.path}`; + // An action that meets no operation of its name keeps it when it has no + // other free: a number would only move it off a name nothing else has. + const next = + yieldedOnPath.get(onPath) ?? + [qualified, plain].find((name) => name !== op.name && !taken.has(name)) ?? + (meeting.has(op) ? suffixedName(plain, taken) : op.name); + yieldedOnPath.set(onPath, next); + if (next === op.name) continue; + rename(op, next); + taken.add(next); + if (next !== qualified) fellBack.push(op); } return fellBack; } +/** + * The name an action's method gives its path with the method's verb that + * the action segment starts with taken off — the whole segment when it is + * that verb alone: `create` (`POST /pets`) for `POST /pets/create`, `listAll` + * (`GET /pets/all`) for `GET /pets/list-all`, `createPet` + * (`POST /store/pets/{id}`) for `POST /store/pets/{id}/create`. `null` when + * the segment does not start with one of the method's verbs (methodVerbs): + * `bulk-delete`, `adopt`, `force-update`, and when nothing but the API root + * is left (`POST /create`). GET's item verb reads one item, so `get` is what + * GET gives an item of what is left: `get` (`GET /pets/{id}`) for + * `GET /pets/get`. An action whose name this is shares it with an operation + * the document may declare, and yields it whether or not it does + * (yieldActionNames). + */ +function verblessName(method: HttpMethod, path: string, apiRootLength: number): string | null { + const segments = pathSegments(path); + const [verb, ...rest] = splitWords(segments[segments.length - 1] ?? ''); + const word = verb?.toLowerCase(); + if (word === undefined || !methodVerbs(method).includes(word)) return null; + const twin = rest.length > 0 ? [...segments.slice(0, -1), rest.join('-')] : segments.slice(0, -1); + if (twin.length <= apiRootLength) return null; + if (word !== METHOD_VERBS[method].collection && !twin[twin.length - 1].startsWith('{')) twin.push('{id}'); + return fallbackOperationName(method, '/' + twin.join('/'), apiRootLength); +} + /** * The last check on one path, after every pass has run. Each pass renames * path-named operations, and a new name can be one another operation on the @@ -2386,8 +2444,8 @@ function sameNoun(a: string, b: string): boolean { * `Pet` coming back as `getPetStatusPet`. The parent's own spelling is left * to the plural pass (writtenParentName), so `/shop/pets/{id}/status` beside * `/shop/pet/{id}/status` is `getPetsStatus`. Every other operation is read - * whole, as is an action that gave its name up on its path - * (demoteSamePathNames; `actions` false), whose name is the next shape's. + * whole, as is an action that gave its name up (demoteSamePathNames, + * yieldActionNames; `actions` false), whose name is the next shape's. */ function qualifierPath(op: Operation, apiRootLength: number, actions = true): string { const segments = pathSegments(op.path); diff --git a/test/parser/fallback-action-segments.test.ts b/test/parser/fallback-action-segments.test.ts index 211e4ab0..9b96b942 100644 --- a/test/parser/fallback-action-segments.test.ts +++ b/test/parser/fallback-action-segments.test.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { extractOperations } from '../../src/parser/operations.js'; import { parseSpec } from '../../src/parser/parse.js'; import { collectModelRefs } from '../../src/ir/types.js'; +import { diffSpecs } from '../../src/differ/diff.js'; /** * Fallback names on an action or singleton segment (issue #271). A literal @@ -242,18 +243,33 @@ describe('fallback names on an action or singleton segment (issue #271)', () => '/store/pets/search': { post: ok }, }), ).toEqual([ - 'Pets.bulkDelete POST /pets/bulk-delete', + // `bulk-delete` and `bulk-update` carry DELETE's and PUT's verbs, whose + // actions they are whether or not those methods are declared (#379). + 'Pets.createBulkDelete POST /pets/bulk-delete', 'Pets.updateBulkDelete PUT /pets/bulk-delete', 'Pets.listLastUpdate GET /pets/last-update', 'Pets.listUpdate GET /pets/update', 'Store.listCheckIns GET /store/events/check-ins', - 'Store.bulkUpdatePets POST /store/pets/bulk-update', + 'Store.createBulkUpdate POST /store/pets/bulk-update', 'Store.listDeleteCandidates GET /store/pets/delete-candidates', 'Store.createDeleteCandidates POST /store/pets/delete-candidates', 'Store.createSearch POST /store/pets/search', - 'Store.bulkDeletePhotos POST /store/pets/{id}/photos/bulk-delete', + 'Store.createBulkDelete POST /store/pets/{id}/photos/bulk-delete', 'Store.listDisplayOptions GET /store/settings/display-options', ]); + // A CRUD verb last that no other method carries: `list` and `check` count + // for GET and HEAD only as the first word, and `create` is POST's own. + expect( + names({ + '/pets/health-check': { post: ok }, + '/pets/allow-list': { post: ok }, + '/pets/bulk-create': { post: ok }, + }), + ).toEqual([ + 'Pets.allowList POST /pets/allow-list', + 'Pets.bulkCreate POST /pets/bulk-create', + 'Pets.healthCheck POST /pets/health-check', + ]); }); it('reads list and check as the verb only at the start of a segment; every other verb anywhere', () => { @@ -269,9 +285,11 @@ describe('fallback names on an action or singleton segment (issue #271)', () => }), ).toEqual([ 'Pets.listAllowList GET /pets/allow-list', - 'Pets.checkHealth HEAD /pets/check-health', + // `HEAD /pets/health` and `GET /pets/all` would be `checkHealth` and + // `listAll`, so the actions take back their collection (#379). + 'Pets.checkHealthPets HEAD /pets/check-health', 'Pets.forceUpdate PUT /pets/force-update', - 'Pets.listAll GET /pets/list-all', + 'Pets.listAllPets GET /pets/list-all', 'Pets.checkPetHealthCheck HEAD /pets/{id}/health-check', 'Pets.getPetWishList GET /pets/{id}/wish-list', 'Pets.batchGet GET /pets:batchGet', @@ -305,9 +323,24 @@ describe('fallback names on an action or singleton segment (issue #271)', () => 'Pets.create POST /pets', 'Pets.listAll GET /pets/all', 'Pets.createPets POST /pets/create', - 'Pets.deletePets POST /pets/delete', + // `delete` and `update` carry DELETE's and PUT's verbs: a POST on them + // is no action (#379). + 'Pets.createDelete POST /pets/delete', 'Pets.listAllPets GET /pets/list-all', - 'Pets.updatePets POST /pets/update', + 'Pets.createUpdate POST /pets/update', + 'Pets.delete DELETE /pets/{id}', + 'Pets.update PUT /pets/{id}', + ]); + // The methods whose verbs they are read them as the actions. + expect( + names({ + '/pets/delete': { delete: ok }, + '/pets/update': { put: ok }, + '/pets/{id}': { put: item('id'), delete: item('id') }, + }), + ).toEqual([ + 'Pets.deletePets DELETE /pets/delete', + 'Pets.updatePets PUT /pets/update', 'Pets.delete DELETE /pets/{id}', 'Pets.update PUT /pets/{id}', ]); @@ -351,11 +384,21 @@ describe('fallback names on an action or singleton segment (issue #271)', () => '/shop/rescue/pets/bulk-delete': { post: ok }, }), ).toEqual([ - 'Shop.bulkDeletePets POST /shop/pets/bulk-delete', + // `bulk-delete` is DELETE's action, so these POSTs are collections (#379). + 'Shop.createBulkDeletePets POST /shop/pets/bulk-delete', 'Shop.getPetStatus GET /shop/pets/{id}/status', - 'Shop.bulkDeletePetsRescue POST /shop/rescue/pets/bulk-delete', + 'Shop.createBulkDeleteRescuePets POST /shop/rescue/pets/bulk-delete', 'Shop.getPetStatusRescue GET /shop/rescue/pets/{id}/status', ]); + expect( + names({ + '/shop/pets/bulk-delete': { delete: ok }, + '/shop/rescue/pets/bulk-delete': { delete: ok }, + }), + ).toEqual([ + 'Shop.bulkDeletePets DELETE /shop/pets/bulk-delete', + 'Shop.bulkDeletePetsRescue DELETE /shop/rescue/pets/bulk-delete', + ]); // Under an API root the parameters tell the paths apart; one every path // carries, `petId`, adds nothing. expect( @@ -625,9 +668,16 @@ describe('one name on one path: two methods never share it unless they are PUT a }); it('keeps the action with POST when no method carries the verb, and shares it between PUT and PATCH', () => { + expect(names({ '/pets/health-check': { get: ok, post: ok, put: ok } })).toEqual([ + 'Pets.listHealthCheck GET /pets/health-check', + 'Pets.healthCheck POST /pets/health-check', + 'Pets.updateHealthCheck PUT /pets/health-check', + ]); + // `bulk-delete` carries DELETE's verb: the action is the DELETE's, declared + // or not, and a POST on it is no action (#379). expect(names({ '/pets/bulk-delete': { get: ok, post: ok, put: ok } })).toEqual([ 'Pets.listBulkDelete GET /pets/bulk-delete', - 'Pets.bulkDelete POST /pets/bulk-delete', + 'Pets.createBulkDelete POST /pets/bulk-delete', 'Pets.updateBulkDelete PUT /pets/bulk-delete', ]); expect(names({ '/pets/bulk-update': { post: ok, put: ok, patch: ok } })).toEqual([ @@ -803,3 +853,121 @@ describe('one name on one path: two methods never share it unless they are PUT a expect(services.flatMap((s) => s.operations).length).toBe(54); }); }); + +describe('an operation added beside an action takes no name an operation already has (issue #379)', () => { + // Every operation tagged `Pets`, returning 204, with no operationId. + const op = { tags: ['Pets'], responses: { '204': { description: 'ok' } } }; + const itemOp = { ...op, ...idParam('id') }; + + /** `paths` with the operations of `more` declared too: a path both carry has the methods of both. */ + const withAdded = (paths: Paths, more: Paths): Paths => { + const merged: Paths = { ...paths }; + for (const [p, methods] of Object.entries(more)) merged[p] = { ...merged[p], ...methods }; + return merged; + }; + + /** What `oagen diff` reports between two documents of `before` and `after`, one line per change. */ + async function diff(before: Paths, after: Paths) { + const doc = (paths: Paths) => ({ openapi: '3.1.0', info: { title: 'repro', version: '1' }, paths }); + const { changes, summary } = diffSpecs( + await parseSpec(await writeSpec('old.json', doc(before))), + await parseSpec(await writeSpec('new.json', doc(after))), + ); + return { + breaking: summary.breaking, + changes: changes.map((c) => `${c.kind} ${'operationName' in c ? c.operationName : ''}: ${c.classification}`), + }; + } + + /** + * Each case declares `before`, then adds `more`, in both orders: whichever + * operation the document had, the other one comes in as one additive + * `operation-added`, under the name it has in the merged document, and the + * one already there keeps its own. + */ + async function expectAdditive(before: Paths, more: Paths, named: { before: string; more: string }) { + const after = withAdded(before, more); + expect(await diff(before, after)).toEqual({ breaking: 0, changes: [`operation-added ${named.more}: additive`] }); + expect(await diff(more, after)).toEqual({ breaking: 0, changes: [`operation-added ${named.before}: additive`] }); + } + + it('reports each pair of the issue as one additive operation-added', async () => { + // `POST /pets/bulk-delete` carries DELETE's verb, so the action is the + // DELETE's whether or not it is declared. + await expectAdditive( + { '/pets/bulk-delete': { post: op } }, + { '/pets/bulk-delete': { delete: op } }, + { before: 'createBulkDelete', more: 'bulkDelete' }, + ); + // `POST /pets` is `create`, so the action on `/pets/create` never is. + await expectAdditive( + { '/pets/create': { post: op } }, + { '/pets': { post: op } }, + { before: 'createPets', more: 'create' }, + ); + // `update` is PUT's verb: a POST on it is no action. + await expectAdditive( + { '/pets/update': { post: op } }, + { '/pets/{id}': { put: itemOp } }, + { before: 'createUpdate', more: 'update' }, + ); + }); + + it('names an action alike whether or not the operation that could share its name is declared', async () => { + // A POST after a parameter on a segment that carries PUT's verb. + await expectAdditive( + { '/pets/{id}/update-password': { post: itemOp } }, + { '/pets/{id}/update-password': { put: itemOp } }, + { before: 'createPetUpdatePassword', more: 'updatePasswordPet' }, + ); + // An action whose name the operation of its method on the path without + // the verb has: `GET /pets/all` is `listAll`, `PUT /pets` is `update`, + // `POST /store/pets/{id}` is `createPet`. + await expectAdditive( + { '/pets/list-all': { get: op } }, + { '/pets/all': { get: op } }, + { before: 'listAllPets', more: 'listAll' }, + ); + await expectAdditive( + { '/pets/update': { put: op } }, + { '/pets/{id}': { put: itemOp } }, + { before: 'updatePets', more: 'update' }, + ); + await expectAdditive( + { '/store/pets/{id}/create': { post: itemOp } }, + { '/store/pets/{id}': { post: itemOp } }, + { before: 'createPetCreate', more: 'createPet' }, + ); + await expectAdditive( + { '/store/pets/update': { post: op } }, + { '/store/pets': { put: op } }, + { before: 'createUpdate', more: 'updatePets' }, + ); + // `get` reads one item: `GET /pets/{id}` is `get`. + await expectAdditive( + { '/pets/get': { get: op } }, + { '/pets/{id}': { get: itemOp } }, + { before: 'getPets', more: 'get' }, + ); + }); + + it('gives PUT and PATCH on one path the one name their action yields to', () => { + expect(names({ '/pets/update': { put: ok, patch: ok } })).toEqual([ + 'Pets.updatePets PATCH /pets/update', + 'Pets.updatePets PUT /pets/update', + ]); + expect( + names({ + '/pets/update': { put: ok, patch: ok }, + '/pets/{id}': { put: item('id') }, + '/store/pets/{id}/update': { put: item('id'), patch: item('id') }, + }), + ).toEqual([ + 'Pets.updatePets PATCH /pets/update', + 'Pets.updatePets PUT /pets/update', + 'Pets.update PUT /pets/{id}', + 'Store.updatePetUpdate PATCH /store/pets/{id}/update', + 'Store.updatePetUpdate PUT /store/pets/{id}/update', + ]); + }); +}); diff --git a/test/parser/fallback-operation-names.test.ts b/test/parser/fallback-operation-names.test.ts index 38b000c6..5368a705 100644 --- a/test/parser/fallback-operation-names.test.ts +++ b/test/parser/fallback-operation-names.test.ts @@ -987,11 +987,11 @@ describe('custom-method paths (issue #269)', () => { const paths = { '/v1/pets:batchGet': { post: json }, '/v1/pets': { get: json } }; const { services } = extractOperations(paths as Parameters[0]); expect(services.map((s) => s.name)).toEqual(['Pets']); - // `batchGet` ends in a CRUD verb, so it is the action (#271), as - // `/v1/pets/batchGet` is; the service segment it sits on is dropped. + // `batchGet` carries GET's verb, so it is GET's action (#379) and a POST + // on it is read as the collection `/v1/pets/batchGet` is. expect(names(paths)).toEqual([ 'list GET /v1/pets', - 'batchGet POST /v1/pets:batchGet', + 'createBatchGet POST /v1/pets:batchGet', ]); }); diff --git a/test/parser/tagged-service-split.test.ts b/test/parser/tagged-service-split.test.ts index 14beb092..a2b29419 100644 --- a/test/parser/tagged-service-split.test.ts +++ b/test/parser/tagged-service-split.test.ts @@ -94,7 +94,7 @@ describe('one untagged operation under a tagged service path leaves the tagged o name: 'Pets', operations: [ 'listPets: GET /v1/pets', - 'batchGetPets: POST /v1/pets:batchGet', + 'createBatchGet: POST /v1/pets:batchGet', 'getPetExport: GET /v1/pets/{id}/export', ], }, @@ -104,7 +104,7 @@ describe('one untagged operation under a tagged service path leaves the tagged o name: 'Pets', operations: [ 'getPetExport: GET /v1/pets/{id}/export', - 'batchGetPets: POST /v1/pets:batchGet', + 'createBatchGet: POST /v1/pets:batchGet', 'listPets: GET /v1/pets', ], }, From 251d9ae125424fc51bd9ccc7f15932d5e0882aed Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 16:26:21 -0400 Subject: [PATCH 37/59] fix(parser)!: number an action whose other names are held, its CRUD operation declared or not Review on #480. yieldActionNames renames an action whose name its method gives the path without the verb, whether or not that operation is declared. When both names it could take instead were held, it kept its own name unless the CRUD operation was declared, and was numbered only then. With operationIds holding `createPets` and `createCreate`, `POST /pets/create` stayed `create` until `POST /pets` was added, then became `createCreate2` while the new operation took `create`, which `oagen diff` read as the action changing path. The action is now numbered on its name without the action rule whenever both are held (`createCreate2`), beside the CRUD operation or not. verblessName now returns null for an action that sits under no collection, with no literal before its segment past the API root (`POST /create`, `PUT /{id}/update`): every name such an action could take back is its own, so it would be numbered alone. pipeline.md and ir-types.md state the numbered fallback. BREAKING CHANGE: a path-named action whose name its method gives the path without the verb, and whose other two names are both held by other operations, is numbered (`createCreate2`) even when no CRUD operation of its name is declared. --- docs/architecture/ir-types.md | 2 +- docs/architecture/pipeline.md | 2 +- src/parser/operations.ts | 37 +++++++++++--------- test/parser/fallback-action-segments.test.ts | 13 +++++++ 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 5682281f..02228709 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -352,7 +352,7 @@ An operation's `callbacks` are not read; a `paths` operation that declares any i An operation carries its OpenAPI `summary` and `description` in two fields, each read from the Operation Object and, when the operation declares none with content, from the Path Item it sits on (the `paths` entry itself, or the `components/pathItems` item a `$ref` entry leads to; see [Path Item references](#path-item-references)) — OpenAPI defines a Path Item's `summary` and `description` as applying to every operation on it. The two fall back one by one: an operation with a `summary` of its own and no `description` shows its own `summary` over the Path Item's `description`, and an operation with both shows only its own. `Operation.description` is the combined doc text of the two resolved texts: `summary + "\n\n" + description` when both have content, or whichever one has content (a summary identical to the description is not repeated). A `summary` or `description` that is not a string, or is empty or whitespace-only, is treated as absent at either level — `summary: "List pets"` with `description: ""` yields `description: "List pets"`, and `summary: ""` on an operation takes the Path Item's — and text that has content is kept verbatim, surrounding whitespace included. An emitter that prints `description` as the doc comment therefore sees the same text whether the document put the text on the operation or on its Path Item, and whether or not it used a summary. `Operation.summary` is the resolved `summary` alone and is present only when one of the two levels has content, so an emitter that wants a one-line label (and the description without it) reads `summary` rather than guessing whether the first paragraph of `description` is one. When a doc URL base is configured, relative links are expanded in `summary` exactly as in `description`. -An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a literal is one verb's action whichever methods the document declares on its path — the verb it carries, the first method's in HTTP method order when it carries several, else POST's — so a method of another verb is never read as an action on it and declaring that method takes no name from an operation already there (`POST /pets/bulk-delete` is `createBulkDelete`, beside `DELETE /pets/bulk-delete`'s `bulkDelete` or not, and `POST /v1/pets:batchGet` is `createBatchGet`); a path-named operation that would share an `operationId`'s name on its path takes the name it has without the action rule, numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action yields its name when an operation of its method would have it on the path without the action's verb (`POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`), whether or not that operation is declared, so declaring it never renames the action, and when it shares its name with a path-named operation of another shape: it takes back the collection it dropped (`POST /pets/create` is `createPets` and `PUT /pets/update` is `updatePets`, while `create` and `update` stay with `POST /pets` and `PUT /pets/{id}`) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural, except that a singleton beside another path-named singleton that writes the parent otherwise keeps the parent as written, its name carrying the parent singular and its qualifiers reading the path without it, so that the plural is all that tells the two apart (`GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, while beside `operationId: getPetStatus` alone it is `getPetStatus2`); members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a literal is one verb's action whichever methods the document declares on its path — the verb it carries, the first method's in HTTP method order when it carries several, else POST's — so a method of another verb is never read as an action on it and declaring that method takes no name from an operation already there (`POST /pets/bulk-delete` is `createBulkDelete`, beside `DELETE /pets/bulk-delete`'s `bulkDelete` or not, and `POST /v1/pets:batchGet` is `createBatchGet`); a path-named operation that would share an `operationId`'s name on its path takes the name it has without the action rule, numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), numbered on the last when every one is held (`createCreate2`), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action yields its name when an operation of its method would have it on the path without the action's verb (`POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`), whether or not that operation is declared, so declaring it never renames the action, and when it shares its name with a path-named operation of another shape: it takes back the collection it dropped (`POST /pets/create` is `createPets` and `PUT /pets/update` is `updatePets`, while `create` and `update` stay with `POST /pets` and `PUT /pets/{id}`) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural, except that a singleton beside another path-named singleton that writes the parent otherwise keeps the parent as written, its name carrying the parent singular and its qualifiers reading the path without it, so that the plural is all that tells the two apart (`GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, while beside `operationId: getPetStatus` alone it is `getPetStatus2`); members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ```typescript interface Service { diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index a5e99661..e2eb1b60 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -66,7 +66,7 @@ A literal after a `{param}` that does not look plural is a singleton of that ite An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /store/pets/list-all` → `listAllPets`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/health-check` → `healthCheck`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). A literal is one verb's action, whichever methods the document declares on its path: the verb it carries — the first method's in `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE` order when it carries several — and POST's when it carries none. A literal carrying another method's verb is therefore no action, after a `{param}` a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`, `POST /users/{id}/update-password` → `createUserUpdatePassword`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`, `POST /pets/bulk-delete` → `createBulkDelete`, `POST /users/bulk-update` → `createBulkUpdate`, `POST /v1/pets:batchGet` → `createBatchGet`), whether or not the method whose verb it carries is declared, so declaring that method takes no name from an operation already there. An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. Only a segment's one colon outside a parameter's braces starts a custom method. A colon inside braces is the parameter's wherever they sit, so `GET /v1/files/file-{file_path:path}` reads one segment and is `listFileFilePathPath`, while `POST /v1/files/file-{file_path:path}:download` is `createDownload`; and AIP-136 appends a single verb, so a segment with several colons outside braces is one literal, even when a parameter opens it: `GET /things/urn:pet:1` is `listUrnPet1`, and `GET /v1/pets/{petId}:adopt:now` is `listPetIdAdoptNow`, so `GET /v1/pets/{petId}` beside it stays `get`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. -Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2` beside `operationId: getPetStatus`). The exception is a singleton beside another fallback singleton that writes the parent otherwise. A singleton's name carries its parent singular, and its qualifiers read the path without the parent, so the parent's plural is all that tells the two apart, and the one that writes it plural keeps it as written: `GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, as the items `GET /shop/pets/{id}/toys/{toyId}` and `GET /shop/pet/{id}/toys/{toyId}` are `getToyPets` and `getToyPet`. That name is numbered when another operation has it, and singletons that write the parent alike share it as fallbacks share a plural (`/shop/Pets/{id}/status` and `/shop/pets/{id}/status` beside `/shop/pet/{id}/status` are `getPetsStatus`, `getPetsStatus2` and `getPetStatus`). An action yields first when an operation of its method would have its name on the path without the action's verb — `POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`, and `GET /pets/{id}`, one item, is `get` — whether or not that operation is declared, so declaring it never renames the action: `create` stays with `POST /pets`, and `POST /pets/create` takes back the collection it dropped, `createPets` (`updatePets` for `PUT /pets/update`, `listAllPets` for `GET /pets/list-all`, `getPets` for `GET /pets/get`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`; `createCreate` for `POST /store/pets/create`). An action that shares its name with a path-named CRUD or singleton operation any other way yields to it the same way. Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`), and since a literal is one verb's action (see above), two methods never share an action name: `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` is `createBulkDelete`, beside each other or not, and `PUT /users/{id}/update-password` is `updatePasswordUser` and a POST on it the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` beside `POST /pets` when the GET on `/pets/create` carries `operationId: createPets`), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2` beside `operationId: getPetStatus`). The exception is a singleton beside another fallback singleton that writes the parent otherwise. A singleton's name carries its parent singular, and its qualifiers read the path without the parent, so the parent's plural is all that tells the two apart, and the one that writes it plural keeps it as written: `GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, as the items `GET /shop/pets/{id}/toys/{toyId}` and `GET /shop/pet/{id}/toys/{toyId}` are `getToyPets` and `getToyPet`. That name is numbered when another operation has it, and singletons that write the parent alike share it as fallbacks share a plural (`/shop/Pets/{id}/status` and `/shop/pets/{id}/status` beside `/shop/pet/{id}/status` are `getPetsStatus`, `getPetsStatus2` and `getPetStatus`). An action yields first when an operation of its method would have its name on the path without the action's verb — `POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`, and `GET /pets/{id}`, one item, is `get` — whether or not that operation is declared, so declaring it never renames the action: `create` stays with `POST /pets`, and `POST /pets/create` takes back the collection it dropped, `createPets` (`updatePets` for `PUT /pets/update`, `listAllPets` for `GET /pets/list-all`, `getPets` for `GET /pets/get`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`; `createCreate` for `POST /store/pets/create`). An action that shares its name with a path-named CRUD or singleton operation any other way yields to it the same way. Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`), and since a literal is one verb's action (see above), two methods never share an action name: `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` is `createBulkDelete`, beside each other or not, and `PUT /users/{id}/update-password` is `updatePasswordUser` and a POST on it the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` when the GET on `/pets/create` carries `operationId: createPets`), numbered on the last when every one is held (`createCreate2` when another operation carries `operationId: createCreate` as well), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ## Stage 2: Emit (`src/engine/`) diff --git a/src/parser/operations.ts b/src/parser/operations.ts index 32e4d5ef..c0e9e15d 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -2089,12 +2089,15 @@ function demoteSamePathNames( * `POST /store/pets/{id}` is) and `POST /store/pets/create` (`createPets`, as * `POST /store/pets` is) — it takes the name it has without the action rule * (`createPetCreate`, `createCreate`) and is read from then on as that - * shape, like a demoted action (demoteSamePathNames). An action beside an - * operationId of its name is left to the numeric pass (`adoptPet2`), and two - * actions of one name to the qualifiers (`adoptPetRescue`). Actions come in - * name order, then path order, and the renames depend only on each - * operation's own path, so the result is deterministic. Returns the - * operations that fell back to their non-action name. + * shape, like a demoted action (demoteSamePathNames). A name another + * operation holds is skipped, and with both held it is numbered on the + * latter (`createCreate2` when operationIds hold `createPets` and + * `createCreate`). An action beside an operationId of its name is left to + * the numeric pass (`adoptPet2`), and two actions of one name to the + * qualifiers (`adoptPetRescue`). Actions come in name order, then path + * order, and the renames depend only on each operation's own path, so the + * result is deterministic. Returns the operations that fell back to their + * non-action name. */ function yieldActionNames( operations: Operation[], @@ -2126,14 +2129,13 @@ function yieldActionNames( const qualified = appendQualifier(op.name, inferServiceNoun(qualifierPath(op, root), root)); const plain = fallbackOperationName(op.httpMethod, op.path, root, false); const onPath = `${inferVerb(op.httpMethod, op.path)} ${op.path}`; - // An action that meets no operation of its name keeps it when it has no - // other free: a number would only move it off a name nothing else has. + // Numbered when both are held, the CRUD operation declared or not, so + // declaring it later never moves the action to a number. const next = yieldedOnPath.get(onPath) ?? [qualified, plain].find((name) => name !== op.name && !taken.has(name)) ?? - (meeting.has(op) ? suffixedName(plain, taken) : op.name); + suffixedName(plain, taken); yieldedOnPath.set(onPath, next); - if (next === op.name) continue; rename(op, next); taken.add(next); if (next !== qualified) fellBack.push(op); @@ -2148,20 +2150,21 @@ function yieldActionNames( * (`GET /pets/all`) for `GET /pets/list-all`, `createPet` * (`POST /store/pets/{id}`) for `POST /store/pets/{id}/create`. `null` when * the segment does not start with one of the method's verbs (methodVerbs): - * `bulk-delete`, `adopt`, `force-update`, and when nothing but the API root - * is left (`POST /create`). GET's item verb reads one item, so `get` is what - * GET gives an item of what is left: `get` (`GET /pets/{id}`) for - * `GET /pets/get`. An action whose name this is shares it with an operation - * the document may declare, and yields it whether or not it does - * (yieldActionNames). + * `bulk-delete`, `adopt`, `force-update`, and for an action that sits under + * no collection, with no literal before its segment past the API root + * (`POST /create`, `PUT /{id}/update`). GET's item verb reads one item, so + * `get` is what GET gives an item of what is left: `get` (`GET /pets/{id}`) + * for `GET /pets/get`. An action whose name this is shares it with an + * operation the document may declare, and yields it whether or not it + * does (yieldActionNames). */ function verblessName(method: HttpMethod, path: string, apiRootLength: number): string | null { const segments = pathSegments(path); const [verb, ...rest] = splitWords(segments[segments.length - 1] ?? ''); const word = verb?.toLowerCase(); if (word === undefined || !methodVerbs(method).includes(word)) return null; + if (!segments.slice(apiRootLength, -1).some((segment) => !segment.startsWith('{'))) return null; const twin = rest.length > 0 ? [...segments.slice(0, -1), rest.join('-')] : segments.slice(0, -1); - if (twin.length <= apiRootLength) return null; if (word !== METHOD_VERBS[method].collection && !twin[twin.length - 1].startsWith('{')) twin.push('{id}'); return fallbackOperationName(method, '/' + twin.join('/'), apiRootLength); } diff --git a/test/parser/fallback-action-segments.test.ts b/test/parser/fallback-action-segments.test.ts index 9b96b942..304295ae 100644 --- a/test/parser/fallback-action-segments.test.ts +++ b/test/parser/fallback-action-segments.test.ts @@ -949,6 +949,19 @@ describe('an operation added beside an action takes no name an operation already { '/pets/{id}': { get: itemOp } }, { before: 'getPets', more: 'get' }, ); + // Both names it would take back are operationIds': the action is + // numbered on its name without the action rule, the CRUD operation + // declared or not. + const held: Paths = { + '/pets/create': { post: op }, + '/pets/a': { get: { ...op, operationId: 'createPets' } }, + '/pets/b': { get: { ...op, operationId: 'createCreate' } }, + }; + expect(await diff(held, withAdded(held, { '/pets': { post: op } }))).toEqual({ + breaking: 0, + changes: ['operation-added create: additive'], + }); + expect(names(held)).toContain('Pets.createCreate2 POST /pets/create'); }); it('gives PUT and PATCH on one path the one name their action yields to', () => { From 93f8b457db2285bd6c89743fc442ceb0f322124a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 28 Oct 2026 16:37:56 -0400 Subject: [PATCH 38/59] fix(parser)!: yield an action on a bare parameter whose service its CRUD twin shares Review on #480. verblessName skipped every action with no literal before its segment past the API root, taking it for one whose path without the verb sits in another service. That holds under a root, where the literal names the service (`PUT /v1/{id}/update` is in `Update`, `PUT /v1/{id}` in `V1`), but not for a bare parameter at the start of a root-less path: `PUT /{id}/update` and `PUT /{id}` are both in `Default`. The action kept `update` alone and was renamed `update2` once `PUT /{id}` was added, which `oagen diff` read as breaking. verblessName now skips an action only when its segment is the one its service is named from (serviceSegmentIndex), so `POST /create` and `PUT /v1/{id}/update` keep their names, while `PUT /{id}/update` is `update2` with or without `PUT /{id}`: its collection and its name without the action rule are both its own, so the number is all that is left. A tagged `POST /create` beside a tagged `POST /` is split into `Create` and the tag's service, so the two never share a name. BREAKING CHANGE: a path-named action on a bare parameter at the start of a root-less path whose name its method gives the parameter alone is numbered (`update2` for `PUT /{id}/update`) even when that operation is not declared. --- docs/architecture/ir-types.md | 2 +- docs/architecture/pipeline.md | 2 +- src/parser/operations.ts | 19 ++++++++++++------- test/parser/fallback-action-segments.test.ts | 17 +++++++++++++++++ 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 02228709..74084160 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -352,7 +352,7 @@ An operation's `callbacks` are not read; a `paths` operation that declares any i An operation carries its OpenAPI `summary` and `description` in two fields, each read from the Operation Object and, when the operation declares none with content, from the Path Item it sits on (the `paths` entry itself, or the `components/pathItems` item a `$ref` entry leads to; see [Path Item references](#path-item-references)) — OpenAPI defines a Path Item's `summary` and `description` as applying to every operation on it. The two fall back one by one: an operation with a `summary` of its own and no `description` shows its own `summary` over the Path Item's `description`, and an operation with both shows only its own. `Operation.description` is the combined doc text of the two resolved texts: `summary + "\n\n" + description` when both have content, or whichever one has content (a summary identical to the description is not repeated). A `summary` or `description` that is not a string, or is empty or whitespace-only, is treated as absent at either level — `summary: "List pets"` with `description: ""` yields `description: "List pets"`, and `summary: ""` on an operation takes the Path Item's — and text that has content is kept verbatim, surrounding whitespace included. An emitter that prints `description` as the doc comment therefore sees the same text whether the document put the text on the operation or on its Path Item, and whether or not it used a summary. `Operation.summary` is the resolved `summary` alone and is present only when one of the two levels has content, so an emitter that wants a one-line label (and the description without it) reads `summary` rather than guessing whether the first paragraph of `description` is one. When a doc URL base is configured, relative links are expanded in `summary` exactly as in `description`. -An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a literal is one verb's action whichever methods the document declares on its path — the verb it carries, the first method's in HTTP method order when it carries several, else POST's — so a method of another verb is never read as an action on it and declaring that method takes no name from an operation already there (`POST /pets/bulk-delete` is `createBulkDelete`, beside `DELETE /pets/bulk-delete`'s `bulkDelete` or not, and `POST /v1/pets:batchGet` is `createBatchGet`); a path-named operation that would share an `operationId`'s name on its path takes the name it has without the action rule, numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), numbered on the last when every one is held (`createCreate2`), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action yields its name when an operation of its method would have it on the path without the action's verb (`POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`), whether or not that operation is declared, so declaring it never renames the action, and when it shares its name with a path-named operation of another shape: it takes back the collection it dropped (`POST /pets/create` is `createPets` and `PUT /pets/update` is `updatePets`, while `create` and `update` stay with `POST /pets` and `PUT /pets/{id}`) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural, except that a singleton beside another path-named singleton that writes the parent otherwise keeps the parent as written, its name carrying the parent singular and its qualifiers reading the path without it, so that the plural is all that tells the two apart (`GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, while beside `operationId: getPetStatus` alone it is `getPetStatus2`); members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a literal is one verb's action whichever methods the document declares on its path — the verb it carries, the first method's in HTTP method order when it carries several, else POST's — so a method of another verb is never read as an action on it and declaring that method takes no name from an operation already there (`POST /pets/bulk-delete` is `createBulkDelete`, beside `DELETE /pets/bulk-delete`'s `bulkDelete` or not, and `POST /v1/pets:batchGet` is `createBatchGet`); a path-named operation that would share an `operationId`'s name on its path takes the name it has without the action rule, numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), numbered on the last when every one is held (`createCreate2`) or is its own name (`update2` for `PUT /{id}/update`, whose `PUT /{id}` is `update`), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action yields its name when an operation of its method would have it on the path without the action's verb (`POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`), whether or not that operation is declared, so declaring it never renames the action, and when it shares its name with a path-named operation of another shape: it takes back the collection it dropped (`POST /pets/create` is `createPets` and `PUT /pets/update` is `updatePets`, while `create` and `update` stay with `POST /pets` and `PUT /pets/{id}`) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural, except that a singleton beside another path-named singleton that writes the parent otherwise keeps the parent as written, its name carrying the parent singular and its qualifiers reading the path without it, so that the plural is all that tells the two apart (`GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, while beside `operationId: getPetStatus` alone it is `getPetStatus2`); members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ```typescript interface Service { diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index e2eb1b60..f4842fc7 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -66,7 +66,7 @@ A literal after a `{param}` that does not look plural is a singleton of that ite An action is a literal that names what the operation does; it is the whole verb phrase of the name and the method's verb is left off. Three shapes are actions. A POST on a literal that follows a `{param}` and does not end in `s`: the segment is the verb and the singular parent is appended (`adoptPet`), even when the parent is the first segment, so two actions with the same name on different resources do not collide — unless the verb already ends with the parent's words (`POST /companies/verification/{id}/restart-id-verification` → `restartIdVerification`); a POST on a singleton that does not end in `s` (`POST /users/{id}/avatar`) is therefore misread as the action `avatarUser` — give it an `operationId`. For any method, a literal whose words include the method's own verb, which the name would otherwise repeat: `DELETE /pets/bulk-delete` → `bulkDelete`, `GET /store/pets/list-all` → `listAllPets`, `GET /pets:batchGet` → `batchGet`, `PUT /users/{id}/update-password` → `updatePasswordUser`; `list` and `check` count only as the first word, since as a later word they name a thing, so `GET /pets/{id}/wish-list` is the singleton `getPetWishList` and `GET /pets/allow-list` the collection `listAllowList`. And, for a POST only, a literal with no `{param}` before it whose last word is any CRUD verb (`get`, `list`, `create`, `update`, `delete`, `check`): `POST /pets/health-check` → `healthCheck`; the last word only, so `POST /pets/delete-candidates` stays `createDeleteCandidates`, and any other literal with no `{param}` before it is a collection (`POST /pets/search` → `createSearch`). A literal is one verb's action, whichever methods the document declares on its path: the verb it carries — the first method's in `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE` order when it carries several — and POST's when it carries none. A literal carrying another method's verb is therefore no action, after a `{param}` a singleton (`GET /users/{id}/last-update` → `getUserLastUpdate`, `POST /users/{id}/update-password` → `createUserUpdatePassword`), before none a collection (`GET /pets/update` → `listUpdate`, `GET /pets/last-update` → `listLastUpdate`, `POST /pets/bulk-delete` → `createBulkDelete`, `POST /users/bulk-update` → `createBulkUpdate`, `POST /v1/pets:batchGet` → `createBatchGet`), whether or not the method whose verb it carries is declared, so declaring that method takes no name from an operation already there. An action after a `{param}` takes the singular parent as its resource; one with no `{param}` before it takes the collection it sits under, dropped when that is the first segment: `DELETE /pets/bulk-delete` in `Pets` is `bulkDelete`, `DELETE /store/pets/bulk-delete` is `bulkDeletePets`. API root and version segments (`api`, `rest`, `v1`, `2.0`) are never the parent, so `POST /api/v1/{companyId}/sendDocument` is the bare `sendDocument`. A custom method in the Google AIP-136 style — the verb written after a colon on the segment it acts on — is read as that segment followed by the verb wherever a path is read for a name (the resource, the parent of an action or a singleton, the `By` qualifier, the inline model prefix), so `POST /v1/pets/{petId}:adopt` is `adoptPet` exactly as `/v1/pets/{petId}/adopt` is, never `createByPetIdAdopt`, `GET /v1/pets/{petId}:status` is `getPetStatus`, `DELETE /v1/pets:bulkDelete` is `bulkDelete`, and `POST /pets:search` is read as `/pets/search` and stays a `create`. Only a segment's one colon outside a parameter's braces starts a custom method. A colon inside braces is the parameter's wherever they sit, so `GET /v1/files/file-{file_path:path}` reads one segment and is `listFileFilePathPath`, while `POST /v1/files/file-{file_path:path}:download` is `createDownload`; and AIP-136 appends a single verb, so a segment with several colons outside braces is one literal, even when a parameter opens it: `GET /things/urn:pet:1` is `listUrnPet1`, and `GET /v1/pets/{petId}:adopt:now` is `listPetIdAdoptNow`, so `GET /v1/pets/{petId}` beside it stays `get`. The verb never names a service: `/v1/pets:batchGet` lands in `Pets` like `/v1/pets`, and `/v1/{name}:cancel` stays in `V1` beside `/v1/{name}`, where the slash-written `/v1/{name}/cancel` names `Cancel`. The operation's `path` is kept as written. -Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2` beside `operationId: getPetStatus`). The exception is a singleton beside another fallback singleton that writes the parent otherwise. A singleton's name carries its parent singular, and its qualifiers read the path without the parent, so the parent's plural is all that tells the two apart, and the one that writes it plural keeps it as written: `GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, as the items `GET /shop/pets/{id}/toys/{toyId}` and `GET /shop/pet/{id}/toys/{toyId}` are `getToyPets` and `getToyPet`. That name is numbered when another operation has it, and singletons that write the parent alike share it as fallbacks share a plural (`/shop/Pets/{id}/status` and `/shop/pets/{id}/status` beside `/shop/pet/{id}/status` are `getPetsStatus`, `getPetsStatus2` and `getPetStatus`). An action yields first when an operation of its method would have its name on the path without the action's verb — `POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`, and `GET /pets/{id}`, one item, is `get` — whether or not that operation is declared, so declaring it never renames the action: `create` stays with `POST /pets`, and `POST /pets/create` takes back the collection it dropped, `createPets` (`updatePets` for `PUT /pets/update`, `listAllPets` for `GET /pets/list-all`, `getPets` for `GET /pets/get`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`; `createCreate` for `POST /store/pets/create`). An action that shares its name with a path-named CRUD or singleton operation any other way yields to it the same way. Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`), and since a literal is one verb's action (see above), two methods never share an action name: `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` is `createBulkDelete`, beside each other or not, and `PUT /users/{id}/update-password` is `updatePasswordUser` and a POST on it the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` when the GET on `/pets/create` carries `operationId: createPets`), numbered on the last when every one is held (`createCreate2` when another operation carries `operationId: createCreate` as well), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +Fallback names are unique within a service. An `operationId` is never renamed; a colliding fallback is qualified by its path, one qualifier at a time on whatever still collides — the resource noun; the segments between the service and the resource; `By` for each parameter the colliding paths do not all share (`/api/notes/{noteId}` beside `/api/{teamId}/notes/{noteId}` is `get` and `getByTeamId`, the shared `noteId` adding nothing); the API root words a path skipped beyond those the colliding rooted paths all skipped (`/api/notes` beside `/api/v1/notes` is `list` and `listV1`, a root-less path taking no part); the resource noun the plain name left off because it names the service, beside an `operationId` (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` is `listNotes`), singular for an item (`/api/notes/{noteId}` beside `operationId: get` is `getNote`) and as written when another operation already has that name (`getNotes` beside `operationId: getNote` as well); the plural an item operation had before singularisation — and numbered as a last resort, the plain name staying with an `operationId`, then with the path that skipped the shorter root (a root-less `/health` keeps `list` over `/api/health`, a tagged `/notes` over `/api/v1/notes`), then with the lexically first path. A deeper segment is singular when a `{param}` follows it: `GET /store/cats/{id}/toys/{toyId}` beside `GET /store/dogs/{id}/toys/{toyId}` is `getToyCat` and `getToyDog` — unless that spelling would give a path the name of an `operationId` or of another path that the spelling as written sets apart, in which case the segments are taken as written, the plural being what tells them apart: `GET /store/pets/{id}/toys/{toyId}` beside `GET /store/pet/{id}/toys/{toyId}` is `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` beside `operationId: getToyCat` is `getToyCats`; and as written alike when another colliding path writes the same words, so one spelling in the paths is one qualifier in the names. Every qualifier applies through one rule. It is added only when it tells the colliding paths apart: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`, and `/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one: `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A number is used only when no qualifier is left, on the spelling such operations share: `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes` are `listV1` and `listV1No2`. A name that already ends in a digit takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a name ending in a letter takes the bare number (`list2`, `list3`, …). When nothing in a singular fallback's path tells it from the name it collides with — an `operationId` in the same service (`GET /store/pets/{id}` next to `operationId: getPet` on `/store/featured/{id}`), or another fallback whose path differs only by the plural (`GET /store/pet/{id}`) — the fallback keeps the plural it had before singularisation, `getPets`, if no operation outside its group has that name, rather than `getPet2`. Fallbacks that would all keep the same plural are numbered instead; fallbacks that share a plural some other fallback does not keep it and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); an action or a singleton, which has no plural to keep, is numbered too (`getPetStatus2` beside `operationId: getPetStatus`). The exception is a singleton beside another fallback singleton that writes the parent otherwise. A singleton's name carries its parent singular, and its qualifiers read the path without the parent, so the parent's plural is all that tells the two apart, and the one that writes it plural keeps it as written: `GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, as the items `GET /shop/pets/{id}/toys/{toyId}` and `GET /shop/pet/{id}/toys/{toyId}` are `getToyPets` and `getToyPet`. That name is numbered when another operation has it, and singletons that write the parent alike share it as fallbacks share a plural (`/shop/Pets/{id}/status` and `/shop/pets/{id}/status` beside `/shop/pet/{id}/status` are `getPetsStatus`, `getPetsStatus2` and `getPetStatus`). An action yields first when an operation of its method would have its name on the path without the action's verb — `POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`, and `GET /pets/{id}`, one item, is `get` — whether or not that operation is declared, so declaring it never renames the action: `create` stays with `POST /pets`, and `POST /pets/create` takes back the collection it dropped, `createPets` (`updatePets` for `PUT /pets/update`, `listAllPets` for `GET /pets/list-all`, `getPets` for `GET /pets/get`), or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`; `createCreate` for `POST /store/pets/create`). An action that shares its name with a path-named CRUD or singleton operation any other way yields to it the same way. Two operations on one path share a name only when both are path-named and their methods take the same verb (PUT and PATCH both `update`), and since a literal is one verb's action (see above), two methods never share an action name: `DELETE /pets/bulk-delete` is `bulkDelete` and `POST /pets/bulk-delete` is `createBulkDelete`, beside each other or not, and `PUT /users/{id}/update-password` is `updatePasswordUser` and a POST on it the singleton `createUserUpdatePassword`. A path-named operation that would share an `operationId`'s name on its path is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` the document reuses on one path is numbered with a warning, as it is across paths. A name any pass gives an operation is checked against every name the service holds: an action takes the first of its names no operation holds (`createCreate` for `POST /pets/create` when the GET on `/pets/create` carries `operationId: createPets`), numbered on the last when every one is held (`createCreate2` when another operation carries `operationId: createCreate` as well) or is its own name, as on a bare parameter (`update2` for `PUT /{id}/update`, whose `PUT /{id}` is `update` in the same service), and once every pass has run, a path-named operation left sharing a name on its path with an `operationId` or with another verb is numbered (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`, and one on `/store/top10/{id}` beside `operationId: updateTop10` is `updateTop10No2`) — a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: list` on `/api/{teamId}/notes` becomes `listNotes`, and `list2`, never `listNotes2`, when `POST /api/notes` carries `operationId: listNotes`. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ## Stage 2: Emit (`src/engine/`) diff --git a/src/parser/operations.ts b/src/parser/operations.ts index c0e9e15d..11cb50d5 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -2092,9 +2092,11 @@ function demoteSamePathNames( * shape, like a demoted action (demoteSamePathNames). A name another * operation holds is skipped, and with both held it is numbered on the * latter (`createCreate2` when operationIds hold `createPets` and - * `createCreate`). An action beside an operationId of its name is left to - * the numeric pass (`adoptPet2`), and two actions of one name to the - * qualifiers (`adoptPetRescue`). Actions come in name order, then path + * `createCreate`), as it is when both are its own name (`update2` for + * `PUT /{id}/update`, which has no collection to take back and whose + * `PUT /{id}` is `update`). An action beside an operationId of its name is + * left to the numeric pass (`adoptPet2`), and two actions of one name to + * the qualifiers (`adoptPetRescue`). Actions come in name order, then path * order, and the renames depend only on each operation's own path, so the * result is deterministic. Returns the operations that fell back to their * non-action name. @@ -2150,9 +2152,12 @@ function yieldActionNames( * (`GET /pets/all`) for `GET /pets/list-all`, `createPet` * (`POST /store/pets/{id}`) for `POST /store/pets/{id}/create`. `null` when * the segment does not start with one of the method's verbs (methodVerbs): - * `bulk-delete`, `adopt`, `force-update`, and for an action that sits under - * no collection, with no literal before its segment past the API root - * (`POST /create`, `PUT /{id}/update`). GET's item verb reads one item, so + * `bulk-delete`, `adopt`, `force-update`, and when the segment is the one + * its service is named from (serviceSegmentIndex), so the path without it + * sits in another service: `POST /create`, and `PUT /v1/{id}/update` + * under a `/v1` root, whose `PUT /v1/{id}` is in `V1`. A bare parameter + * before the segment is no other service: `PUT /{id}/update` and + * `PUT /{id}` are both in `Default`. GET's item verb reads one item, so * `get` is what GET gives an item of what is left: `get` (`GET /pets/{id}`) * for `GET /pets/get`. An action whose name this is shares it with an * operation the document may declare, and yields it whether or not it @@ -2163,7 +2168,7 @@ function verblessName(method: HttpMethod, path: string, apiRootLength: number): const [verb, ...rest] = splitWords(segments[segments.length - 1] ?? ''); const word = verb?.toLowerCase(); if (word === undefined || !methodVerbs(method).includes(word)) return null; - if (!segments.slice(apiRootLength, -1).some((segment) => !segment.startsWith('{'))) return null; + if (serviceSegmentIndex(path, apiRootLength) === segments.length - 1) return null; const twin = rest.length > 0 ? [...segments.slice(0, -1), rest.join('-')] : segments.slice(0, -1); if (word !== METHOD_VERBS[method].collection && !twin[twin.length - 1].startsWith('{')) twin.push('{id}'); return fallbackOperationName(method, '/' + twin.join('/'), apiRootLength); diff --git a/test/parser/fallback-action-segments.test.ts b/test/parser/fallback-action-segments.test.ts index 304295ae..f8262f69 100644 --- a/test/parser/fallback-action-segments.test.ts +++ b/test/parser/fallback-action-segments.test.ts @@ -962,6 +962,23 @@ describe('an operation added beside an action takes no name an operation already changes: ['operation-added create: additive'], }); expect(names(held)).toContain('Pets.createCreate2 POST /pets/create'); + // On a bare parameter both names it could take back are its own, and + // `PUT /{id}` lands in its service: it is numbered alone too. + await expectAdditive( + { '/{id}/update': { put: itemOp } }, + { '/{id}': { put: itemOp } }, + { before: 'update2', more: 'update' }, + ); + // Untagged, a literal that names its service shares no service with the + // path without it (`V1` for `/v1/{id}`), so it keeps its name. + expect(names({ '/{id}/update': { put: item('id') } })).toEqual([ + 'Default.update2 PUT /{id}/update', + ]); + expect(names({ '/v1/{id}/update': { put: item('id') }, '/v1/{id}': { put: item('id') } })).toEqual([ + 'V1.update PUT /v1/{id}', + 'Update.update PUT /v1/{id}/update', + ]); + expect(names({ '/create': { post: ok } })).toEqual(['Create.create POST /create']); }); it('gives PUT and PATCH on one path the one name their action yields to', () => { From 7b12f97b1ecc6703a613cdbf844637ace43b06b0 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 09:40:52 -0400 Subject: [PATCH 39/59] fix(parser)!: give each operation reusing an operationId its own inline models An inline body was named from the operationId alone, so two Operation Objects that reuse one operationId (which OpenAPI forbids, but the parser accepts) both minted `CreateNoteRequest`; the merge kept one definition and the other operation was typed with fields it does not take. The operation was renamed (`createNote2`) only after its models were named. Every operation's service and name are now settled before any operation is built (`planOperations`, running `splitTaggedServices` and `disambiguateOperationNames` on stand-ins), so the models can follow the name the operation ends up with (`resolveReusedOperationIds`): - an operation the reuse renames names its models after its new name (`createNote2` sends a `CreateNote2Request`); - of the operations that keep the name, one per service, the first by API root length, path and method keeps the operationId's models, and every other one names them from its path as an operation without an operationId does (`NotesPostRequest`), as does a renamed operation whose new name another operationId already names models from. Two paths that `$ref` one Path Item still share its one Operation Object's models. Specs that do not reuse an operationId parse to the same IR; the rename warnings are now printed before the per-operation ones. Closes #416 --- docs/architecture/ir-types.md | 4 +- src/parser/operations.ts | 275 +++++++++++++++---- test/parser/inline-model-naming.test.ts | 262 ++++++++++++++++++ test/parser/numbered-operation-names.test.ts | 35 +++ 4 files changed, 518 insertions(+), 58 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 74084160..9b7b1390 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -352,7 +352,7 @@ An operation's `callbacks` are not read; a `paths` operation that declares any i An operation carries its OpenAPI `summary` and `description` in two fields, each read from the Operation Object and, when the operation declares none with content, from the Path Item it sits on (the `paths` entry itself, or the `components/pathItems` item a `$ref` entry leads to; see [Path Item references](#path-item-references)) — OpenAPI defines a Path Item's `summary` and `description` as applying to every operation on it. The two fall back one by one: an operation with a `summary` of its own and no `description` shows its own `summary` over the Path Item's `description`, and an operation with both shows only its own. `Operation.description` is the combined doc text of the two resolved texts: `summary + "\n\n" + description` when both have content, or whichever one has content (a summary identical to the description is not repeated). A `summary` or `description` that is not a string, or is empty or whitespace-only, is treated as absent at either level — `summary: "List pets"` with `description: ""` yields `description: "List pets"`, and `summary: ""` on an operation takes the Path Item's — and text that has content is kept verbatim, surrounding whitespace included. An emitter that prints `description` as the doc comment therefore sees the same text whether the document put the text on the operation or on its Path Item, and whether or not it used a summary. `Operation.summary` is the resolved `summary` alone and is present only when one of the two levels has content, so an emitter that wants a one-line label (and the description without it) reads `summary` rather than guessing whether the first paragraph of `description` is one. When a doc URL base is configured, relative links are expanded in `summary` exactly as in `description`. -An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a literal is one verb's action whichever methods the document declares on its path — the verb it carries, the first method's in HTTP method order when it carries several, else POST's — so a method of another verb is never read as an action on it and declaring that method takes no name from an operation already there (`POST /pets/bulk-delete` is `createBulkDelete`, beside `DELETE /pets/bulk-delete`'s `bulkDelete` or not, and `POST /v1/pets:batchGet` is `createBatchGet`); a path-named operation that would share an `operationId`'s name on its path takes the name it has without the action rule, numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), numbered on the last when every one is held (`createCreate2`) or is its own name (`update2` for `PUT /{id}/update`, whose `PUT /{id}` is `update`), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action yields its name when an operation of its method would have it on the path without the action's verb (`POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`), whether or not that operation is declared, so declaring it never renames the action, and when it shares its name with a path-named operation of another shape: it takes back the collection it dropped (`POST /pets/create` is `createPets` and `PUT /pets/update` is `updatePets`, while `create` and `update` stay with `POST /pets` and `PUT /pets/{id}`) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural, except that a singleton beside another path-named singleton that writes the parent otherwise keeps the parent as written, its name carrying the parent singular and its qualifiers reading the path without it, so that the plural is all that tells the two apart (`GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, while beside `operationId: getPetStatus` alone it is `getPetStatus2`); members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose. Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). +An operation without an `operationId` is named from its method and path (`GET /orgs/{id}/teams` → `listTeams`), and that name is only as unique as the path words it was built from. Within a service, a name taken from an `operationId` is kept as written, and path-derived names that collide with each other or with it are qualified until every path has a name of its own; two operations on one path share a name only when both are path-named and their methods take the same CRUD verb (PUT and PATCH both `update`); a literal is one verb's action whichever methods the document declares on its path — the verb it carries, the first method's in HTTP method order when it carries several, else POST's — so a method of another verb is never read as an action on it and declaring that method takes no name from an operation already there (`POST /pets/bulk-delete` is `createBulkDelete`, beside `DELETE /pets/bulk-delete`'s `bulkDelete` or not, and `POST /v1/pets:batchGet` is `createBatchGet`); a path-named operation that would share an `operationId`'s name on its path takes the name it has without the action rule, numbered when that name is held on the path too (a path-named PATCH beside `operationId: updatePet` on the PUT is `updatePet2`), and an `operationId` reused on one path is numbered with a warning; a name any pass produces is checked against every name the service holds — an action takes the first of its names no operation holds (`createCreate` when `createPets` is an `operationId` on its path), numbered on the last when every one is held (`createCreate2`) or is its own name (`update2` for `PUT /{id}/update`, whose `PUT /{id}` is `update`), and after all passes a path-named operation left sharing a name on its path with an `operationId` or another verb is numbered (`updatePet2` for a path-named PATCH beside `operationId: updatePet` on the PUT; a name another operation holds is never what a qualifier gives, so `GET /api/notes` beside `operationId: listNotes` on its POST is `list2`, never `listNotes2`). Qualifiers are tried in order, each on whatever still collides, every one through one rule. A qualifier is added only when it tells the colliding paths apart, and a context two paths write alike is spelled alike for both: `Teams` sits in both `/org/teams/members` and `/org/teams/{teamId}/members`, so they are `listMembers` and `listMembersByTeamId`, never `listMembersTeams` and `listMembersTeam`. A qualified name another operation in the service already has is skipped for that operation and the next qualifier is tried, so a number never lands on a name another operation already had: `/org/members/archived` beside `/org/teams/archived` and `/org/archived_members` is `listArchived`, `listArchivedTeams` and `listArchivedMembers`, never `listArchivedMembers2`. A name held only by path-named operations naming the same noun is joined instead, when a later qualifier would tell them apart by a name no other operation has — by a parameter, a segment between the service and the resource, a root word, or the plural only the nested item can take back: `/notes` beside `operationId: list` restores `Notes` and meets the nested collection `/notes/{noteId}/notes` at `listNotes`, and the parameter then tells them apart, `listNotes` and `listNotesByNoteId` (`getNote` and `getNoteByChildId` for their items). Otherwise the name counts as taken, since joined the two would only be numbered: `/notes/notes` has nothing `/notes` lacks, so beside it `/notes` is `list2` and `/notes/notes` keeps `listNotes` — and so does `/notes/{noteId}/notes` when `operationId: listNotesByNoteId` holds the name its parameter would give it. Operations a qualifier does not tell from each other take one spelling and stay together for the next one, numbered on it when nothing after tells them apart (`listV1` and `listV1No2` for `/api/v1/{userId}/notes` and `/api/v1/{user_id}/notes` beside `/api/{userId}/notes`): `/orgs/{orgId}/groups/members` and `/orgs/{orgId}/groups/{groupId}/members` beside `/orgs/teams/members` are `listMembersGroups` and `listMembersGroupsByGroupId`. A qualifier never puts the same word twice in a row into a name (word for word on the camelCase boundary). The words it starts with are left off where they repeat the words the name ends with, each compared by its singular form, so `listNotes` never becomes `listNotesNotes`, nor `listNotesNote` for `/orgs/notes/{id}/notes`; inside the qualifier only the same word is a repeat, so `/org/analytics/analytics/teams` next to `/org/teams` yields `listTeamsAnalytics`, while a plural segment and its singular are two words: `/x/notes/{id}/note/tags` next to `/x/notes/{id}/tags` yields `listTagsNotesNote` and `listTagsNotes`. Every qualifier skips the API root and the service segment just as the name itself does, so `DELETE /api/v1/notes/all` in `Notes` is `deleteAll`, never `deleteAllV1Notes`. Before the qualifiers, an action yields its name when an operation of its method would have it on the path without the action's verb (`POST /pets` is `create`, `PUT /pets` is `update`, `GET /pets/all` is `listAll`), whether or not that operation is declared, so declaring it never renames the action, and when it shares its name with a path-named operation of another shape: it takes back the collection it dropped (`POST /pets/create` is `createPets` and `PUT /pets/update` is `updatePets`, while `create` and `update` stay with `POST /pets` and `PUT /pets/{id}`) or, when that adds nothing, its name without the action rule (`createPetCreate` for `POST /store/pets/{id}/create`, as `POST /store/pets/{id}` is `createPet`). The order: first the resource noun — for the service's own path the service noun as well, when a path-named operation already carries it under the name it would make and a later qualifier can tell the two apart (`/notes` beside `operationId: list` meets `/notes/{noteId}/notes` at `listNotes`, told apart next as `listNotes` and `listNotesByNoteId`); then the path segments between the service and the resource, each singular when a path parameter follows it, as the resource of an item operation is (`GET /users/{id}/legacy/factors` next to `GET /users/{id}/factors` becomes `listFactorsLegacy`, and `GET /store/cats/{id}/toys/{toyId}` next to `GET /store/dogs/{id}/toys/{toyId}` becomes `getToyCat` and `getToyDog`), unless the singular spelling would give a path the name of an operation outside its group or of another path that the spelling as written sets apart, which keeps the segments as written, the plural being what tells them apart (`GET /store/pets/{id}/toys/{toyId}` next to `GET /store/pet/{id}/toys/{toyId}` becomes `getToyPets` and `getToyPet`, and `/store/cats/{id}/toys/{toyId}` next to `operationId: getToyCat` becomes `getToyCats`), and as written alike when another colliding path writes the same words, so a segment singular in one path and plural in another from one spelling is one qualifier, which tells nothing apart and is left to the parameters (`/store/cats/toys/{toyId}` beside `/store/cats/{id}/toys/{toyId}` is `getToy` and `getToyById`, as `/org/teams/members` beside `/org/teams/{teamId}/members` is `listMembers` and `listMembersByTeamId`); then `By` for each path parameter the colliding paths do not all share, in path order, when those are what tell the paths apart (`GET /v1/notes` keeps `list` while `GET /v1/{teamId}/notes` becomes `listByTeamId`, `GET /users/teams` keeps `listTeams` while `GET /users/{id}/teams` becomes `listTeamsById`, `/orgs/{orgId}/{teamId}/members` beside `/orgs/members` gains `ByOrgIdByTeamId`, and `GET /api/notes/{noteId}` keeps `get` while `GET /api/{teamId}/notes/{noteId}` becomes `getByTeamId`, the shared `noteId` adding nothing, and a parameter that carries a custom method contributes the parameter alone: `GET /v1/pets/{petId}:history` beside `GET /v1/{orgId}/pets/{petId}:history` becomes `getPetHistoryByOrgId`); then the API root words a path skipped beyond those the colliding rooted paths all skipped, when they sit under different lengths of the document root — the one place a root word belongs in a name: `GET /api/notes` keeps `list` while `GET /api/v1/notes` becomes `listV1`, and a root-less path takes no part; then the resource noun the plain name left off because it names the service, when the colliding paths do not all drop the same one — this is what tells a path-named operation from an `operationId` it collides with, since that name is never rewritten and the parameters may have nothing to add: `GET /api/notes` beside `operationId: list` on `GET /api/{teamId}/notes` is `listNotes`, and `list` stays with the `operationId`, while the item `GET /api/notes/{noteId}` beside `operationId: get` is `getNote`, the noun singular as an item operation's resource is; then, for an item still colliding because an operation outside its group has that singular name, the noun as written, as for the segments between the service and the resource: with `operationId: getNote` on `POST /api/notes/pinned` as well, `GET /api/notes/{noteId}` is `getNotes`; then, for an item operation whose singular name none of those could tell apart, the plural it had before singularisation (`GET /store/pets/{id}` is `getPet`, and so is `operationId: getPet` on `/store/featured/{id}` or path-named `GET /store/pet/{id}`, so it becomes `getPets`) when no operation outside its group has that name — an `operationId`-named operation, an action, a singleton of one item (`GET /store/pets/{id}/status` is `getPetStatus`) and a name a qualifier has extended have no plural, except that a singleton beside another path-named singleton that writes the parent otherwise keeps the parent as written, its name carrying the parent singular and its qualifiers reading the path without it, so that the plural is all that tells the two apart (`GET /shop/pets/{id}/status` beside `GET /shop/pet/{id}/status` is `getPetsStatus` and `getPetStatus`, while beside `operationId: getPetStatus` alone it is `getPetStatus2`); members that would all take the same plural are numbered instead, and members that share a plural some other member does not take it together and are numbered (`/store/Pets/{id}` and `/store/pets/{id}` beside `/store/pet/{id}` or `operationId: getPet` are `getPets`, `getPets2` and `getPet`); and finally, when no qualifier is left, a numeric suffix (`list2`, `list3`, …) for paths that flatten to the same words (`/v1/{userId}/notes` and `/v1/{user_id}/notes`, or `/notes` and `/api/v1/notes`, which differ only by the root) or whose every qualified name is taken (`/api/notes` beside `operationId: list` on `/api/{teamId}/notes` and `operationId: listNotes` on `/api/{orgId}/notes` is `list2`, never `listNotes2`). A name that already ends in a digit — the version a root word gave it, a path word such as `top10`, or an `operationId` such as `getV2` — takes the number behind `No`, so that it cannot be read as part of the number the name ends with: `GET /api/v1` beside `GET /api/v1/` is `listV1` and `listV1No2`, never `listV12`, which would read as version 12, `GET /api/v1/{userId}/notes` beside `GET /api/v1/{user_id}/notes` under a versionless `GET /api/{userId}/notes` is `listV1` and `listV1No2`, and `GET /pets/top10` beside `GET /pets/top10/` is `listTop10` and `listTop10No2`; a number on one path is spelled the same way, so a path-named PATCH on `/store/top10/{id}` beside `operationId: updateTop10` on the PUT is `updateTop10No2`. For the plain name, an `operationId`-named operation outranks a path-named one, then the path that skipped the shorter root (`/health` or a tagged `/notes` keeps `list` over `/api/health` or `/api/v1/notes`, exactly as without the root), and otherwise the lexically first path wins, so the outcome does not depend on document order. The suffix is silent for a path-derived name, like a hashed inline model name; when the document itself reuses an `operationId` within a service, the later path is suffixed with a `[oagen] Warning:`, since that changes a name the author chose, and its inline models follow the new name (`createNote2` sends a `CreateNote2Request`; see Inline model naming). Two colliding actions are qualified by the path of their parent, never by repeating the verb (`adoptPet` and `adoptPetRescue`, not `adoptPetAdopt`), and two colliding singletons by the path around the parent, never by repeating it (`getPetStatus` and `getPetStatusRescue`, not `getPetStatusPet`). ```typescript interface Service { @@ -749,7 +749,7 @@ Two shapes are not array components. A `type: 'array'` with no `items` still bec Models come from `components/schemas` (named as written, after `cleanSchemaName` and any `schemaNameTransform`; an enum or array component yields none — see Enum and Array components) and from inline object schemas in operations, which the parser materializes as models so emitters have something to reference. Two component keys that resolve to one name (`Note` and `note`) keep the definition with more fields, the first on a tie; when the two differ in fields, types or limits (`modelFingerprint`), the parser prints `[oagen] Warning: Component schemas "Note" and "note" both resolve to "Note" with different fields or field types` before dropping the other, and folds identical definitions silently. An inline request body or response is named from its operation: -- **With an `operationId`:** `PascalCase(operationId)` + `Request` / `Response`, passed through `cleanSchemaName`. `createNote` → `CreateNoteRequest` / `CreateNoteResponse`. +- **With an `operationId`:** `PascalCase(operationId)` + `Request` / `Response`, passed through `cleanSchemaName`. `createNote` → `CreateNoteRequest` / `CreateNoteResponse`. An `operationId` declared on more than one Operation Object, which OpenAPI forbids, names the models of one operation only, so no two operations share a body. An operation the reuse renames within its service (see Service & Operation) names its models after its new name: `createNote2` on `POST /notes/drafts` beside `createNote` on `POST /notes` sends a `CreateNote2Request`, and its response, error bodies and nested models follow (`CreateNote2Response`, `CreateNote2Error404`, `CreateNote2ResponseOwner`). Of the operations that keep the name — one per service — the first by API root length, then path, then HTTP method keeps the `operationId`'s models, and every other one names its models from its path, as an operation without an `operationId` does: `createNote` on `POST /archived-notes` and on `POST /notes`, in two services, send a `CreateNoteRequest` and a `NotesPostRequest`. So does a renamed operation whose new name another `operationId` already names models from (`createNote2` in `Notes` beside `operationId: createNote2` in `Tags` sends a `NotesDraftsPostRequest`). The outcome does not depend on document order. Two paths that `$ref` one Path Item read one Operation Object and one body, so they share its models. - **Without an `operationId`:** from the full path and method. Each non-parameter segment is PascalCased in order, each `{param}` segment becomes `By` + `PascalCase(param)`, a custom method written after a colon is read as its segment followed by the verb (`POST /pets/{id}:adopt` → `PetsByIdAdoptPostRequest`, the prefix of `/pets/{id}/adopt`), a segment with several colons outside braces is one literal even when a parameter opens it (`POST /v1/pets/{id}:adopt:now` → `V1PetsIdAdoptNowPostRequest`), then the method and `Request` / `Response` are appended, and the whole name is normalized with `toPascalCase` so an operation's body and response share a prefix (`POST /a/b` → `AbPostRequest` / `AbPostResponse`). `POST /notes` → `NotesPostRequest` / `NotesPostResponse`; `POST /notes/{id}/pin` → `NotesByIdPinPostResponse`; `GET /users/{user_id}/sessions` → `UsersByUserIdSessionsGetResponse`. This tells the common path shapes apart (list vs. item, sibling and nested sub-resources) but is not unique by construction: `/foo-bar` and `/foo/bar`, `/notes/by-id` and `/notes/{id}`, `/notes` and `/notes/`, or `/pets/{id}:adopt` and `/pets/{id}/adopt` flatten to the same words. Names already taken are reserved first: every component schema name and, for every operation with an `operationId`, the stem all of its models start with (`PascalCase(operationId)` after `cleanSchemaName`). A path-based prefix is treated as colliding when any reserved name starts with it, because one prefix is the stem of several emitted names (`…Request`, `…Response`, a list's `…Item`, nested inline models such as `…ResponseOwner`, envelope companions such as `…ListMetadata`). Names derived from a path-based prefix use it verbatim — body `oneOf` variants included (`NotesPostRequest`, `NotesPostRequest2`, `EmailNotesPostRequest`) — and are never re-cleaned, so no later step can move them out from under the reservation; as a second line of defence the check also runs on the cleaned spellings of the prefix (`cleanSchemaName`, the project's `schemaNameTransform`, `ListItem`-marker stripping), so an unnamed `GET /notes` also yields to a component `NoteGetItem`. Names that embed the prefix behind a const value are covered as well: a body `oneOf` variant pinning `kind: email` is `EmailTagsPostRequest` and a nested `oneOf`/`anyOf` variant is ``, so every string `const` (or single-value `enum`) found in the operation's inline body and 2xx response variants, top level and nested, is prepended to every spelling before the check, and a hashed prefix lands inside those names too (`EmailTags151Dd5PostRequest`). Names that do not embed the prefix at all — a response `oneOf` variant named from its bare const (`Email`) or a `title`-mapped variant — are outside what a disambiguator can separate and are left as they are. This is deliberately a superset: a component `AuditEventsGetter`, or an operationId `auditEventsGetSummary`, also pushes an unnamed `GET /audit/events` onto a hashed name although no emitted name would coincide; determinism and never sharing a name wrongly matter more than the rare extra hash. The reservation is unconditional, so an inline body is never merged into a same-named component even when their shapes agree. When two distinct paths with the same method would collide, the path spelled most plainly keeps the plain name (no trailing slash, `{id}` rather than a literal `by-id`; precisely, the lowest `[trailing slash, custom methods written after a colon, literal segments with a character outside a-z0-9]`, so `/pets/{id}/adopt` keeps the plain name over `/pets/{id}:adopt` and `/pets/{id}/batchAdopt` over `/pets/{id}:batchAdopt`, while a parameter segment is never irregular and `/tags/{tag}.json` beside `/tags/{tag}/json` ties), and every other path in the group gets a six-character disambiguator derived from its raw path (the start of its SHA-1, inserted before the method): `POST /foo/bar` next to `POST /foo-bar` → `FooBarPostResponse` and `FooBar546F12PostResponse`; `GET /notes/{id}` next to `GET /notes/by-id` → `NotesByIdGetResponse` and `NotesByIdBa41B5GetResponse`. When two paths tie for plainest (`/foo-bar` and `/foo_bar`), or the path-based name matches a reserved one (an unnamed `POST /audit/events` next to `operationId: auditEventsPost` or next to `components/schemas/AuditEventsPostRequest`), nobody keeps the plain name and every path in the group is disambiguated. The result depends only on the document's paths, `operationId`s and component names and never on their order; `operationId`-derived and component names never change, and paths that do not collide keep the plain name. - **Shared through `components/responses`:** a response written as `{ $ref: '#/components/responses/PetFound' }` is resolved before it is read (chains are followed; a circular chain fails the parse; a `$ref` that points outside `components/responses` — into another section such as `#/components/schemas/Pet`, or a bundler-written pointer such as `#/paths/~1pets/get/responses/200` — is warned about (`Unresolved response $ref "#/components/schemas/Pet" in 404 response of getPet: no matching entry under components/responses — reading the response as written, without a body or headers`) and read as written, with no body or headers, while a `$ref` into `components/responses` that names no entry never reaches the parser: the bundler rejects it, and `parseSpec` throws `Failed to parse spec: Can't resolve $ref`; a sibling `description` beside the `$ref` wins in an OAS 3.1 document, outermost first, and is ignored in an OAS 3.0 one). Its inline body is named after the component, not the operation — `PascalCase(componentName)` + `Response`, passed through `cleanSchemaName`, the suffix not doubled when the component is already named `…Response` (`PetFound` → `PetFoundResponse`, `ErrorResponse` → `ErrorResponse`) — so every operation that returns it shares one model instead of minting a `Response` copy each — whether it returns it at an explicit status code, as `default` or in a `1XX`–`5XX` range: a `default` and a `4XX` both written as `$ref: '#/components/responses/Problem'` are one `ProblemResponse`, shared with the `404` of another operation that points at it; a chain is named after the ref the operation wrote. Everything else the component declares that mints a model is named from the stem of that shared body name — `Response` less its `Response` (`Card`), or `CardResponse2` when the body stepped to it — too: an inline object under another media type is `Response` (`Card` with an `application/yaml` body → `CardApplicationYamlResponse`) and an object-shaped header is `
` (`Card` with `X-Meta` → `CardXMeta`), nested objects under either qualified by it. These names do not depend on the operation, its service, or the status code or fallback key (`404`, `302`, `default`, `4XX`) the operation mounts the component at, so every operation that returns the component shares one model of each, as it shares the body; a name a component schema or another site already owns with a shape it does not share steps by number (`CardXMeta2`, `CardApplicationYamlResponse2`), as the component's body steps to `CardResponse2`, never by the operation or the status code — those qualifiers belong to an inline response's sites — so a stepped name depends on the component alone too, and removing one of the operations that return the component leaves the others' names unchanged; an inline enum such a site carries — a header's, a secondary body's, the body's — steps by number the same way when the collectors promote it (`CardXMode2`), never by the status code or fallback label (see Response Headers and Response Content Types above). The component's stem is reserved like an `operationId`'s, so an unnamed `GET /owner` beside a component response `OwnerGet` takes a hashed name. The names a body mints are reserved under every reading an operation can give it — a list envelope unwrapped for an iterator (`NotePage: { data: { title }[], has_more }` → `NotePageItem`) and kept as the wire shape (`NotePageResponse`, `NotePageResponseData`) — since which reading an operation takes depends on its own query parameters and override, and two operations returning one component can differ (`GET /notes` keeps it, `GET /notes/recent?after` unwraps it): beside a schema `NotePageResponseData` or `NotePageItem` the stem steps to `NotePageResponse2` for every operation, so a kept body's item model is never renamed late and both readings move together. The component's headers resolve through `components/headers` as inline ones do. diff --git a/src/parser/operations.ts b/src/parser/operations.ts index 11cb50d5..2a3aa4d3 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -485,7 +485,21 @@ export function extractOperations( // request bodies, responses, headers and examples alike. const refSiblingDescriptions = siblingDocsMode(openapiVersion); - // Operations without an operationId name their inline models from the + // An API root the untagged paths sit under (`/api`, `/v1`) tells the + // services apart no better than nothing, so untagged operations skip it + // when their service and fallback name are taken from the path (see + // documentApiRoot). Tagged operations are named exactly as before. + const apiRoot = documentApiRoot(resolvedPaths); + // Every operation's service and name are settled before any operation is + // built (see planOperations), so the models of an operation whose + // operationId the document reuses can follow the name it ends up with, and + // no two such operations name their models alike (see + // resolveReusedOperationIds). + const plan = planOperations(resolvedPaths, apiRoot, operationIdTransform); + const modelOperationIds = resolveReusedOperationIds(resolvedPaths, plan); + + // Operations without an operationId — or left none by the reuse of their + // own (see resolveReusedOperationIds) — name their inline models from the // path, and two distinct paths — or a path and an operationId, component // schema or component response — can produce the same name, so the prefix // for every such operation is decided up front (see resolvePathModelBaseNames). @@ -504,27 +518,20 @@ export function extractOperations( componentModels, componentRequestBodies, componentResponseNaming, + modelOperationIds, ); - // An API root the untagged paths sit under (`/api`, `/v1`) tells the - // services apart no better than nothing, so untagged operations skip it - // when their service and fallback name are taken from the path (see - // documentApiRoot). Tagged operations are named exactly as before. - const apiRoot = documentApiRoot(resolvedPaths); - const serviceMap = new Map(); + /** Every operation built, by its plan key (`${method} ${path}`). */ + const operationsByKey = new Map(); const inlineModels: Model[] = []; const inlineModelBundles: InlineModelBundle[] = []; const envelopeCompanions: EnvelopeCompanion[] = []; /** The PascalCase operation name each companion was lifted under, to qualify it should a merge pass rename it. */ const companionQualifiers = new Map(); // Service name each tagged operation was grouped under, keyed by operation - // identity. The path-prefix split below moves operations between services, - // so this is how a split-off service is traced back to the tag it came from. + // identity. The path-prefix split moves operations between services, so + // this is how a split-off service is traced back to the tag it came from. const tagServiceByOperation = new Map(); - // Operations named from the method and path rather than an operationId. - // Only these may be renamed when names collide within a service (see - // disambiguateOperationNames); a name taken from an operationId is kept as is. - const pathNamed = new Set(); // Header, secondary-body, fallback-body, error-body and parameter schemas // are typed only after this loop, once every primary model is known // (`resolveDeferredInlineTypes`): their names must not collide with a @@ -583,7 +590,8 @@ export function extractOperations( const tag = serviceTag(op.tags); const apiRootLength = tag ? 0 : apiRootLengthOf(path, apiRoot); const serviceName = inferServiceName(path, tag, apiRootLength); - const pathModelBaseName = pathModelBaseNames.get(`${method} ${path}`) ?? derivePathModelBaseName(path, method); + const key = `${method} ${path}`; + const pathModelBaseName = pathModelBaseNames.get(key) ?? derivePathModelBaseName(path, method); const servers = operationServers( extractServerList(op.servers, `operation ${operationLabel(op, path, method)}`), pathLevelServers, @@ -603,7 +611,7 @@ export function extractOperations( pathLevelParams, pathItem, pathModelBaseName, - operationIdTransform, + plan.operations.get(key)!.initialName, serviceName, componentSchemas, componentParameters, @@ -612,7 +620,6 @@ export function extractOperations( componentHeaders, componentResponses, deferred, - apiRootLength, servers, refSiblingDescriptions, componentResponseModelNames, @@ -620,6 +627,7 @@ export function extractOperations( operationHints?.[`${method.toUpperCase()} ${path}`]?.pagination, componentModels, registry, + modelOperationIds.get(key), ); inlineModels.push(...opModels); // The operation's models join the registry under its qualifier, so a @@ -630,15 +638,14 @@ export function extractOperations( inlineModelBundles.push(...opBundles); if (opCompanions) { envelopeCompanions.push(...opCompanions); - for (const companion of opCompanions) companionQualifiers.set(companion, toPascalCase(operation.name)); + // Qualified as the operation's own models are (`buildOperation`'s `namingName`). + const qualifier = toPascalCase(modelOperationIds.get(key) ?? operation.name); + for (const companion of opCompanions) companionQualifiers.set(companion, qualifier); } // Same truthiness check as inferServiceName: for a tagged operation the // service name *is* the normalized tag, so it doubles as the lookup key. if (tag) tagServiceByOperation.set(operation, serviceName); - if (!op.operationId) pathNamed.add(operation); - const ops = serviceMap.get(serviceName) ?? []; - ops.push(operation); - serviceMap.set(serviceName, ops); + operationsByKey.set(key, operation); } } @@ -713,26 +720,15 @@ export function extractOperations( inlineModelBundles.push(...deferredTypes.bundles); attachSharedRefs(inlineModelBundles, new Set(registeredModels.map((model) => model.name))); - // The API root each operation's path sits under, for every step below: none - // for a tagged operation, whose service is its tag. - const rootLengthOf = (op: Operation) => (tagServiceByOperation.has(op) ? 0 : apiRootLengthOf(op.path, apiRoot)); - - // Split a tagged service whose tag groups operations from several first - // path segments — `/organizations` and `/organization_domains` both tagged - // `organizations` — into one service per segment. Decided over the tagged - // operations alone, so an untagged operation the service picked up by path - // never moves its tagged siblings (see splitTaggedServices). - splitTaggedServices(serviceMap, tagServiceByOperation); - - // Disambiguate operation names within each service. - // Multiple operations can get the same name (e.g., several "list" endpoints - // in UserManagement for users, invitations, auth factors, etc.). - for (const [serviceName, operations] of serviceMap) { - disambiguateOperationNames(operations, pathNamed, serviceName, rootLengthOf); - } - const declaredTags = indexDocumentTags(documentTags); - const services: Service[] = Array.from(serviceMap.entries()).map(([name, operations]) => { + // Each service the plan settled, its operations under the names they + // settled on (see planOperations). + const services: Service[] = Array.from(plan.services.entries()).map(([name, keys]) => { + const operations = keys.map((key) => { + const operation = operationsByKey.get(key)!; + operation.name = plan.operations.get(key)!.name; + return operation; + }); const { description, externalDocs } = resolveServiceDocs(name, operations, tagServiceByOperation, declaredTags); // Omit each key entirely when no tag gives it so the IR shape is // unchanged for documents without tag descriptions or links. @@ -747,6 +743,91 @@ export function extractOperations( return { services, inlineModels, inlineModelBundles, componentSites }; } +/** What `planOperations` settles for one operation. */ +interface PlannedOperation { + /** The name it starts with: its `operationId`'s, else the one its method and path give it (`inferOperationName`). */ + initialName: string; + /** The name it ends with, unique within its service (`disambiguateOperationNames`). */ + name: string; + /** The API root its path sits under for naming: none for a tagged operation, whose service is its tag. */ + rootLength: number; +} + +/** Every operation's plan, and every service's operations, by key (`${method} ${path}`), services in IR order. */ +interface OperationPlan { + operations: Map; + services: Map; +} + +/** + * Settle the service and the name of every operation before any operation is + * built, so an operation's models can follow the name it ends up with + * (resolveReusedOperationIds). Each operation starts in the service its tag + * or its path gives it (inferServiceName), under the name its `operationId` + * or its method and path give it (inferOperationName, the one place + * `operationIdTransform` is called). Then a tagged service whose tag groups + * operations from several first path segments — `/organizations` and + * `/organization_domains` both tagged `organizations` — is split into one + * service per segment, decided over the tagged operations alone, so an + * untagged operation the service picked up by path never moves its tagged + * siblings (splitTaggedServices); and the names within each service are made + * unique, since several operations can take one name — several `list` + * endpoints in `UserManagement`, for users, invitations and auth factors + * (disambiguateOperationNames, whose warnings are given here). + * + * Both passes read nothing of an operation but its name, path and method, + * so they run on stand-ins that carry those, and each operation built + * afterwards takes the service and the name the passes gave its stand-in. + */ +function planOperations( + paths: Record, + apiRoot: readonly string[], + operationIdTransform: ((id: string) => string) | undefined, +): OperationPlan { + const serviceMap = new Map(); + const started = new Map(); + // Service name each tagged operation was grouped under, keyed by operation + // identity, so a split-off service is traced back to the tag it came from. + const tagServiceByOperation = new Map(); + // Operations named from the method and path rather than an operationId. + // Only these may be renamed when names collide within a service (see + // disambiguateOperationNames); a name taken from an operationId is kept as is. + const pathNamed = new Set(); + for (const [path, pathItem] of Object.entries(paths)) { + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (!op) continue; + // The same reading the operation loop of extractOperations makes. + const tag = serviceTag(op.tags); + const apiRootLength = tag ? 0 : apiRootLengthOf(path, apiRoot); + const serviceName = inferServiceName(path, tag, apiRootLength); + const name = inferOperationName(method, path, op.operationId, operationIdTransform, apiRootLength); + const stub = { name, path, httpMethod: method } as Operation; + started.set(stub, { key: `${method} ${path}`, initialName: name }); + if (tag) tagServiceByOperation.set(stub, serviceName); + if (!op.operationId) pathNamed.add(stub); + const ops = serviceMap.get(serviceName) ?? []; + ops.push(stub); + serviceMap.set(serviceName, ops); + } + } + + splitTaggedServices(serviceMap, tagServiceByOperation); + const rootLengthOf = (op: Operation) => (tagServiceByOperation.has(op) ? 0 : apiRootLengthOf(op.path, apiRoot)); + for (const [serviceName, operations] of serviceMap) { + disambiguateOperationNames(operations, pathNamed, serviceName, rootLengthOf); + } + + const operations = new Map(); + for (const [stub, { key, initialName }] of started) { + operations.set(key, { initialName, name: stub.name, rootLength: rootLengthOf(stub) }); + } + const services = new Map( + [...serviceMap].map(([serviceName, stubs]) => [serviceName, stubs.map((stub) => started.get(stub)!.key)] as const), + ); + return { operations, services }; +} + /** * What a document tag contributes to the service it names. Both keys are * absent when the tag declares neither, so a declared-but-undocumented tag is @@ -2733,7 +2814,8 @@ function buildOperation( pathLevelParams: RawParameterObject[], pathItemDocs: PathItemDocs, pathModelBaseName: string, - operationIdTransform?: (id: string) => string, + /** The name the operation starts with (`planOperations`); the naming passes settle the one it ends with. */ + operationName: string, serviceName?: string, componentSchemas?: Record, componentParameters?: Record>, @@ -2742,13 +2824,19 @@ function buildOperation( componentHeaders?: Record>, componentResponses?: Record>, deferred: DeferredInlineType[] = [], - apiRootLength = 0, servers?: ServerEntry[], refSiblingDescriptions: SiblingDocs = 'override', componentResponseModelNames?: ReadonlyMap, paginationHint?: unknown, componentModels?: Model[], registry?: ModelShapeRegistry, + /** + * The `operationId` the operation's models are named from when the + * document reuses its own (`resolveReusedOperationIds`): the name the + * reuse renamed it to, or `null` to name them from the path; absent, its + * own. + */ + modelOperationId?: string | null, ): { operation: Operation; inlineModels: Model[]; @@ -2759,7 +2847,12 @@ function buildOperation( } { const opLabel = operationLabel(op, path, method); warnOnIgnoredCallbacks(op.callbacks, opLabel); - const operationName = inferOperationName(method, path, op.operationId, operationIdTransform, apiRootLength); + // The Operation Object as every model name reads it (the request and + // response bodies, the error and fallback bodies, the qualifiers): with + // the `operationId` the reuse of its own leaves it, if any. The name its + // models are qualified by goes with it. + const namingOp = modelOperationId === undefined ? op : { ...op, operationId: modelOperationId ?? undefined }; + const namingName = modelOperationId ?? operationName; // Resolve a `components/requestBodies` ref up front so body extraction, body // parameter groups, and the body-ownership rule below all read one object. const requestBodyObject = resolveRequestBodyRef( @@ -2821,7 +2914,7 @@ function buildOperation( const opContext = serviceName; // Names this operation's models in the shape registry, and qualifies a // deferred model's name when one of them is taken across operations. - const operationQualifier = headerOperationQualifier(op, operationName, pathModelBaseName, opContext); + const operationQualifier = headerOperationQualifier(namingOp, namingName, pathModelBaseName, opContext); // What the typing of a parameter, a response header or a secondary, // fallback or error body reads from the operation and the spec; a schema // deferred through it is typed once every operation has been read @@ -2883,7 +2976,7 @@ function buildOperation( description: requestBodyDescription, contentTypes: requestBodyContentTypes, contents: requestBodyContents, - } = extractRequestBody(requestBodyObject, op, pathModelBaseName, reqBodyModels, componentSchemas, opLabel, { + } = extractRequestBody(requestBodyObject, namingOp, pathModelBaseName, reqBodyModels, componentSchemas, opLabel, { deferred, operationQualifier, componentSchemas, @@ -2968,7 +3061,7 @@ function buildOperation( overrideProblem, envelopeCompanions, unpagedEnvelope, - } = extractResponses(op.responses, op, pathModelBaseName, path, method, componentSchemas, typing, { + } = extractResponses(op.responses, namingOp, pathModelBaseName, opLabel, componentSchemas, typing, { disabled: iteratorSource === undefined, dataPath: paginationDisabled ? undefined : paginationOverride?.hint.dataPath, endOfIteration: paginationDisabled ? undefined : paginationOverride?.hint.endOfIteration, @@ -3139,7 +3232,7 @@ function buildOperation( // bundle owns the success bodies, the list envelope and the response // headers; the request bundle owns the body. Qualified as a header model or // list-envelope companion is (`GetLegacyWidgetWidget`). - const qualifier = toPascalCase(operationName); + const qualifier = toPascalCase(namingName); const inlineModelBundles: InlineModelBundle[] = []; // The response site is recorded even when it minted nothing: its array // items may have shared another site's model (`typeInlineItems`), and the @@ -5035,13 +5128,80 @@ export function derivePathBasedName( } /** - * Decide the path-derived model prefix of every operation that has no - * `operationId`, keyed by `${method} ${path}`. + * The `operationId` each operation whose own the document reuses names its + * models from, keyed by `${method} ${path}`: `null` for one that names them + * from its path, as an operation without an `operationId` does + * (resolvePathModelBaseNames). An operation not here names them from its + * own. Reused means declared on more than one Operation Object, which + * OpenAPI forbids: every such operation would otherwise name its models + * alike (`CreateNoteRequest`), and the merge would keep one body for all of + * them. + * + * Each claims the stem (operationIdModelBase) of the name it ends up with + * (planOperations): its `operationId`'s while it keeps the name it took from + * it, and its new name's when the reuse renamed it within its service — + * `createNote2` names its models `CreateNote2Request` beside `createNote`'s + * `CreateNoteRequest`. Claims are served in the order the numeric pass of + * disambiguateOperationNames keeps a name in — the shorter API root, then + * the path by code unit, then the method in HTTP_METHODS order — so the + * outcome never depends on document order, and a claim is granted while no + * earlier one, and no `operationId` declared on one Operation Object, holds + * its stem. An operation whose claim is not granted names its models from + * its path: one that kept the name in another service as well (`createNote` + * in `Notes` and in `ArchivedNotes`: `/archived-notes` sorts first, so + * `/notes` sends a `NotesPostRequest`), or one renamed to a name another + * `operationId` already names its models from. An `operationId` declared on + * one Operation Object is not reused, even when two paths `$ref` the Path + * Item it is on: both operations read one body, and share its models. + */ +function resolveReusedOperationIds(paths: Record, plan: OperationPlan): Map { + const declaring = new Map>(); + for (const [path, pathItem] of Object.entries(paths)) { + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (!op?.operationId) continue; + const entries = declaring.get(op.operationId) ?? []; + entries.push({ key: `${method} ${path}`, path, method, op }); + declaring.set(op.operationId, entries); + } + } + const reused = (entries: Array<{ op: OperationObject }>) => new Set(entries.map(({ op }) => op)).size > 1; + // Every stem an `operationId` declared on one Operation Object holds. + const held = new Set( + [...declaring].filter(([, entries]) => !reused(entries)).map(([operationId]) => operationIdModelBase(operationId)), + ); + const claims = [...declaring.values()] + .filter(reused) + .flat() + .map((entry) => ({ ...entry, ...plan.operations.get(entry.key)! })) + .sort( + (a, b) => + a.rootLength - b.rootLength || + (a.path < b.path ? -1 : a.path > b.path ? 1 : 0) || + HTTP_METHODS.indexOf(a.method) - HTTP_METHODS.indexOf(b.method), + ); + + const modelOperationIds = new Map(); + for (const { key, op, initialName, name } of claims) { + const renamed = name !== initialName; + const stem = operationIdModelBase(renamed ? name : op.operationId!); + if (held.has(stem)) modelOperationIds.set(key, null); + else if (renamed) modelOperationIds.set(key, name); + held.add(stem); + } + return modelOperationIds; +} + +/** + * Decide the path-derived model prefix of every operation that names its + * models from its path — one without an `operationId`, or one whose + * `operationId` the document reuses and leaves it none + * (resolveReusedOperationIds) — keyed by `${method} ${path}`. * * A prefix collides when two distinct raw paths with the same method flatten * to it (see derivePathModelBaseName), or when a reserved name starts with * it. The reserved names are every resolved component model name and, for - * every operation with an `operationId`, its model stem + * every operation that names its models from an `operationId`, their stem * (operationIdModelBase). A prefix is the stem of several emitted names — * `…Request`, `…Response`, a list's `…Item`, nested inline models such as * `…ResponseOwner`, envelope companions such as `…ListMetadata` — so the @@ -5085,6 +5245,7 @@ function resolvePathModelBaseNames( componentModels?: Model[], componentRequestBodies?: Record>, componentResponseNaming: ReadonlyMap = new Map(), + modelOperationIds: ReadonlyMap = new Map(), ): Map { const candidates: Array<{ key: string; path: string; method: HttpMethod; plain: string }> = []; const rawPathsByPlainName = new Map>(); @@ -5116,12 +5277,14 @@ function resolvePathModelBaseNames( for (const method of HTTP_METHODS) { const op = pathItem[method]; if (!op) continue; - if (op.operationId) { - reservedNames.add(operationIdModelBase(op.operationId)); + const key = `${method} ${path}`; + const modelOperationId = modelOperationIds.has(key) ? modelOperationIds.get(key) : op.operationId; + if (modelOperationId) { + reservedNames.add(operationIdModelBase(modelOperationId)); continue; } const plain = derivePathModelBaseName(path, method); - candidates.push({ key: `${method} ${path}`, path, method, plain }); + candidates.push({ key, path, method, plain }); const rawPaths = rawPathsByPlainName.get(plain) ?? new Set(); rawPaths.add(path); rawPathsByPlainName.set(plain, rawPaths); @@ -6232,10 +6395,11 @@ function undescribedBodyType(): TypeRef { function extractResponses( responses: Record | undefined, + /** The Operation Object as the operation's model names read it (`buildOperation`'s `namingOp`). */ op: OperationObject | undefined, pathModelBaseName: string, - path?: string, - method?: HttpMethod, + /** The operation as diagnostics name it (`operationLabel`). */ + opLabel: string, componentSchemas?: Record, ctx: ResponseTypingContext = { operationQualifier: '', deferred: [] }, pagination: PaginationExtractionOptions = {}, @@ -6292,7 +6456,6 @@ function extractResponses( let unpagedEnvelope: boolean | undefined; const allSuccessResponses: SuccessResponse[] = []; - const opLabel = operationLabel(op, path ?? '/', method ?? 'get'); // Every 2xx body's classification, by status code. The models of all of // them are kept — `successResponses` references each — while the paging // fields are read from the status chosen as primary once every status code diff --git a/test/parser/inline-model-naming.test.ts b/test/parser/inline-model-naming.test.ts index 199ea407..61103b7e 100644 --- a/test/parser/inline-model-naming.test.ts +++ b/test/parser/inline-model-naming.test.ts @@ -237,6 +237,268 @@ describe('inline model naming without operationId (issue #188)', () => { }); }); +describe('an operationId two operations declare (issue #416)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const api = (paths: object, components?: object) => ({ + openapi: '3.1.0', + info: { title: 'reused', version: '1' }, + paths, + ...(components ? { components } : {}), + }); + + const createNote = (properties: Record) => ({ + post: { + operationId: 'createNote', + requestBody: jsonBody(inlineObject(properties)), + responses: { '204': { description: 'ok' } }, + }, + }); + + /** The issue's print-out: every model with its fields, every operation with the body it sends. */ + const printOut = (ir: Awaited>) => ({ + models: ir.models.map(({ fields, name }) => `${name} {${fields.map((f) => f.name).join(', ')}}`), + operations: ir.services.flatMap(({ name: service, operations }) => + operations.map((op) => `${service}.${op.name} (${op.path}): ${summarize(op).requestBody}`), + ), + }); + + const RENAMED_CREATE_NOTE = + '[oagen] Warning: Operations POST /notes and POST /notes/drafts in service Notes both take the name "createNote" from their operationId; the latter is renamed "createNote2" — give each a distinct operationId'; + + const oneService = { + '/notes': createNote({ text: { type: 'string' } }), + '/notes/drafts': createNote({ title: { type: 'string' } }), + }; + + it('names the body of the operation the reuse renames after its new name (the one-service repro)', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(await parseInline(api(oneService), printOut)).toMatchInlineSnapshot(` + { + "models": [ + "CreateNoteRequest {text}", + "CreateNote2Request {title}", + ], + "operations": [ + "Notes.createNote (/notes): CreateNoteRequest", + "Notes.createNote2 (/notes/drafts): CreateNote2Request", + ], + } + `); + // The rename is reported; no body is folded into the other. + expect(warnSpy.mock.calls.map(([message]) => message)).toEqual([RENAMED_CREATE_NOTE]); + }); + + it('names them the same whatever order the document declares the paths in', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const forward = await parseInline(api(oneService), printOut); + const backward = await parseInline(api(Object.fromEntries(Object.entries(oneService).reverse())), printOut); + + expect([...backward.operations].sort()).toEqual([...forward.operations].sort()); + expect([...backward.models].sort()).toEqual([...forward.models].sort()); + }); + + it("gives operations in two services distinct bodies: the first path keeps the operationId's, the other is named from its path (the two-services repro)", async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const out = await parseInline( + api({ + '/notes': createNote({ text: { type: 'string' } }), + '/archived-notes': createNote({ title: { type: 'string' }, archivedAt: { type: 'string' } }), + }), + printOut, + ); + + // Neither operation is renamed, so the operationId names one body only: + // `/archived-notes` sorts first and keeps it, and `/notes` names its body + // from its path, as an operation without an operationId does (#188). + expect(out).toMatchInlineSnapshot(` + { + "models": [ + "NotesPostRequest {text}", + "CreateNoteRequest {title, archivedAt}", + ], + "operations": [ + "Notes.createNote (/notes): NotesPostRequest", + "ArchivedNotes.createNote (/archived-notes): CreateNoteRequest", + ], + } + `); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('names every model the renamed operation mints after its new name: response, nested object and error body', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const getNote = (properties: Record, error: Record) => ({ + get: { + operationId: 'getNote', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': jsonResponse({ type: 'object', properties }), + '404': jsonResponse(inlineObject(error)), + }, + }, + }); + + const { ops, models } = await parseInline( + api({ + '/notes/{id}': getNote( + { id: { type: 'string' }, owner: inlineObject({ name: { type: 'string' } }) }, + { message: { type: 'string' } }, + ), + '/notes/{id}/draft': getNote( + { title: { type: 'string' }, owner: inlineObject({ email: { type: 'string' } }) }, + { reason: { type: 'string' } }, + ), + }), + (ir) => ({ + ops: ir.services.flatMap((s) => + s.operations.map((op) => ({ + ...summarize(op), + errors: op.errors.map((e) => (e.type?.kind === 'model' ? e.type.name : e.type)), + })), + ), + models: ir.models.map(({ fields, name }) => `${name} {${fields.map((f) => f.name).join(', ')}}`), + }), + ); + + expect(ops).toMatchInlineSnapshot(` + [ + { + "errors": [ + "GetNoteError404", + ], + "name": "getNote", + "path": "/notes/{id}", + "requestBody": undefined, + "response": "GetNoteResponse", + }, + { + "errors": [ + "GetNote2Error404", + ], + "name": "getNote2", + "path": "/notes/{id}/draft", + "requestBody": undefined, + "response": "GetNote2Response", + }, + ] + `); + expect([...models].sort()).toEqual([ + 'GetNote2Error404 {reason}', + 'GetNote2Response {title, owner}', + 'GetNote2ResponseOwner {email}', + 'GetNoteError404 {message}', + 'GetNoteResponse {id, owner}', + 'GetNoteResponseOwner {name}', + ]); + }); + + it('names the models of an operationId reused on one path after the number the later method takes', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const returning = (properties: Record) => ({ + operationId: 'note', + responses: { '200': jsonResponse(inlineObject(properties)) }, + }); + + const out = await parseInline( + api({ + '/notes/{id}': { + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + get: returning({ title: { type: 'string' } }), + put: returning({ updatedAt: { type: 'string' } }), + }, + }), + (ir) => ({ + models: ir.models.map(({ fields, name }) => `${name} {${fields.map((f) => f.name).join(', ')}}`), + operations: ir.services.flatMap((s) => s.operations.map((op) => `${op.name}: ${summarize(op).response}`)), + }), + ); + + expect(out).toEqual({ + models: ['NoteResponse {title}', 'Note2Response {updatedAt}'], + operations: ['note: NoteResponse', 'note2: Note2Response'], + }); + // The rename alone is reported; neither response is folded into the other. + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("names a renamed operation's body from its path when another operationId already names its models so", async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // `createNote2` is free in `Notes`, so the reuse renames `/notes/drafts` + // to it, but `Tags` holds the `operationId` `createNote2` and with it + // every `CreateNote2…` model. + const out = await parseInline( + api({ + ...oneService, + '/tags': { + post: { + operationId: 'createNote2', + requestBody: jsonBody(inlineObject({ label: { type: 'string' } })), + responses: { '204': { description: 'ok' } }, + }, + }, + }), + printOut, + ); + + expect(out).toMatchInlineSnapshot(` + { + "models": [ + "CreateNoteRequest {text}", + "NotesDraftsPostRequest {title}", + "CreateNote2Request {label}", + ], + "operations": [ + "Notes.createNote (/notes): CreateNoteRequest", + "Notes.createNote2 (/notes/drafts): NotesDraftsPostRequest", + "Tags.createNote2 (/tags): CreateNote2Request", + ], + } + `); + expect(warnSpy.mock.calls.map(([message]) => message)).toEqual([RENAMED_CREATE_NOTE]); + }); + + it('still shares the models of one Operation Object that two paths $ref', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Both operations read the one schema, so they share its one model, as + // two webhooks that `$ref` one path item share their payload. + const out = await parseInline( + api( + { + '/pets': { $ref: '#/components/pathItems/Pets' }, + '/pets/all': { $ref: '#/components/pathItems/Pets' }, + }, + { + pathItems: { + Pets: { + get: { + operationId: 'listPets', + responses: { '200': jsonResponse(inlineObject({ name: { type: 'string' } })) }, + }, + }, + }, + }, + ), + (ir) => ({ + models: ir.models.map(({ fields, name }) => `${name} {${fields.map((f) => f.name).join(', ')}}`), + operations: ir.services.flatMap((s) => s.operations.map((op) => `${op.name}: ${summarize(op).response}`)), + }), + ); + + expect(out).toEqual({ + models: ['ListPetsResponse {name}'], + operations: ['listPets: ListPetsResponse', 'listPets2: ListPetsResponse'], + }); + }); +}); + describe('path-derived names that would otherwise collide', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/test/parser/numbered-operation-names.test.ts b/test/parser/numbered-operation-names.test.ts index 614bee25..f9ec36df 100644 --- a/test/parser/numbered-operation-names.test.ts +++ b/test/parser/numbered-operation-names.test.ts @@ -165,6 +165,41 @@ describe('a numeric suffix on a name that ends in a digit (#289)', () => { } }); + it("names the renamed operation's models after the number it takes: getV2No2 returns a GetV2No2Response (#416)", () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const returning = (field: string) => ({ + ...param('id', { operationId: 'getV2' }), + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { schema: { type: 'object', properties: { [field]: { type: 'string' } } } }, + }, + }, + }, + }); + const { services, inlineModels } = extractOperations({ + '/store/pets/{id}': { get: returning('pet') }, + '/store/featured/{id}': { get: returning('featured') }, + }); + // Each response keeps its own fields, under the name of the operation + // that returns it. + expect( + services.flatMap((s) => + s.operations.map((op) => { + const name = op.response.kind === 'model' ? op.response.name : ''; + const fields = inlineModels.find((m) => m.name === name)?.fields.map((f) => f.name); + return `${op.name}: ${name} {${fields?.join(', ')}}`; + }), + ), + ).toEqual(['getV2No2: GetV2No2Response {pet}', 'getV2: GetV2Response {featured}']); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + it('spells the number the same way on one path: a path-named PATCH beside operationId updateTop10 is updateTop10No2', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { From d884b03bb38b57f780b36c97bd3fab2b6b2a3f8f Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 09:53:38 -0400 Subject: [PATCH 40/59] fix(parser): share a $ref'd operation's models and keep numbered models off longer stems Two cases the first pass at #416 still got wrong, from review: - A renamed operation's new name is the parser's, yet it was granted whenever no other operationId held the same stem. A renamed `getNote2` returning a `request` object minted `GetNote2ResponseRequest`, the request body of `operationId: getNote2Response`, and the merge kept one. A new name is now granted, as a path-derived prefix is, only while no stem held starts with it, and the operationIds' own stems are served before any new name. - Claims were made per path, so when a third Operation Object reused the operationId of one that two paths `$ref`, those two paths split into `PetsGetResponse` and `ListPets2Response` for one body. Each Operation Object now claims once, by the first of its paths, and its other paths name their models as that one does. --- docs/architecture/ir-types.md | 2 +- src/parser/operations.ts | 95 +++++++++++++++++-------- test/parser/inline-model-naming.test.ts | 89 +++++++++++++++++++++++ 3 files changed, 155 insertions(+), 31 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 9b7b1390..6f4d6ae0 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -749,7 +749,7 @@ Two shapes are not array components. A `type: 'array'` with no `items` still bec Models come from `components/schemas` (named as written, after `cleanSchemaName` and any `schemaNameTransform`; an enum or array component yields none — see Enum and Array components) and from inline object schemas in operations, which the parser materializes as models so emitters have something to reference. Two component keys that resolve to one name (`Note` and `note`) keep the definition with more fields, the first on a tie; when the two differ in fields, types or limits (`modelFingerprint`), the parser prints `[oagen] Warning: Component schemas "Note" and "note" both resolve to "Note" with different fields or field types` before dropping the other, and folds identical definitions silently. An inline request body or response is named from its operation: -- **With an `operationId`:** `PascalCase(operationId)` + `Request` / `Response`, passed through `cleanSchemaName`. `createNote` → `CreateNoteRequest` / `CreateNoteResponse`. An `operationId` declared on more than one Operation Object, which OpenAPI forbids, names the models of one operation only, so no two operations share a body. An operation the reuse renames within its service (see Service & Operation) names its models after its new name: `createNote2` on `POST /notes/drafts` beside `createNote` on `POST /notes` sends a `CreateNote2Request`, and its response, error bodies and nested models follow (`CreateNote2Response`, `CreateNote2Error404`, `CreateNote2ResponseOwner`). Of the operations that keep the name — one per service — the first by API root length, then path, then HTTP method keeps the `operationId`'s models, and every other one names its models from its path, as an operation without an `operationId` does: `createNote` on `POST /archived-notes` and on `POST /notes`, in two services, send a `CreateNoteRequest` and a `NotesPostRequest`. So does a renamed operation whose new name another `operationId` already names models from (`createNote2` in `Notes` beside `operationId: createNote2` in `Tags` sends a `NotesDraftsPostRequest`). The outcome does not depend on document order. Two paths that `$ref` one Path Item read one Operation Object and one body, so they share its models. +- **With an `operationId`:** `PascalCase(operationId)` + `Request` / `Response`, passed through `cleanSchemaName`. `createNote` → `CreateNoteRequest` / `CreateNoteResponse`. An `operationId` declared on more than one Operation Object, which OpenAPI forbids, names the models of one of those Operation Objects only, so no two bodies share a model. An operation the reuse renames within its service (see Service & Operation) names its models after its new name: `createNote2` on `POST /notes/drafts` beside `createNote` on `POST /notes` sends a `CreateNote2Request`, and its response, error bodies and nested models follow (`CreateNote2Response`, `CreateNote2Error404`, `CreateNote2ResponseOwner`). Of the operations that keep the name — one per service — the first by API root length, then path, then HTTP method keeps the `operationId`'s models, and every other one names its models from its path, as an operation without an `operationId` does: `createNote` on `POST /archived-notes` and on `POST /notes`, in two services, send a `CreateNoteRequest` and a `NotesPostRequest`. A new name is the parser's, so, like a path-derived prefix, it yields to every `operationId` whose models could start with it: a renamed operation names its models from its path as well when another `operationId` names models from its new name (`createNote2` in `Notes` beside `operationId: createNote2` in `Tags` sends a `NotesDraftsPostRequest`) or from a name that extends it (a renamed `getNote2` beside `operationId: getNote2Response` returns a `NotesDraftsGetResponse`, since the `request` object it returns would otherwise be `GetNote2ResponseRequest`, the body `getNote2Response` sends). Two paths that `$ref` one Path Item read one Operation Object and one body, so they share its models, named as the first of its paths in that order names them (`listPets` on `/pets` and `listPets2` on `/pets/all` both return a `PetsGetResponse` when `operationId: listPets` on `/other` keeps `ListPetsResponse`). The outcome does not depend on document order. - **Without an `operationId`:** from the full path and method. Each non-parameter segment is PascalCased in order, each `{param}` segment becomes `By` + `PascalCase(param)`, a custom method written after a colon is read as its segment followed by the verb (`POST /pets/{id}:adopt` → `PetsByIdAdoptPostRequest`, the prefix of `/pets/{id}/adopt`), a segment with several colons outside braces is one literal even when a parameter opens it (`POST /v1/pets/{id}:adopt:now` → `V1PetsIdAdoptNowPostRequest`), then the method and `Request` / `Response` are appended, and the whole name is normalized with `toPascalCase` so an operation's body and response share a prefix (`POST /a/b` → `AbPostRequest` / `AbPostResponse`). `POST /notes` → `NotesPostRequest` / `NotesPostResponse`; `POST /notes/{id}/pin` → `NotesByIdPinPostResponse`; `GET /users/{user_id}/sessions` → `UsersByUserIdSessionsGetResponse`. This tells the common path shapes apart (list vs. item, sibling and nested sub-resources) but is not unique by construction: `/foo-bar` and `/foo/bar`, `/notes/by-id` and `/notes/{id}`, `/notes` and `/notes/`, or `/pets/{id}:adopt` and `/pets/{id}/adopt` flatten to the same words. Names already taken are reserved first: every component schema name and, for every operation with an `operationId`, the stem all of its models start with (`PascalCase(operationId)` after `cleanSchemaName`). A path-based prefix is treated as colliding when any reserved name starts with it, because one prefix is the stem of several emitted names (`…Request`, `…Response`, a list's `…Item`, nested inline models such as `…ResponseOwner`, envelope companions such as `…ListMetadata`). Names derived from a path-based prefix use it verbatim — body `oneOf` variants included (`NotesPostRequest`, `NotesPostRequest2`, `EmailNotesPostRequest`) — and are never re-cleaned, so no later step can move them out from under the reservation; as a second line of defence the check also runs on the cleaned spellings of the prefix (`cleanSchemaName`, the project's `schemaNameTransform`, `ListItem`-marker stripping), so an unnamed `GET /notes` also yields to a component `NoteGetItem`. Names that embed the prefix behind a const value are covered as well: a body `oneOf` variant pinning `kind: email` is `EmailTagsPostRequest` and a nested `oneOf`/`anyOf` variant is ``, so every string `const` (or single-value `enum`) found in the operation's inline body and 2xx response variants, top level and nested, is prepended to every spelling before the check, and a hashed prefix lands inside those names too (`EmailTags151Dd5PostRequest`). Names that do not embed the prefix at all — a response `oneOf` variant named from its bare const (`Email`) or a `title`-mapped variant — are outside what a disambiguator can separate and are left as they are. This is deliberately a superset: a component `AuditEventsGetter`, or an operationId `auditEventsGetSummary`, also pushes an unnamed `GET /audit/events` onto a hashed name although no emitted name would coincide; determinism and never sharing a name wrongly matter more than the rare extra hash. The reservation is unconditional, so an inline body is never merged into a same-named component even when their shapes agree. When two distinct paths with the same method would collide, the path spelled most plainly keeps the plain name (no trailing slash, `{id}` rather than a literal `by-id`; precisely, the lowest `[trailing slash, custom methods written after a colon, literal segments with a character outside a-z0-9]`, so `/pets/{id}/adopt` keeps the plain name over `/pets/{id}:adopt` and `/pets/{id}/batchAdopt` over `/pets/{id}:batchAdopt`, while a parameter segment is never irregular and `/tags/{tag}.json` beside `/tags/{tag}/json` ties), and every other path in the group gets a six-character disambiguator derived from its raw path (the start of its SHA-1, inserted before the method): `POST /foo/bar` next to `POST /foo-bar` → `FooBarPostResponse` and `FooBar546F12PostResponse`; `GET /notes/{id}` next to `GET /notes/by-id` → `NotesByIdGetResponse` and `NotesByIdBa41B5GetResponse`. When two paths tie for plainest (`/foo-bar` and `/foo_bar`), or the path-based name matches a reserved one (an unnamed `POST /audit/events` next to `operationId: auditEventsPost` or next to `components/schemas/AuditEventsPostRequest`), nobody keeps the plain name and every path in the group is disambiguated. The result depends only on the document's paths, `operationId`s and component names and never on their order; `operationId`-derived and component names never change, and paths that do not collide keep the plain name. - **Shared through `components/responses`:** a response written as `{ $ref: '#/components/responses/PetFound' }` is resolved before it is read (chains are followed; a circular chain fails the parse; a `$ref` that points outside `components/responses` — into another section such as `#/components/schemas/Pet`, or a bundler-written pointer such as `#/paths/~1pets/get/responses/200` — is warned about (`Unresolved response $ref "#/components/schemas/Pet" in 404 response of getPet: no matching entry under components/responses — reading the response as written, without a body or headers`) and read as written, with no body or headers, while a `$ref` into `components/responses` that names no entry never reaches the parser: the bundler rejects it, and `parseSpec` throws `Failed to parse spec: Can't resolve $ref`; a sibling `description` beside the `$ref` wins in an OAS 3.1 document, outermost first, and is ignored in an OAS 3.0 one). Its inline body is named after the component, not the operation — `PascalCase(componentName)` + `Response`, passed through `cleanSchemaName`, the suffix not doubled when the component is already named `…Response` (`PetFound` → `PetFoundResponse`, `ErrorResponse` → `ErrorResponse`) — so every operation that returns it shares one model instead of minting a `Response` copy each — whether it returns it at an explicit status code, as `default` or in a `1XX`–`5XX` range: a `default` and a `4XX` both written as `$ref: '#/components/responses/Problem'` are one `ProblemResponse`, shared with the `404` of another operation that points at it; a chain is named after the ref the operation wrote. Everything else the component declares that mints a model is named from the stem of that shared body name — `Response` less its `Response` (`Card`), or `CardResponse2` when the body stepped to it — too: an inline object under another media type is `Response` (`Card` with an `application/yaml` body → `CardApplicationYamlResponse`) and an object-shaped header is `
` (`Card` with `X-Meta` → `CardXMeta`), nested objects under either qualified by it. These names do not depend on the operation, its service, or the status code or fallback key (`404`, `302`, `default`, `4XX`) the operation mounts the component at, so every operation that returns the component shares one model of each, as it shares the body; a name a component schema or another site already owns with a shape it does not share steps by number (`CardXMeta2`, `CardApplicationYamlResponse2`), as the component's body steps to `CardResponse2`, never by the operation or the status code — those qualifiers belong to an inline response's sites — so a stepped name depends on the component alone too, and removing one of the operations that return the component leaves the others' names unchanged; an inline enum such a site carries — a header's, a secondary body's, the body's — steps by number the same way when the collectors promote it (`CardXMode2`), never by the status code or fallback label (see Response Headers and Response Content Types above). The component's stem is reserved like an `operationId`'s, so an unnamed `GET /owner` beside a component response `OwnerGet` takes a hashed name. The names a body mints are reserved under every reading an operation can give it — a list envelope unwrapped for an iterator (`NotePage: { data: { title }[], has_more }` → `NotePageItem`) and kept as the wire shape (`NotePageResponse`, `NotePageResponseData`) — since which reading an operation takes depends on its own query parameters and override, and two operations returning one component can differ (`GET /notes` keeps it, `GET /notes/recent?after` unwraps it): beside a schema `NotePageResponseData` or `NotePageItem` the stem steps to `NotePageResponse2` for every operation, so a kept body's item model is never renamed late and both readings move together. The component's headers resolve through `components/headers` as inline ones do. diff --git a/src/parser/operations.ts b/src/parser/operations.ts index 2a3aa4d3..3c7c9e01 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -496,7 +496,7 @@ export function extractOperations( // no two such operations name their models alike (see // resolveReusedOperationIds). const plan = planOperations(resolvedPaths, apiRoot, operationIdTransform); - const modelOperationIds = resolveReusedOperationIds(resolvedPaths, plan); + const { modelOperationIds, sharedWith } = resolveReusedOperationIds(resolvedPaths, plan); // Operations without an operationId — or left none by the reuse of their // own (see resolveReusedOperationIds) — name their inline models from the @@ -519,6 +519,7 @@ export function extractOperations( componentRequestBodies, componentResponseNaming, modelOperationIds, + sharedWith, ); /** Every operation built, by its plan key (`${method} ${path}`). */ @@ -591,7 +592,9 @@ export function extractOperations( const apiRootLength = tag ? 0 : apiRootLengthOf(path, apiRoot); const serviceName = inferServiceName(path, tag, apiRootLength); const key = `${method} ${path}`; - const pathModelBaseName = pathModelBaseNames.get(key) ?? derivePathModelBaseName(path, method); + // A path that shares another's Operation Object names its models as that path does. + const pathModelBaseName = + pathModelBaseNames.get(sharedWith.get(key) ?? key) ?? derivePathModelBaseName(path, method); const servers = operationServers( extractServerList(op.servers, `operation ${operationLabel(op, path, method)}`), pathLevelServers, @@ -5128,33 +5131,44 @@ export function derivePathBasedName( } /** - * The `operationId` each operation whose own the document reuses names its - * models from, keyed by `${method} ${path}`: `null` for one that names them + * What the models of each operation whose `operationId` the document reuses + * are named from, keyed by `${method} ${path}`. `modelOperationIds` holds + * the name to read as its `operationId`, or `null` for one that names them * from its path, as an operation without an `operationId` does - * (resolvePathModelBaseNames). An operation not here names them from its - * own. Reused means declared on more than one Operation Object, which - * OpenAPI forbids: every such operation would otherwise name its models - * alike (`CreateNoteRequest`), and the merge would keep one body for all of - * them. + * (resolvePathModelBaseNames); an operation not there names them from its + * own. `sharedWith` holds, for a path that `$ref`s the Operation Object of + * another, the path whose models it shares. Reused means declared on more + * than one Operation Object, which OpenAPI forbids: every such operation + * would otherwise name its models alike (`CreateNoteRequest`), and the + * merge would keep one body for all of them. * - * Each claims the stem (operationIdModelBase) of the name it ends up with - * (planOperations): its `operationId`'s while it keeps the name it took from - * it, and its new name's when the reuse renamed it within its service — - * `createNote2` names its models `CreateNote2Request` beside `createNote`'s - * `CreateNoteRequest`. Claims are served in the order the numeric pass of + * Each Operation Object claims the stem (operationIdModelBase) of the name + * the first of its paths ends up with (planOperations): its `operationId`'s + * while the operation keeps the name it took from it, and its new name's + * when the reuse renamed it within its service — `createNote2` names its + * models `CreateNote2Request` beside `createNote`'s `CreateNoteRequest`. + * Paths and claims are taken in the order the numeric pass of * disambiguateOperationNames keeps a name in — the shorter API root, then * the path by code unit, then the method in HTTP_METHODS order — so the - * outcome never depends on document order, and a claim is granted while no - * earlier one, and no `operationId` declared on one Operation Object, holds - * its stem. An operation whose claim is not granted names its models from - * its path: one that kept the name in another service as well (`createNote` - * in `Notes` and in `ArchivedNotes`: `/archived-notes` sorts first, so - * `/notes` sends a `NotesPostRequest`), or one renamed to a name another - * `operationId` already names its models from. An `operationId` declared on - * one Operation Object is not reused, even when two paths `$ref` the Path - * Item it is on: both operations read one body, and share its models. - */ -function resolveReusedOperationIds(paths: Record, plan: OperationPlan): Map { + * outcome never depends on document order. The stems the document wrote + * are served first, each granted while no earlier claim and no `operationId` + * declared on one Operation Object holds it; then the new names, which the + * parser made, each granted, as a path-derived prefix is, only while no + * stem held starts with it: a renamed `getNote2` that returns a `request` + * object would name it `GetNote2ResponseRequest`, the request body of + * `operationId: getNote2Response`. An operation whose claim is not granted + * names its models from its path: one that kept the name in another service + * as well (`createNote` in `Notes` and in `ArchivedNotes`: `/archived-notes` + * sorts first, so `/notes` sends a `NotesPostRequest`), or one renamed to a + * name another `operationId` holds. Every other path of the Operation + * Object reads its one body and shares its models. An `operationId` + * declared on one Operation Object is not reused, even when two paths + * `$ref` the Path Item it is on. + */ +function resolveReusedOperationIds( + paths: Record, + plan: OperationPlan, +): { modelOperationIds: Map; sharedWith: Map } { const declaring = new Map>(); for (const [path, pathItem] of Object.entries(paths)) { for (const method of HTTP_METHODS) { @@ -5181,15 +5195,32 @@ function resolveReusedOperationIds(paths: Record, plan: Operat HTTP_METHODS.indexOf(a.method) - HTTP_METHODS.indexOf(b.method), ); + // One claim per Operation Object, made by the first of its paths. + const claimants = new Map(); + const sharedWith = new Map(); + for (const claim of claims) { + const first = claimants.get(claim.op); + if (first) sharedWith.set(claim.key, first.key); + else claimants.set(claim.op, claim); + } + const modelOperationIds = new Map(); - for (const { key, op, initialName, name } of claims) { - const renamed = name !== initialName; - const stem = operationIdModelBase(renamed ? name : op.operationId!); + for (const { key, op, initialName, name } of claimants.values()) { + if (name !== initialName) continue; + const stem = operationIdModelBase(op.operationId!); if (held.has(stem)) modelOperationIds.set(key, null); - else if (renamed) modelOperationIds.set(key, name); held.add(stem); } - return modelOperationIds; + for (const { key, initialName, name } of claimants.values()) { + if (name === initialName) continue; + const stem = operationIdModelBase(name); + modelOperationIds.set(key, [...held].some((taken) => taken.startsWith(stem)) ? null : name); + held.add(stem); + } + for (const [key, first] of sharedWith) { + if (modelOperationIds.has(first)) modelOperationIds.set(key, modelOperationIds.get(first)!); + } + return { modelOperationIds, sharedWith }; } /** @@ -5197,6 +5228,8 @@ function resolveReusedOperationIds(paths: Record, plan: Operat * models from its path — one without an `operationId`, or one whose * `operationId` the document reuses and leaves it none * (resolveReusedOperationIds) — keyed by `${method} ${path}`. + * A path that shares another's Operation Object (`sharedWith`) takes no part: + * it names its models as that path does. * * A prefix collides when two distinct raw paths with the same method flatten * to it (see derivePathModelBaseName), or when a reserved name starts with @@ -5246,6 +5279,7 @@ function resolvePathModelBaseNames( componentRequestBodies?: Record>, componentResponseNaming: ReadonlyMap = new Map(), modelOperationIds: ReadonlyMap = new Map(), + sharedWith: ReadonlyMap = new Map(), ): Map { const candidates: Array<{ key: string; path: string; method: HttpMethod; plain: string }> = []; const rawPathsByPlainName = new Map>(); @@ -5278,6 +5312,7 @@ function resolvePathModelBaseNames( const op = pathItem[method]; if (!op) continue; const key = `${method} ${path}`; + if (sharedWith.has(key)) continue; const modelOperationId = modelOperationIds.has(key) ? modelOperationIds.get(key) : op.operationId; if (modelOperationId) { reservedNames.add(operationIdModelBase(modelOperationId)); diff --git a/test/parser/inline-model-naming.test.ts b/test/parser/inline-model-naming.test.ts index 61103b7e..c67d0e3e 100644 --- a/test/parser/inline-model-naming.test.ts +++ b/test/parser/inline-model-naming.test.ts @@ -464,6 +464,95 @@ describe('an operationId two operations declare (issue #416)', () => { expect(warnSpy.mock.calls.map(([message]) => message)).toEqual([RENAMED_CREATE_NOTE]); }); + it("names a renamed operation's models from its path when another operationId's models start with its new name", async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const getNote = (properties: Record) => ({ + get: { operationId: 'getNote', responses: { '200': jsonResponse({ type: 'object', properties }) } }, + }); + + // Named after `getNote2`, the `request` object `/notes/drafts` returns + // would be `GetNote2ResponseRequest`: the body `getNote2Response` sends. + const out = await parseInline( + api({ + '/notes': getNote({ id: { type: 'string' } }), + '/notes/drafts': getNote({ request: inlineObject({ title: { type: 'string' } }) }), + '/other': { + post: { + operationId: 'getNote2Response', + requestBody: jsonBody(inlineObject({ label: { type: 'string' } })), + responses: { '204': { description: 'ok' } }, + }, + }, + }), + (ir) => ({ + models: ir.models.map(({ fields, name }) => `${name} {${fields.map((f) => f.name).join(', ')}}`), + operations: ir.services.flatMap((s) => + s.operations.map((op) => `${op.name}: ${summarize(op).requestBody ?? summarize(op).response}`), + ), + }), + ); + + expect(out).toMatchInlineSnapshot(` + { + "models": [ + "GetNoteResponse {id}", + "NotesDraftsGetResponse {request}", + "NotesDraftsGetResponseRequest {title}", + "GetNote2ResponseRequest {label}", + ], + "operations": [ + "getNote: GetNoteResponse", + "getNote2: NotesDraftsGetResponse", + "getNote2Response: GetNote2ResponseRequest", + ], + } + `); + expect(warnSpy.mock.calls.map(([message]) => message)).toEqual([ + '[oagen] Warning: Operations GET /notes and GET /notes/drafts in service Notes both take the name "getNote" from their operationId; the latter is renamed "getNote2" — give each a distinct operationId', + ]); + }); + + it('names every path of one Operation Object alike when another Operation Object reuses its operationId', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const listPets = (properties: Record) => ({ + get: { operationId: 'listPets', responses: { '200': jsonResponse(inlineObject(properties)) } }, + }); + + // `/other` sorts first and keeps the operationId's models; `/pets` names + // its models from its path, and `/pets/all`, which reads the same + // Operation Object, shares them although it is renamed `listPets2`. + const out = await parseInline( + api( + { + '/pets': { $ref: '#/components/pathItems/Pets' }, + '/pets/all': { $ref: '#/components/pathItems/Pets' }, + '/other': listPets({ title: { type: 'string' } }), + }, + { pathItems: { Pets: listPets({ name: { type: 'string' } }) } }, + ), + (ir) => ({ + models: ir.models.map(({ fields, name }) => `${name} {${fields.map((f) => f.name).join(', ')}}`), + operations: ir.services.flatMap((s) => + s.operations.map((op) => `${s.name}.${op.name} (${op.path}): ${summarize(op).response}`), + ), + }), + ); + + expect(out).toMatchInlineSnapshot(` + { + "models": [ + "PetsGetResponse {name}", + "ListPetsResponse {title}", + ], + "operations": [ + "Pets.listPets (/pets): PetsGetResponse", + "Pets.listPets2 (/pets/all): PetsGetResponse", + "Other.listPets (/other): ListPetsResponse", + ], + } + `); + }); + it('still shares the models of one Operation Object that two paths $ref', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); From d688a486984d5d73b780b69cb8ef932c2d16dde5 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 10:09:56 -0400 Subject: [PATCH 41/59] fix(parser)!: combine an array's items across allOf members as a property's declarations Since #360 every allOf merge combines two array declarations of one property through conjoinTypedDeclarations, which kept the later member's `items` whenever they had any keyword. An `items` with only a `description` declares no type, so a later member documenting the items of `data: Pet[]` made the field `unknown[]`, an explicit `dataPath` iterated `unknown`, and `oagen diff` reported a breaking `field-type-changed`. Two typed `items` did not combine either: the later replaced the earlier. The two `items` are now combined by combineDeclarations, the rule two declarations of a property follow: a typeless `items` only annotates the typed one, in either order; two inline objects conjoin (the items of `owners` are `{ name, email }`, as the object `owner` is); otherwise the earlier typed `items` stays (`string[]` then `integer[]` is `string[]`). When only one declaration has `items`, it is kept. A component's model, an inline merge and the list-envelope walk all read this one rule. Closes #431 --- docs/architecture/ir-types.md | 6 +-- src/parser/inline-models.ts | 26 ++++++++----- src/parser/pagination.ts | 4 +- test/parser/inline-allof-oneof.test.ts | 51 +++++++++++++++++++++++++ test/parser/inline-array-items.test.ts | 5 ++- test/parser/pagination-override.test.ts | 39 +++++++++++++++++++ 6 files changed, 114 insertions(+), 17 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 6f4d6ae0..e6a7f64c 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -171,7 +171,7 @@ Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. T Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context, so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and their children, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it, or an earlier variant of the union minted it; when the chosen context still mints a claimed name it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). -**`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the schema's own declaration kept where the variant declares the same property and the `required` names the union of both lists; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, two of arrays keep the `items` that say something, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it; a union no discriminator dispatches is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), two typing arrays keep the `items` that say something — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). +**`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the schema's own declaration kept where the variant declares the same property and the `required` names the union of both lists; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, two of arrays combine their `items` by this same rule, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it; a union no discriminator dispatches is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), two typing arrays combine their `items` by this same rule (`items: { description }` only annotates `items: { $ref: Pet }`, so `data` stays `Pet[]`) — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). ### UnionType @@ -271,7 +271,7 @@ It is detected from a recognized envelope — a boolean `has_more`/`hasMore`/`ha The `dataPath` is dynamically detected from the response envelope — when `undefined`, emitters should treat the entire response as the data array or use their own default. -When the parser recognizes an inline list envelope — one array property plus a companion that looks like pagination metadata (a stop field in any spelling — `has_more`, `HasMore`, `total`, … — or a name whose snake_case spelling contains `meta`, `pagination`, `cursor`, `page_info`, `total`, `offset`, …, `pageInfo` and `page_info` alike) — and a query parameter the operation declares drives the iterator (the one detection found, or the one an override names), `Operation.response` becomes the bare array, and `envelopeFields` keeps everything else the envelope carries; an envelope nothing pages stays one plain response model, with no `pagination` (see the strategy table above). The array property is an array in any spelling, and two `allOf` declarations of it — one nullable, one not; one with `items`, one with limits alone — merge as the arrays they wrap (the later `items` when they narrow, the earlier otherwise, `null` admitted when either side admits it): `type: 'array'`, `items` with no `type`, or either under a nullable wrapper — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, or a `oneOf`/`anyOf` with a `null` variant in any spelling (`{ type: 'null' }`, `{ const: null }`, `{ enum: [null] }`), with or without `items` beside it — in which case `response` is `nullable>` while `itemType` is still the element (`ListEventsItem[] | null`, `dataPath: 'data'`). Any of these, or a `$ref` to an array component, is still the array when written as an `allOf` that `schemaToTypeRef` reads as its member (`readInlineAllOf`: one member with nothing beside it that adds to it, or `$ref` members with nothing inline beside them), at any depth and on either side of a nullable wrapper, as a field of that schema is typed: `allOf: [{ type: 'array', items: Pet }]` is `Pet[]` and `allOf: [{ oneOf: [{ type: 'array', items: Pet }, { type: 'null' }] }]` is `Pet[] | null`, each with `dataPath: 'data'`. Its items, whatever their shape, are typed as `Item` (see Inline model naming). `envelopeFields` keeps everything else the envelope carries: the pagination companion (`list_metadata`, `pagination`, `has_more`, `total`, …) and any discriminant such as `object: "list"`. Each entry is an ordinary `Field` (wire name, type, `required`, description), so `dataPath` + `itemType` + `envelopeFields` together describe the full shape of what the endpoint returns, and an emitter can both type the response and read the page details it needs for the next request: +When the parser recognizes an inline list envelope — one array property plus a companion that looks like pagination metadata (a stop field in any spelling — `has_more`, `HasMore`, `total`, … — or a name whose snake_case spelling contains `meta`, `pagination`, `cursor`, `page_info`, `total`, `offset`, …, `pageInfo` and `page_info` alike) — and a query parameter the operation declares drives the iterator (the one detection found, or the one an override names), `Operation.response` becomes the bare array, and `envelopeFields` keeps everything else the envelope carries; an envelope nothing pages stays one plain response model, with no `pagination` (see the strategy table above). The array property is an array in any spelling, and two `allOf` declarations of it — one nullable, one not; one with `items`, one with limits alone — merge as the arrays they wrap (their `items` combined by the rule two declarations of a property follow, so a typeless `items` only annotates a typed one; `null` admitted when either side admits it): `type: 'array'`, `items` with no `type`, or either under a nullable wrapper — `type: ['array', 'null']`, OAS 3.0 `nullable: true`, or a `oneOf`/`anyOf` with a `null` variant in any spelling (`{ type: 'null' }`, `{ const: null }`, `{ enum: [null] }`), with or without `items` beside it — in which case `response` is `nullable>` while `itemType` is still the element (`ListEventsItem[] | null`, `dataPath: 'data'`). Any of these, or a `$ref` to an array component, is still the array when written as an `allOf` that `schemaToTypeRef` reads as its member (`readInlineAllOf`: one member with nothing beside it that adds to it, or `$ref` members with nothing inline beside them), at any depth and on either side of a nullable wrapper, as a field of that schema is typed: `allOf: [{ type: 'array', items: Pet }]` is `Pet[]` and `allOf: [{ oneOf: [{ type: 'array', items: Pet }, { type: 'null' }] }]` is `Pet[] | null`, each with `dataPath: 'data'`. Its items, whatever their shape, are typed as `Item` (see Inline model naming). `envelopeFields` keeps everything else the envelope carries: the pagination companion (`list_metadata`, `pagination`, `has_more`, `total`, …) and any discriminant such as `object: "list"`. Each entry is an ordinary `Field` (wire name, type, `required`, description), so `dataPath` + `itemType` + `envelopeFields` together describe the full shape of what the endpoint returns, and an emitter can both type the response and read the page details it needs for the next request: ```typescript // GET /customers?page=&limit= → { customers: Customer[] ("The customers on this page."), pagination: { page, totalPages } } @@ -761,7 +761,7 @@ An inline model that resolves to a component schema's name — a request body or An inline model never keeps a name an enum holds, whatever its shape or flag — an enum admits no model — so the model is always the one that steps, wherever the enum sits: a component enum, an inline enum lifted from a component schema's own fields, union variants or array items, or an inline enum an operation or webhook carries (a parameter's, a body's, a header's, one nested in a model). A request body or primary response named from its operation, a model nested under one, a list-envelope companion and a component field's object take the qualified name described above through the merge passes, which check every enum name in play (`reservedNamesByKind`): `GetPetResponse2` for the `getPet` body beside a component enum `GetPetResponse`; `CreatePetRequest2` for the `createPet` body beside a component enum `CreatePetRequest`, or beside a `request` query enum of its own service under that name (a `request` parameter object beside that enum steps in the shape registry to `CreatePetListPetsRequest`, and the enum keeps the name in both cases); `GetPetResponseOwner2` for the `owner` object nested under the `getPet` body beside a component enum `GetPetResponseOwner`; `ListPetsListMetadata` for the companion a `list_metadata` field of `listPets` mints beside a component enum `ListMetadata`; `PetOwner2` for the `owner` object of `Pet` beside a component enum `PetOwner`, settled with the fields before any operation is read; and `PetStatus2` for the `status` object of `Pet` beside a `status` query enum `PetStatus` of a `pet` service, settled at the merges once the enum is known — a header that shared the object's model follows it to the new name. Identical copies that must leave the name share one model under the new one, as they share the bare name: two identical `getPet` bodies are one `GetPetResponse2`, two identical bodies pinned by `object: widget` beside a component enum `Widget` one `GetAWidget`. The passes say so: `[oagen] Renamed inline model "GetPetResponse" → "GetPetResponse2": enum "GetPetResponse" owns the name`, with `(shared by 2 sites)` when two do. A parameter model, a header and a fallback, error or secondary body step past every enum name in play as they are named in the shape registry instead (see Response Headers and ErrorResponse), the names of the bodies that are themselves enums reserved first, so the same holds there: an object `404` body is `GetPetError404Body` beside an enum `404` body of another operation under the same stem, declared before or after it. The one enum that steps instead is one the collectors lift after a model of its name no pass can rename — a component model (`PetsMode2` for a `mode` parameter enum beside a component model `PetsMode`; see Response Headers). A name both a component schema and a component enum hold is the document's own clash and is settled as a component schema's. A model named like an enum that the parser could not rename — only a direct caller's — is kept beside the enum, with a warning. -**Inline `allOf`.** An inline schema composed with `allOf` is merged into one model by a single reading (`mergeAllOfMembers`), wherever it appears: a request body, a response body of every kind (primary, secondary media type, error, `default` and range), a response header, a webhook payload or parameter, a component or inline model's field, array items, map values, a union's `allOf` variant, and a field nested under a discriminated component variant. Members are read in document order. A `$ref` member contributes its component's properties (those of the component's own `allOf` chain included). A model built from the merge (`materializeMergedAllOf`: a field anywhere, a field nested under a discriminated component variant) types each inherited property as the component's model types it (`Base.meta` stays `BaseMeta`) and does not read into it again — the component being the one whose own model carries the field: an `allOf` component that inherits it, through a `$ref` or a nullable member, types everything reached inside its chain under its own name (`DogMeta`), while a bare alias (`Hop: { $ref: Base }`), a nullable alias (`Alias: { oneOf: [{ $ref: Base }, { type: 'null' }] }`) or a chain of either declares nothing of its own and builds no model, so a field reached through one is typed under the component that declares it (`Base.meta` is `BaseMeta` through `Alias`, never `AliasMeta`) — so a component that extends itself through a field (`Node.child: allOf: [{ $ref: Node }, …]`) is typed as the `NodeChild` that exists; a primary response body types inherited properties under its own name. A discriminated component variant's model is built as any inline object's is — its fields by the field extractor under the variant's name — so every shape under it (a nested object, typed or not; array items and map values of any shape; a union and its members; an `allOf` merge; an inline enum) is materialized under the name its type uses, and typing and extraction cannot disagree there. A ref no component schema answers contributes nothing (a response whose only `$ref` is unanswered is typed as that reference). A `oneOf`/`anyOf` member with exactly one non-null variant beside `null` is a nullable wrapper, not a union — decided by `nullableUnionBranch`, the definition `unwrapNullableSchema` collapses by — and applies as that variant wherever the member sits, a `$ref` member's component included: its `$ref` is followed, its `required` read and its properties always-applying, for the inline merge, for a component's `allOf` model (`readComponentAllOfMember`) and for a list envelope's companions (`collectObjectProperties`) alike, so `allOf: [{ oneOf: [{ $ref: Base }, { type: 'null' }] }, { properties: { data } }]` carries `Base`'s fields beside `data` (#308). An inline member contributes its own properties, those of its nested `allOf` members and — for a `oneOf`/`anyOf` member with more than one non-null variant — those of every inline object variant — read through the variant's own `allOf` members and nested unions the same way — as optional fields: `allOf: [{ properties: { name }, required: [name] }, { oneOf: [{ properties: { ownerId } }, { properties: { shelterId } }] }]` is `{ name, ownerId?, shelterId? }` inline exactly as it is as a component. A variant's `required` list is not read, since its properties are present only on that branch; a `$ref` under a variant — the variant itself, or a member of the variant's own `allOf` — is not followed (its component keeps its fields); and a union a `$ref` member's component carries is not read (that component's own model dispatches or flattens it). A property keeps the first always-applying declaration that declares a type, a typeless one before or after it only annotating it — two typing inline objects conjoin their properties and `required`, two typing arrays keep the `items` that say something, and a declaration conjoined from two components' is the merged model's own (`HolderValueResult`), since no component model nests the whole — whatever a variant says; a property only variants declare is typed as the union of its distinct declarations (documentation ignored): one shape is that declaration, several shapes are a `oneOf` of them read as any inline union is — `kind: { const: cat }` beside `kind: { const: dog }` becomes an inline enum `Kind`, `const: true` beside `const: false` a union of literals, two inline objects a union of two variant models — so a field is never typed from one variant's value alone. A field reads its flags, documentation and limits from its own schema, never from a union's variants, so the combined declaration carries every such keyword the declarations agree on, value for value — `readOnly`, `writeOnly`, `deprecated`, `description`, `default`, `example`, `examples`, `externalDocs` and the validation keywords: two `kind` declarations both `readOnly: true` with different consts make a read-only enum field; a keyword the declarations disagree on, or only some declare, is not the field's. A `null` any declaration admits (`nullable: true` or a `type` list naming it) is a value the property takes on that branch, so the combined declaration admits it (`nullable`, `nullable`). The declarations themselves are kept as written inside the union, so an enum member or a variant model still carries its own description and limits. A schema reached twice is read once in each capacity it is reached in — as an inline member (unions included), through a `$ref` member (its unions left to its component) or under a variant — and only an inline reading stands for the others: an object shared between a variant and a member (a YAML anchor, an object a `transformSpec` hook reuses, a `$ref` written twice) still contributes its `required`, and is still followed, when the member is reached; a component's own object reused as an inline member (`allOf: [{ $ref: Shared }, Shared]`) still has its union read after the `$ref` reading left it to the component. An inline `allOf` is always flattened: the dispatcher form — a `discriminator` whose mapping names one model per variant — is built for component schemas only. How an inline `allOf` is read is one decision made everywhere — by the type builder, the name predictor, both extractors and the primary response body path (`readInlineAllOf`) — so a model is built exactly when a reference names it: a `$ref` member beside an inline object member (`type: 'object'`, or properties declared inline — its own, a nested `allOf` member's or a `oneOf`/`anyOf` variant's: `declaresInlineProperties`), or several inline members one of which declares properties, is the merged model `` (`allOf: [{ $ref: Base }, { oneOf: [...] }]` carries `Base`'s fields and the variants', not a bare reference to `Base`); a single non-`$ref` member with nothing beside it that adds to it is unwrapped and read as the field or body itself, the wrapper's limits, `deprecated` and link carried onto it (a `required` the wrapper writes beside a lone member that declares properties adds to it, so `{ allOf: [{ properties: { name } }], required: [name] }` is the merged model with `name` required; beside a lone array, primitive or enum member `required` constrains nothing and the member is unwrapped); a single `$ref` member with nothing beside it that adds to the component (`allOf: [{ $ref: Base }]`, `allOf: [{ $ref: Base }, { description }]`, `allOf: [{ $ref: Base }, { maxItems: 10 }]`) is a reference to that component, and nothing is minted — a component the document declares under the nested name is never replaced by an unused merged copy, and a response body written so is `Base`, not a copy of `Base` under the operation's name. A second `$ref` member, or a `required` list beside the `$ref` — the schema's own, or a member's — adds to the component, so `allOf: [{ $ref: Pet }, { $ref: Owner }]` and `allOf: [{ $ref: Pet }], required: [name]` are the merged model (``, `Response`), carrying every component's properties and every `required`, at every site alike. A `required` adds to the component only when the component declares properties the merge reads — its own, or its `allOf` chain's: JSON Schema applies `required` to an object instance only, so beside a `$ref` to an array, a primitive, an enum or a union component it constrains nothing, and the reference stands (`{ allOf: [{ $ref: Pets }], required: [name] }` with `Pets` an array component is `Pet[]`, as `{ allOf: [{ $ref: Pets }] }` is). A merged model is built with whatever fields the merge yields: an empty `properties: {}` is an inline object with no fields, as it is as a plain field or a union variant, so `{ allOf: [{ oneOf: [{ type: 'object', properties: {} }] }, { description }] }` is a model with no fields rather than a reference nothing creates. +**Inline `allOf`.** An inline schema composed with `allOf` is merged into one model by a single reading (`mergeAllOfMembers`), wherever it appears: a request body, a response body of every kind (primary, secondary media type, error, `default` and range), a response header, a webhook payload or parameter, a component or inline model's field, array items, map values, a union's `allOf` variant, and a field nested under a discriminated component variant. Members are read in document order. A `$ref` member contributes its component's properties (those of the component's own `allOf` chain included). A model built from the merge (`materializeMergedAllOf`: a field anywhere, a field nested under a discriminated component variant) types each inherited property as the component's model types it (`Base.meta` stays `BaseMeta`) and does not read into it again — the component being the one whose own model carries the field: an `allOf` component that inherits it, through a `$ref` or a nullable member, types everything reached inside its chain under its own name (`DogMeta`), while a bare alias (`Hop: { $ref: Base }`), a nullable alias (`Alias: { oneOf: [{ $ref: Base }, { type: 'null' }] }`) or a chain of either declares nothing of its own and builds no model, so a field reached through one is typed under the component that declares it (`Base.meta` is `BaseMeta` through `Alias`, never `AliasMeta`) — so a component that extends itself through a field (`Node.child: allOf: [{ $ref: Node }, …]`) is typed as the `NodeChild` that exists; a primary response body types inherited properties under its own name. A discriminated component variant's model is built as any inline object's is — its fields by the field extractor under the variant's name — so every shape under it (a nested object, typed or not; array items and map values of any shape; a union and its members; an `allOf` merge; an inline enum) is materialized under the name its type uses, and typing and extraction cannot disagree there. A ref no component schema answers contributes nothing (a response whose only `$ref` is unanswered is typed as that reference). A `oneOf`/`anyOf` member with exactly one non-null variant beside `null` is a nullable wrapper, not a union — decided by `nullableUnionBranch`, the definition `unwrapNullableSchema` collapses by — and applies as that variant wherever the member sits, a `$ref` member's component included: its `$ref` is followed, its `required` read and its properties always-applying, for the inline merge, for a component's `allOf` model (`readComponentAllOfMember`) and for a list envelope's companions (`collectObjectProperties`) alike, so `allOf: [{ oneOf: [{ $ref: Base }, { type: 'null' }] }, { properties: { data } }]` carries `Base`'s fields beside `data` (#308). An inline member contributes its own properties, those of its nested `allOf` members and — for a `oneOf`/`anyOf` member with more than one non-null variant — those of every inline object variant — read through the variant's own `allOf` members and nested unions the same way — as optional fields: `allOf: [{ properties: { name }, required: [name] }, { oneOf: [{ properties: { ownerId } }, { properties: { shelterId } }] }]` is `{ name, ownerId?, shelterId? }` inline exactly as it is as a component. A variant's `required` list is not read, since its properties are present only on that branch; a `$ref` under a variant — the variant itself, or a member of the variant's own `allOf` — is not followed (its component keeps its fields); and a union a `$ref` member's component carries is not read (that component's own model dispatches or flattens it). A property keeps the first always-applying declaration that declares a type, a typeless one before or after it only annotating it — two typing inline objects conjoin their properties and `required`, two typing arrays combine their `items` by this same rule, and a declaration conjoined from two components' is the merged model's own (`HolderValueResult`), since no component model nests the whole — whatever a variant says; a property only variants declare is typed as the union of its distinct declarations (documentation ignored): one shape is that declaration, several shapes are a `oneOf` of them read as any inline union is — `kind: { const: cat }` beside `kind: { const: dog }` becomes an inline enum `Kind`, `const: true` beside `const: false` a union of literals, two inline objects a union of two variant models — so a field is never typed from one variant's value alone. A field reads its flags, documentation and limits from its own schema, never from a union's variants, so the combined declaration carries every such keyword the declarations agree on, value for value — `readOnly`, `writeOnly`, `deprecated`, `description`, `default`, `example`, `examples`, `externalDocs` and the validation keywords: two `kind` declarations both `readOnly: true` with different consts make a read-only enum field; a keyword the declarations disagree on, or only some declare, is not the field's. A `null` any declaration admits (`nullable: true` or a `type` list naming it) is a value the property takes on that branch, so the combined declaration admits it (`nullable`, `nullable`). The declarations themselves are kept as written inside the union, so an enum member or a variant model still carries its own description and limits. A schema reached twice is read once in each capacity it is reached in — as an inline member (unions included), through a `$ref` member (its unions left to its component) or under a variant — and only an inline reading stands for the others: an object shared between a variant and a member (a YAML anchor, an object a `transformSpec` hook reuses, a `$ref` written twice) still contributes its `required`, and is still followed, when the member is reached; a component's own object reused as an inline member (`allOf: [{ $ref: Shared }, Shared]`) still has its union read after the `$ref` reading left it to the component. An inline `allOf` is always flattened: the dispatcher form — a `discriminator` whose mapping names one model per variant — is built for component schemas only. How an inline `allOf` is read is one decision made everywhere — by the type builder, the name predictor, both extractors and the primary response body path (`readInlineAllOf`) — so a model is built exactly when a reference names it: a `$ref` member beside an inline object member (`type: 'object'`, or properties declared inline — its own, a nested `allOf` member's or a `oneOf`/`anyOf` variant's: `declaresInlineProperties`), or several inline members one of which declares properties, is the merged model `` (`allOf: [{ $ref: Base }, { oneOf: [...] }]` carries `Base`'s fields and the variants', not a bare reference to `Base`); a single non-`$ref` member with nothing beside it that adds to it is unwrapped and read as the field or body itself, the wrapper's limits, `deprecated` and link carried onto it (a `required` the wrapper writes beside a lone member that declares properties adds to it, so `{ allOf: [{ properties: { name } }], required: [name] }` is the merged model with `name` required; beside a lone array, primitive or enum member `required` constrains nothing and the member is unwrapped); a single `$ref` member with nothing beside it that adds to the component (`allOf: [{ $ref: Base }]`, `allOf: [{ $ref: Base }, { description }]`, `allOf: [{ $ref: Base }, { maxItems: 10 }]`) is a reference to that component, and nothing is minted — a component the document declares under the nested name is never replaced by an unused merged copy, and a response body written so is `Base`, not a copy of `Base` under the operation's name. A second `$ref` member, or a `required` list beside the `$ref` — the schema's own, or a member's — adds to the component, so `allOf: [{ $ref: Pet }, { $ref: Owner }]` and `allOf: [{ $ref: Pet }], required: [name]` are the merged model (``, `Response`), carrying every component's properties and every `required`, at every site alike. A `required` adds to the component only when the component declares properties the merge reads — its own, or its `allOf` chain's: JSON Schema applies `required` to an object instance only, so beside a `$ref` to an array, a primitive, an enum or a union component it constrains nothing, and the reference stands (`{ allOf: [{ $ref: Pets }], required: [name] }` with `Pets` an array component is `Pet[]`, as `{ allOf: [{ $ref: Pets }] }` is). A merged model is built with whatever fields the merge yields: an empty `properties: {}` is an inline object with no fields, as it is as a plain field or a union variant, so `{ allOf: [{ oneOf: [{ type: 'object', properties: {} }] }, { description }] }` is a model with no fields rather than a reference nothing creates. ### Field Examples diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index 6ef81e4f..efa2c921 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -2030,14 +2030,18 @@ export function declaresType(schema: SchemaObject): boolean { * earlier's; two arrays, each under a nullable wrapper of any spelling or * none (`arraySchemaOf`), merge as the bare arrays they wrap, so the `items` * land in the one array that is then read, whichever declaration wore the - * wrapper: the later `items` when they say something and the earlier when - * the later declaration only constrains the array (`minItems: 1`) or allows - * any item (`items: {}`), so `data: User[]` is not downgraded in either - * order; the later's other keywords over the earlier's; and `type: - * ['array', 'null']` when either admits `null` (`declaresNull`), else - * `type: 'array'`. Any other pair keeps the first as written. Returns - * `held` itself when `next` adds nothing to it, so a lone declaration keeps - * its identity. + * wrapper: the two `items` combined by this same rule when both declare + * them, else the one declared — so `data: User[]` is not downgraded in + * either order by `items` that declare no type (`{ description }`, `{}`) + * or by a declaration that only constrains the array (`minItems: 1`), two + * inline object `items` conjoin, and `string[]` then `integer[]` stays + * `string[]` (workos/oagen#431: the later `items` replaced the earlier + * whenever they had any keyword, so a member documenting the items made + * `data` an `unknown[]`); the later's other keywords over the earlier's; + * and `type: ['array', 'null']` when either admits `null` + * (`declaresNull`), else `type: 'array'`. Any other pair keeps the first + * as written. Returns `held` itself when `next` adds nothing to it, so a + * lone declaration keeps its identity. */ export function combineDeclarations(held: SchemaObject, next: SchemaObject): SchemaObject { if (!declaresType(held) && declaresType(next)) return { ...held, ...next }; @@ -2061,8 +2065,10 @@ function conjoinTypedDeclarations(first: SchemaObject, second: SchemaObject): Sc const firstArray = arraySchemaOf(first); const secondArray = firstArray && arraySchemaOf(second); if (firstArray && secondArray) { - const says = (items: SchemaObject | undefined): boolean => items !== undefined && Object.keys(items).length > 0; - const items = says(secondArray.items) ? secondArray.items : firstArray.items; + const items = + firstArray.items && secondArray.items + ? combineDeclarations(firstArray.items, secondArray.items) + : (secondArray.items ?? firstArray.items); const { nullable: _firstFlag, ...firstBare } = firstArray; const { nullable: _secondFlag, ...secondBare } = secondArray; return { diff --git a/src/parser/pagination.ts b/src/parser/pagination.ts index 3d06de5f..56228b53 100644 --- a/src/parser/pagination.ts +++ b/src/parser/pagination.ts @@ -586,8 +586,8 @@ function objectProperties( * merge settles it for a model's field (`combineDeclarations`): two inline * objects merge their properties — `pagination.next` from one member and * `pagination.total` from another both belong to the response, as they do - * to the wrapper's model — two arrays keep the `items` that say something, - * and otherwise the first declaration that declares a type types it, a + * to the wrapper's model — two arrays combine their `items` by the same + * rule, and otherwise the first declaration that declares a type types it, a * typeless one only annotating it. (Envelope detection reads the * members' own `properties`; a `oneOf`/`anyOf` member with more than one * non-null variant declares none, so it contributes no property here; it is diff --git a/test/parser/inline-allof-oneof.test.ts b/test/parser/inline-allof-oneof.test.ts index f58c1163..d4a1907f 100644 --- a/test/parser/inline-allof-oneof.test.ts +++ b/test/parser/inline-allof-oneof.test.ts @@ -1586,6 +1586,57 @@ describe('two typing declarations of one name conjoin as JSON Schema applies bot expect(first.properties[0]!.schema).toEqual({ type: 'string' }); }); + it('combines the items of two arrays as two declarations of a property, in either order (#431)', async () => { + const arrayOf = (itemSchema: SchemaObject): SchemaObject => ({ type: 'array', items: itemSchema }); + // The issue's `PetPage`: the second member only describes the items of + // `data`, declares `tags` again as `integer[]`, and gives the items of + // `owners` and the object `owner` a property each. + const first = obj({ + data: arrayOf({ $ref: '#/components/schemas/Pet' }), + tags: arrayOf({ type: 'string' }), + owners: arrayOf(obj({ name: { type: 'string' } })), + owner: obj({ name: { type: 'string' } }), + }); + const second = obj({ + data: { ...arrayOf({ description: 'A pet' }), description: 'The pets on this page' }, + tags: arrayOf({ type: 'integer' }), + owners: arrayOf(obj({ email: { type: 'string' } })), + owner: obj({ email: { type: 'string' } }), + }); + const ir = await parseWithoutWarnings({ + openapi: '3.1.0', + info, + paths: {}, + components: { + schemas: { + Pet: obj({ name: { type: 'string' } }), + PetPage: { allOf: [first, second] }, + PagePet: { allOf: [second, first] }, + }, + }, + }); + const models = modelsByName(ir); + const typeOf = (model: string, field: string) => models[model]?.fields.find((f) => f.name === field)?.type; + const arrayOfType = (items: unknown) => ({ kind: 'array', items }); + for (const [name, ownerFields] of [ + ['PetPage', ['name?: string', 'email?: string']], + ['PagePet', ['email?: string', 'name?: string']], + ] as const) { + // A typeless `items` only annotates the typed one. + expect(typeOf(name, 'data'), name).toEqual(arrayOfType(model('Pet'))); + // Two typed primitives keep the first. + expect(typeOf(name, 'tags'), name).toEqual( + arrayOfType({ kind: 'primitive', type: name === 'PetPage' ? 'string' : 'integer' }), + ); + // Two inline objects conjoin, as the same two objects as a property do, + // so the items are the very shape of `owner` and share its model. + expect(typeOf(name, 'owner'), name).toEqual(model(`${name}Owner`)); + expect(fieldsOf(models[`${name}Owner`]), name).toEqual(ownerFields); + expect(typeOf(name, 'owners'), name).toEqual(arrayOfType(model(`${name}Owner`))); + } + expectSound(ir, ['Pet', 'PetPage', 'PagePet']); + }); + it("builds a conjoined object as the merged model's own and the component's own, so the field names a model that has both parts", async () => { const A = obj({ result: items }); const B = obj({ result: total }); diff --git a/test/parser/inline-array-items.test.ts b/test/parser/inline-array-items.test.ts index 586a23b6..1a3f9ad3 100644 --- a/test/parser/inline-array-items.test.ts +++ b/test/parser/inline-array-items.test.ts @@ -796,7 +796,8 @@ describe('review round 1', () => { ['nullable first', nullable({}), plain(pet), petList], // Nullable last: the later `items: {}` narrows nothing, so the earlier `pet` stays. ['nullable last', plain(pet), nullable({}), petList], - // A later declaration that narrows wins whichever side is nullable. + // A later declaration that narrows conjoins with the earlier whichever side + // is nullable: two inline object items merge their properties (#431). [ 'nullable last, narrowed', plain(pet), @@ -813,7 +814,7 @@ describe('review round 1', () => { expect(result.response, label).toEqual(response); expect(result.itemType, label).toEqual({ kind: 'model', name: 'ListPetsItem' }); const fields = result.inlineModels.find((m) => m.name === 'ListPetsItem')?.fields.map((f) => f.name); - expect(fields, label).toEqual(label === 'nullable last, narrowed' ? ['text'] : ['name']); + expect(fields, label).toEqual(label === 'nullable last, narrowed' ? ['name', 'text'] : ['name']); } // Neither side nullable: a plain array, the later items winning. const plainResult = classifyAndExtractResponse(envelope(plain({}), plain(pet)), 'ListPetsResponse'); diff --git a/test/parser/pagination-override.test.ts b/test/parser/pagination-override.test.ts index 0b17e9f6..21570e31 100644 --- a/test/parser/pagination-override.test.ts +++ b/test/parser/pagination-override.test.ts @@ -1926,6 +1926,45 @@ describe('parseSpec — page pagination and x-pagination overrides', () => { expect(warn).not.toHaveBeenCalled(); }); + it('keeps the data array’s items against a member that only describes them, in the model and in the read alike (#431)', async () => { + // The issue's `PetPage`: one member declares `data: Pet[]`, another only + // documents the array and its items. A typeless `items` only annotates + // the typed one, whichever member comes first. + const typed = { type: 'object', properties: { data: petArray, has_more: { type: 'boolean' } } }; + const described = { + type: 'object', + properties: { data: { type: 'array', description: 'The pets on this page', items: { description: 'A pet' } } }, + }; + const pets = { kind: 'array', items: { kind: 'model', name: 'Pet' } }; + const expected = { + ...page, + dataPath: 'data', + endOfIteration: { kind: 'hasMore', path: 'has_more' }, + itemType: 'Pet', + }; + for (const [label, members] of [ + ['typed first', [typed, described]], + ['described first', [described, typed]], + ] as const) { + const PetPage = { allOf: members }; + const referencedIr = await parseJson( + listOp({ $ref: '#/components/schemas/PetPage' }, { dataPath: 'data' }, { PetPage }), + ); + const referenced = onlyOperation(referencedIr); + const data = referencedIr.models.find((m) => m.name === 'PetPage')?.fields.find((f) => f.name === 'data'); + expect(data?.type, `${label}: PetPage.data`).toEqual(pets); + expect(projection(referenced), `${label}: $ref wrapper`).toEqual(expected); + const inline = onlyOperation(await parseJson(listOp(PetPage, { dataPath: 'data' }))); + expect(inline.response, `${label}: inline wrapper`).toEqual(pets); + expect(projection(inline), `${label}: inline wrapper`).toEqual({ + ...expected, + dataDescription: 'The pets on this page', + envelopeFields: ['has_more'], + }); + } + expect(warn).not.toHaveBeenCalled(); + }); + it('names a stop field the wrapper model declares when two members declare the parent object (Greptile)', async () => { // Two `allOf` members declare `result`: the first with the array, the // second with the stop field. The model's `result` conjoins both, as the From d4d66a4ab0f0dd686a1707045d8b7f1d328842cb Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 10:35:32 -0400 Subject: [PATCH 42/59] fix(parser)!: conjoin an object $ref and an inline object declaring one property Since #360 every allOf merge and the property walk behind a `dataPath` settle a name two members declare through combineDeclarations. Its conjoinTypedDeclarations conjoined two inline objects only and kept the first declaration for any other pair, so a `$ref` to an object component and an inline object were never conjoined: `result` was `PetPage { items }` or `{ total_pages }`, never both, and an `x-pagination` dataPath through the later declaration was rejected with a warning. An inline object beside a `$ref` to a component that declares properties (its own or its allOf chain's), or beside an allOf that reaches one, now becomes the allOf of both, in document order. Every reader reads it as any inline allOf: the field is the merged `Result { items, total_pages }` in either member order, its inherited fields typed as the component types them, and the walk reaches `result.items` and `result.total_pages` through it, so `itemType` is `Pet` and `endOfIteration` is `result.total_pages`. The keywords a field reads from its own schema (`description`, `readOnly`, ...) and the extensions of either declaration ride on the allOf, the later's over the earlier's. A `$ref` to an array, enum, primitive or union component is no object to conjoin, and the first declaration stays. Closes #432 --- docs/architecture/ir-types.md | 6 +- src/parser/inline-models.ts | 77 +++++++++++++++++------ src/parser/pagination.ts | 7 ++- test/parser/inline-allof-oneof.test.ts | 83 +++++++++++++++++++++++++ test/parser/pagination-override.test.ts | 79 +++++++++++++++++++++++ 5 files changed, 229 insertions(+), 23 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index e6a7f64c..b94c3649 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -81,7 +81,7 @@ The specification extensions (`x-*` keys) an OpenAPI object declares, each under **Source per node.** `Operation.extensions` is the Operation Object's own keys. `Model.extensions` is the keys of the schema the model was built from — a component's own, or the inline schema's for a request body, a response, a `oneOf` variant (each variant model carries its own variant's keys), an object nested in a field, array items; an `allOf` wrapper's own keys, a member's staying on that member and a `$ref` member's on its component, for a component `allOf`, an inline `allOf` merged into one model and an `allOf` response alike; a discriminated `oneOf` body merged into one model carries the wrapper's keys, not a variant's; a discriminated `allOf` + `oneOf` component's variant models carry each variant's own. `Field.extensions` is the field schema's own keys, read like `constraints`: keys written beside a `$ref` (OAS 3.1) are the field's, a bare `$ref` yields none — the referenced model, union or enum carries its own — unless it names an array component, whose keys the field then carries as it carries that component's `description`. A list envelope's `envelopeFields` carry theirs the same way. -**Several declarations of one property.** A property several `allOf` members always declare is typed by the first always-applying declaration that declares a type, its extensions included; a later typing declaration is not conjoined into it, and a typeless one fills in only the keys the typing one leaves unset (`combineDeclarations`) — the precedence every keyword follows. A property only `oneOf`/`anyOf` variants declare carries exactly the keys every declaration as written agrees on, value for value — one reading, `agreedExtensions`, at every site that rebuilds or widens a property from several declarations without changing which declaration types it: `unionOfDeclarations` for an inline `allOf`; `declaredComponentProperty` for a component (a bare `oneOf` component or a union member of a component `allOf`, whose field is typed from the first branch); `mergeBranchPropertySchemas` for the nested sub-branches of a discriminated `allOf` + `oneOf` variant, where boolean or string consts widen to a plain boolean or string; and `flattenDiscriminatedUnionBody` for a discriminated inline request body, whose discriminator is rebuilt as an enum of the pinned consts and whose other properties are typed from the first variant that declares them — whether the declarations share one shape or become a union: a key any declaration leaves unset or writes another value for is nobody's, and a declaration that repeats an earlier shape still has its say — three variants writing `x-since` as `1`, `2` and `1` leave it off. The lifted keywords (`description`, `readOnly`, the limits, …) are read by the same agreement when the declarations become a union; when they share one shape, the first declaration gives them. `Enum.extensions` is the keys of the schema that declares the members: a component enum's own, or, for an enum lifted from an inline schema, that schema's — the field, parameter or header the schema types carries the same keys, since they are its schema's own — read as the enum's `description` and `externalDocs` are, through `collectInlineEnumFromRef`; when several inline schemas lift to one name, the first lifted gives the keys, as it gives the values. +**Several declarations of one property.** A property several `allOf` members always declare is typed by the first always-applying declaration that declares a type, its extensions included; a later typing declaration adds to it only where the two are conjoined, as JSON Schema applies both — two inline objects, an inline object and a `$ref` to an object component, two arrays (see Inline `allOf`) — the later's keys then over the earlier's, and a typeless one fills in only the keys the typing one leaves unset (`combineDeclarations`) — the precedence every keyword follows. A property only `oneOf`/`anyOf` variants declare carries exactly the keys every declaration as written agrees on, value for value — one reading, `agreedExtensions`, at every site that rebuilds or widens a property from several declarations without changing which declaration types it: `unionOfDeclarations` for an inline `allOf`; `declaredComponentProperty` for a component (a bare `oneOf` component or a union member of a component `allOf`, whose field is typed from the first branch); `mergeBranchPropertySchemas` for the nested sub-branches of a discriminated `allOf` + `oneOf` variant, where boolean or string consts widen to a plain boolean or string; and `flattenDiscriminatedUnionBody` for a discriminated inline request body, whose discriminator is rebuilt as an enum of the pinned consts and whose other properties are typed from the first variant that declares them — whether the declarations share one shape or become a union: a key any declaration leaves unset or writes another value for is nobody's, and a declaration that repeats an earlier shape still has its say — three variants writing `x-since` as `1`, `2` and `1` leave it off. The lifted keywords (`description`, `readOnly`, the limits, …) are read by the same agreement when the declarations become a union; when they share one shape, the first declaration gives them. `Enum.extensions` is the keys of the schema that declares the members: a component enum's own, or, for an enum lifted from an inline schema, that schema's — the field, parameter or header the schema types carries the same keys, since they are its schema's own — read as the enum's `description` and `externalDocs` are, through `collectInlineEnumFromRef`; when several inline schemas lift to one name, the first lifted gives the keys, as it gives the values. **Collapsed wrappers.** A nullable `oneOf`/`anyOf` or single-member `allOf` wrapper the parser collapses carries its documentation layer onto what it unwraps to by one rule, `withWrapperDocs`, applied at every collapse step — by the type builder (`schemaToTypeRef`), by the model extractors (`unwrapNullableField` through `unwrapNullableSchema`, `unwrappedAllOfMember`) and by the response path — so the model built from the branch, the enum lifted from it and the field the wrapper types document themselves alike. The layer is every keyword that documents a schema without shaping it, the schema-side counterpart of `FIELD_DOC_KEYS`: `description` (when it has content), `externalDocs` (when usable), `example` (when the key is present), `examples` (when non-empty) and the `x-*` keys one by one, the consumed ones left out. The wrapper's value wins wherever both declare one and the branch keeps its own where the wrapper has none. Nested wrappers compose outermost-first: an outer wrapper's keys land on the inner wrapper before the inner wrapper's land on the branch, so with `x-since` on the outer and `x-owner` on the inner the branch carries both and an `x-since` on both is the outer's. `oneOf: [{ enum: [...] }, { type: "null" }]` and `type: ["string", "null"]` give the same enum the same keys. The field the wrapper types carries the outermost wrapper's keys alone, since that wrapper is its schema. @@ -171,7 +171,7 @@ Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. T Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context, so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and their children, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it, or an earlier variant of the union minted it; when the chosen context still mints a claimed name it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). -**`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the schema's own declaration kept where the variant declares the same property and the `required` names the union of both lists; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, two of arrays combine their `items` by this same rule, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it; a union no discriminator dispatches is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), two typing arrays combine their `items` by this same rule (`items: { description }` only annotates `items: { $ref: Pet }`, so `data` stays `Pet[]`) — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). +**`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the schema's own declaration kept where the variant declares the same property and the `required` names the union of both lists; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both, two of arrays combine their `items` by this same rule, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it; a union no discriminator dispatches is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), an inline object and a `$ref` to a component that declares properties (its own or its `allOf` chain's), or an `allOf` that reaches one, conjoin as the `allOf` of both, read as an inline `allOf` is (`result: { $ref: PetPage }` beside `result: { properties: { total_pages } }` is a `PageResult` with `PetPage`'s `items` and `total_pages`, whichever member comes first, `items` typed as `PetPage` types it, and the flags and documentation either declaration gives the field stay the field's), while a `$ref` to an array, enum, primitive or union component is no object to conjoin and the first declaration stays; two typing arrays combine their `items` by this same rule (`items: { description }` only annotates `items: { $ref: Pet }`, so `data` stays `Pet[]`) — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). ### UnionType @@ -761,7 +761,7 @@ An inline model that resolves to a component schema's name — a request body or An inline model never keeps a name an enum holds, whatever its shape or flag — an enum admits no model — so the model is always the one that steps, wherever the enum sits: a component enum, an inline enum lifted from a component schema's own fields, union variants or array items, or an inline enum an operation or webhook carries (a parameter's, a body's, a header's, one nested in a model). A request body or primary response named from its operation, a model nested under one, a list-envelope companion and a component field's object take the qualified name described above through the merge passes, which check every enum name in play (`reservedNamesByKind`): `GetPetResponse2` for the `getPet` body beside a component enum `GetPetResponse`; `CreatePetRequest2` for the `createPet` body beside a component enum `CreatePetRequest`, or beside a `request` query enum of its own service under that name (a `request` parameter object beside that enum steps in the shape registry to `CreatePetListPetsRequest`, and the enum keeps the name in both cases); `GetPetResponseOwner2` for the `owner` object nested under the `getPet` body beside a component enum `GetPetResponseOwner`; `ListPetsListMetadata` for the companion a `list_metadata` field of `listPets` mints beside a component enum `ListMetadata`; `PetOwner2` for the `owner` object of `Pet` beside a component enum `PetOwner`, settled with the fields before any operation is read; and `PetStatus2` for the `status` object of `Pet` beside a `status` query enum `PetStatus` of a `pet` service, settled at the merges once the enum is known — a header that shared the object's model follows it to the new name. Identical copies that must leave the name share one model under the new one, as they share the bare name: two identical `getPet` bodies are one `GetPetResponse2`, two identical bodies pinned by `object: widget` beside a component enum `Widget` one `GetAWidget`. The passes say so: `[oagen] Renamed inline model "GetPetResponse" → "GetPetResponse2": enum "GetPetResponse" owns the name`, with `(shared by 2 sites)` when two do. A parameter model, a header and a fallback, error or secondary body step past every enum name in play as they are named in the shape registry instead (see Response Headers and ErrorResponse), the names of the bodies that are themselves enums reserved first, so the same holds there: an object `404` body is `GetPetError404Body` beside an enum `404` body of another operation under the same stem, declared before or after it. The one enum that steps instead is one the collectors lift after a model of its name no pass can rename — a component model (`PetsMode2` for a `mode` parameter enum beside a component model `PetsMode`; see Response Headers). A name both a component schema and a component enum hold is the document's own clash and is settled as a component schema's. A model named like an enum that the parser could not rename — only a direct caller's — is kept beside the enum, with a warning. -**Inline `allOf`.** An inline schema composed with `allOf` is merged into one model by a single reading (`mergeAllOfMembers`), wherever it appears: a request body, a response body of every kind (primary, secondary media type, error, `default` and range), a response header, a webhook payload or parameter, a component or inline model's field, array items, map values, a union's `allOf` variant, and a field nested under a discriminated component variant. Members are read in document order. A `$ref` member contributes its component's properties (those of the component's own `allOf` chain included). A model built from the merge (`materializeMergedAllOf`: a field anywhere, a field nested under a discriminated component variant) types each inherited property as the component's model types it (`Base.meta` stays `BaseMeta`) and does not read into it again — the component being the one whose own model carries the field: an `allOf` component that inherits it, through a `$ref` or a nullable member, types everything reached inside its chain under its own name (`DogMeta`), while a bare alias (`Hop: { $ref: Base }`), a nullable alias (`Alias: { oneOf: [{ $ref: Base }, { type: 'null' }] }`) or a chain of either declares nothing of its own and builds no model, so a field reached through one is typed under the component that declares it (`Base.meta` is `BaseMeta` through `Alias`, never `AliasMeta`) — so a component that extends itself through a field (`Node.child: allOf: [{ $ref: Node }, …]`) is typed as the `NodeChild` that exists; a primary response body types inherited properties under its own name. A discriminated component variant's model is built as any inline object's is — its fields by the field extractor under the variant's name — so every shape under it (a nested object, typed or not; array items and map values of any shape; a union and its members; an `allOf` merge; an inline enum) is materialized under the name its type uses, and typing and extraction cannot disagree there. A ref no component schema answers contributes nothing (a response whose only `$ref` is unanswered is typed as that reference). A `oneOf`/`anyOf` member with exactly one non-null variant beside `null` is a nullable wrapper, not a union — decided by `nullableUnionBranch`, the definition `unwrapNullableSchema` collapses by — and applies as that variant wherever the member sits, a `$ref` member's component included: its `$ref` is followed, its `required` read and its properties always-applying, for the inline merge, for a component's `allOf` model (`readComponentAllOfMember`) and for a list envelope's companions (`collectObjectProperties`) alike, so `allOf: [{ oneOf: [{ $ref: Base }, { type: 'null' }] }, { properties: { data } }]` carries `Base`'s fields beside `data` (#308). An inline member contributes its own properties, those of its nested `allOf` members and — for a `oneOf`/`anyOf` member with more than one non-null variant — those of every inline object variant — read through the variant's own `allOf` members and nested unions the same way — as optional fields: `allOf: [{ properties: { name }, required: [name] }, { oneOf: [{ properties: { ownerId } }, { properties: { shelterId } }] }]` is `{ name, ownerId?, shelterId? }` inline exactly as it is as a component. A variant's `required` list is not read, since its properties are present only on that branch; a `$ref` under a variant — the variant itself, or a member of the variant's own `allOf` — is not followed (its component keeps its fields); and a union a `$ref` member's component carries is not read (that component's own model dispatches or flattens it). A property keeps the first always-applying declaration that declares a type, a typeless one before or after it only annotating it — two typing inline objects conjoin their properties and `required`, two typing arrays combine their `items` by this same rule, and a declaration conjoined from two components' is the merged model's own (`HolderValueResult`), since no component model nests the whole — whatever a variant says; a property only variants declare is typed as the union of its distinct declarations (documentation ignored): one shape is that declaration, several shapes are a `oneOf` of them read as any inline union is — `kind: { const: cat }` beside `kind: { const: dog }` becomes an inline enum `Kind`, `const: true` beside `const: false` a union of literals, two inline objects a union of two variant models — so a field is never typed from one variant's value alone. A field reads its flags, documentation and limits from its own schema, never from a union's variants, so the combined declaration carries every such keyword the declarations agree on, value for value — `readOnly`, `writeOnly`, `deprecated`, `description`, `default`, `example`, `examples`, `externalDocs` and the validation keywords: two `kind` declarations both `readOnly: true` with different consts make a read-only enum field; a keyword the declarations disagree on, or only some declare, is not the field's. A `null` any declaration admits (`nullable: true` or a `type` list naming it) is a value the property takes on that branch, so the combined declaration admits it (`nullable`, `nullable`). The declarations themselves are kept as written inside the union, so an enum member or a variant model still carries its own description and limits. A schema reached twice is read once in each capacity it is reached in — as an inline member (unions included), through a `$ref` member (its unions left to its component) or under a variant — and only an inline reading stands for the others: an object shared between a variant and a member (a YAML anchor, an object a `transformSpec` hook reuses, a `$ref` written twice) still contributes its `required`, and is still followed, when the member is reached; a component's own object reused as an inline member (`allOf: [{ $ref: Shared }, Shared]`) still has its union read after the `$ref` reading left it to the component. An inline `allOf` is always flattened: the dispatcher form — a `discriminator` whose mapping names one model per variant — is built for component schemas only. How an inline `allOf` is read is one decision made everywhere — by the type builder, the name predictor, both extractors and the primary response body path (`readInlineAllOf`) — so a model is built exactly when a reference names it: a `$ref` member beside an inline object member (`type: 'object'`, or properties declared inline — its own, a nested `allOf` member's or a `oneOf`/`anyOf` variant's: `declaresInlineProperties`), or several inline members one of which declares properties, is the merged model `` (`allOf: [{ $ref: Base }, { oneOf: [...] }]` carries `Base`'s fields and the variants', not a bare reference to `Base`); a single non-`$ref` member with nothing beside it that adds to it is unwrapped and read as the field or body itself, the wrapper's limits, `deprecated` and link carried onto it (a `required` the wrapper writes beside a lone member that declares properties adds to it, so `{ allOf: [{ properties: { name } }], required: [name] }` is the merged model with `name` required; beside a lone array, primitive or enum member `required` constrains nothing and the member is unwrapped); a single `$ref` member with nothing beside it that adds to the component (`allOf: [{ $ref: Base }]`, `allOf: [{ $ref: Base }, { description }]`, `allOf: [{ $ref: Base }, { maxItems: 10 }]`) is a reference to that component, and nothing is minted — a component the document declares under the nested name is never replaced by an unused merged copy, and a response body written so is `Base`, not a copy of `Base` under the operation's name. A second `$ref` member, or a `required` list beside the `$ref` — the schema's own, or a member's — adds to the component, so `allOf: [{ $ref: Pet }, { $ref: Owner }]` and `allOf: [{ $ref: Pet }], required: [name]` are the merged model (``, `Response`), carrying every component's properties and every `required`, at every site alike. A `required` adds to the component only when the component declares properties the merge reads — its own, or its `allOf` chain's: JSON Schema applies `required` to an object instance only, so beside a `$ref` to an array, a primitive, an enum or a union component it constrains nothing, and the reference stands (`{ allOf: [{ $ref: Pets }], required: [name] }` with `Pets` an array component is `Pet[]`, as `{ allOf: [{ $ref: Pets }] }` is). A merged model is built with whatever fields the merge yields: an empty `properties: {}` is an inline object with no fields, as it is as a plain field or a union variant, so `{ allOf: [{ oneOf: [{ type: 'object', properties: {} }] }, { description }] }` is a model with no fields rather than a reference nothing creates. +**Inline `allOf`.** An inline schema composed with `allOf` is merged into one model by a single reading (`mergeAllOfMembers`), wherever it appears: a request body, a response body of every kind (primary, secondary media type, error, `default` and range), a response header, a webhook payload or parameter, a component or inline model's field, array items, map values, a union's `allOf` variant, and a field nested under a discriminated component variant. Members are read in document order. A `$ref` member contributes its component's properties (those of the component's own `allOf` chain included). A model built from the merge (`materializeMergedAllOf`: a field anywhere, a field nested under a discriminated component variant) types each inherited property as the component's model types it (`Base.meta` stays `BaseMeta`) and does not read into it again — the component being the one whose own model carries the field: an `allOf` component that inherits it, through a `$ref` or a nullable member, types everything reached inside its chain under its own name (`DogMeta`), while a bare alias (`Hop: { $ref: Base }`), a nullable alias (`Alias: { oneOf: [{ $ref: Base }, { type: 'null' }] }`) or a chain of either declares nothing of its own and builds no model, so a field reached through one is typed under the component that declares it (`Base.meta` is `BaseMeta` through `Alias`, never `AliasMeta`) — so a component that extends itself through a field (`Node.child: allOf: [{ $ref: Node }, …]`) is typed as the `NodeChild` that exists; a primary response body types inherited properties under its own name. A discriminated component variant's model is built as any inline object's is — its fields by the field extractor under the variant's name — so every shape under it (a nested object, typed or not; array items and map values of any shape; a union and its members; an `allOf` merge; an inline enum) is materialized under the name its type uses, and typing and extraction cannot disagree there. A ref no component schema answers contributes nothing (a response whose only `$ref` is unanswered is typed as that reference). A `oneOf`/`anyOf` member with exactly one non-null variant beside `null` is a nullable wrapper, not a union — decided by `nullableUnionBranch`, the definition `unwrapNullableSchema` collapses by — and applies as that variant wherever the member sits, a `$ref` member's component included: its `$ref` is followed, its `required` read and its properties always-applying, for the inline merge, for a component's `allOf` model (`readComponentAllOfMember`) and for a list envelope's companions (`collectObjectProperties`) alike, so `allOf: [{ oneOf: [{ $ref: Base }, { type: 'null' }] }, { properties: { data } }]` carries `Base`'s fields beside `data` (#308). An inline member contributes its own properties, those of its nested `allOf` members and — for a `oneOf`/`anyOf` member with more than one non-null variant — those of every inline object variant — read through the variant's own `allOf` members and nested unions the same way — as optional fields: `allOf: [{ properties: { name }, required: [name] }, { oneOf: [{ properties: { ownerId } }, { properties: { shelterId } }] }]` is `{ name, ownerId?, shelterId? }` inline exactly as it is as a component. A variant's `required` list is not read, since its properties are present only on that branch; a `$ref` under a variant — the variant itself, or a member of the variant's own `allOf` — is not followed (its component keeps its fields); and a union a `$ref` member's component carries is not read (that component's own model dispatches or flattens it). A property keeps the first always-applying declaration that declares a type, a typeless one before or after it only annotating it — two typing inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both (`result` is a `PetPage` that also has `total_pages`, its inherited fields typed as `PetPage` types them), two typing arrays combine their `items` by this same rule, and a declaration conjoined from two components' is the merged model's own (`HolderValueResult`), since no component model nests the whole — whatever a variant says; a property only variants declare is typed as the union of its distinct declarations (documentation ignored): one shape is that declaration, several shapes are a `oneOf` of them read as any inline union is — `kind: { const: cat }` beside `kind: { const: dog }` becomes an inline enum `Kind`, `const: true` beside `const: false` a union of literals, two inline objects a union of two variant models — so a field is never typed from one variant's value alone. A field reads its flags, documentation and limits from its own schema, never from a union's variants, so the combined declaration carries every such keyword the declarations agree on, value for value — `readOnly`, `writeOnly`, `deprecated`, `description`, `default`, `example`, `examples`, `externalDocs` and the validation keywords: two `kind` declarations both `readOnly: true` with different consts make a read-only enum field; a keyword the declarations disagree on, or only some declare, is not the field's. A `null` any declaration admits (`nullable: true` or a `type` list naming it) is a value the property takes on that branch, so the combined declaration admits it (`nullable`, `nullable`). The declarations themselves are kept as written inside the union, so an enum member or a variant model still carries its own description and limits. A schema reached twice is read once in each capacity it is reached in — as an inline member (unions included), through a `$ref` member (its unions left to its component) or under a variant — and only an inline reading stands for the others: an object shared between a variant and a member (a YAML anchor, an object a `transformSpec` hook reuses, a `$ref` written twice) still contributes its `required`, and is still followed, when the member is reached; a component's own object reused as an inline member (`allOf: [{ $ref: Shared }, Shared]`) still has its union read after the `$ref` reading left it to the component. An inline `allOf` is always flattened: the dispatcher form — a `discriminator` whose mapping names one model per variant — is built for component schemas only. How an inline `allOf` is read is one decision made everywhere — by the type builder, the name predictor, both extractors and the primary response body path (`readInlineAllOf`) — so a model is built exactly when a reference names it: a `$ref` member beside an inline object member (`type: 'object'`, or properties declared inline — its own, a nested `allOf` member's or a `oneOf`/`anyOf` variant's: `declaresInlineProperties`), or several inline members one of which declares properties, is the merged model `` (`allOf: [{ $ref: Base }, { oneOf: [...] }]` carries `Base`'s fields and the variants', not a bare reference to `Base`); a single non-`$ref` member with nothing beside it that adds to it is unwrapped and read as the field or body itself, the wrapper's limits, `deprecated` and link carried onto it (a `required` the wrapper writes beside a lone member that declares properties adds to it, so `{ allOf: [{ properties: { name } }], required: [name] }` is the merged model with `name` required; beside a lone array, primitive or enum member `required` constrains nothing and the member is unwrapped); a single `$ref` member with nothing beside it that adds to the component (`allOf: [{ $ref: Base }]`, `allOf: [{ $ref: Base }, { description }]`, `allOf: [{ $ref: Base }, { maxItems: 10 }]`) is a reference to that component, and nothing is minted — a component the document declares under the nested name is never replaced by an unused merged copy, and a response body written so is `Base`, not a copy of `Base` under the operation's name. A second `$ref` member, or a `required` list beside the `$ref` — the schema's own, or a member's — adds to the component, so `allOf: [{ $ref: Pet }, { $ref: Owner }]` and `allOf: [{ $ref: Pet }], required: [name]` are the merged model (``, `Response`), carrying every component's properties and every `required`, at every site alike. A `required` adds to the component only when the component declares properties the merge reads — its own, or its `allOf` chain's: JSON Schema applies `required` to an object instance only, so beside a `$ref` to an array, a primitive, an enum or a union component it constrains nothing, and the reference stands (`{ allOf: [{ $ref: Pets }], required: [name] }` with `Pets` an array component is `Pet[]`, as `{ allOf: [{ $ref: Pets }] }` is). A merged model is built with whatever fields the merge yields: an empty `properties: {}` is an inline object with no fields, as it is as a plain field or a union variant, so `{ allOf: [{ oneOf: [{ type: 'object', properties: {} }] }, { description }] }` is a model with no fields rather than a reference nothing creates. ### Field Examples diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index efa2c921..84e990a7 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -1894,7 +1894,7 @@ export type InlineAllOfReading = 'merged' | 'unwrapped' | 'reference'; * Pet }], required: [name]` was `Pet` with `name` optional). A * `required` adds to the component only when the component declares * properties the merge would read — its own, or its `allOf` chain's - * (`refContributesProperties`): JSON Schema applies `required` to an + * (`contributesProperties`): JSON Schema applies `required` to an * object instance only, so beside a `$ref` to an array, a primitive, an * enum or a union component it constrains nothing, and the reference * stands (the same review: `{ allOf: [{ $ref: Pets }], required: [name] }` @@ -1917,7 +1917,7 @@ export function readInlineAllOf( if (members.some(isInlineAllOfMember) || refs.length > 1) return 'merged'; const addsRequired = [wrapper, ...members].some((s) => s !== undefined && (s.required?.length ?? 0) > 0) && - refContributesProperties(refs[0]!.$ref!, componentSchemas); + contributesProperties(refs[0]!, componentSchemas); return addsRequired ? 'merged' : 'reference'; } if (members.length === 1) { @@ -1929,24 +1929,24 @@ export function readInlineAllOf( } /** - * Whether the component `ref` names contributes properties to an `allOf` - * merge — declares `properties` of its own, or through its `allOf` chain - * (a member's `$ref` followed the same way), the reading `mergeAllOfMembers` - * makes of a `$ref` member; a union the component carries is not read - * there, so it does not count. False for an array, a primitive, an enum, a + * Whether `schema` contributes properties to an `allOf` merge — declares + * `properties` of its own, or through its `$ref` or its `allOf` chain (a + * member's `$ref` followed the same way), the reading `mergeAllOfMembers` + * makes of a member; a union a component carries is not read there, so it + * does not count. False for a `$ref` to an array, a primitive, an enum, a * union-only component, and for a ref no component answers. A chain that * returns to a schema it has read declares nothing more. */ -function refContributesProperties(ref: string, componentSchemas?: Record): boolean { +function contributesProperties(schema: SchemaObject, componentSchemas?: Record): boolean { const seen = new Set(); - const declares = (schema: SchemaObject | undefined): boolean => { - if (!schema || seen.has(schema)) return false; - seen.add(schema); - if (schema.$ref) return declares(componentSchemaOf(schema.$ref, componentSchemas)); - if (Object.keys(schema.properties ?? {}).length > 0) return true; - return (schema.allOf ?? []).some(declares); + const declares = (s: SchemaObject | undefined): boolean => { + if (!s || seen.has(s)) return false; + seen.add(s); + if (s.$ref) return declares(componentSchemaOf(s.$ref, componentSchemas)); + if (Object.keys(s.properties ?? {}).length > 0) return true; + return (s.allOf ?? []).some(declares); }; - return declares(componentSchemaOf(ref, componentSchemas)); + return declares(schema); } /** @@ -2039,9 +2039,20 @@ export function declaresType(schema: SchemaObject): boolean { * whenever they had any keyword, so a member documenting the items made * `data` an `unknown[]`); the later's other keywords over the earlier's; * and `type: ['array', 'null']` when either admits `null` - * (`declaresNull`), else `type: 'array'`. Any other pair keeps the first - * as written. Returns `held` itself when `next` adds nothing to it, so a - * lone declaration keeps its identity. + * (`declaresNull`), else `type: 'array'`; an inline object and a `$ref` to + * an object component, or an `allOf` that reaches one (`composesObject`, + * the component the document's: `componentSchemaOf`), become the `allOf` + * of both in document order, which every reader reads as any inline + * `allOf` (`mergeAllOfMembers`, `collectObjectProperties`), carrying the + * keywords a field reads from either (`declarationKeywords`, the later's + * over the earlier's) — so `result: { $ref: PetPage }` beside `result: { + * properties: { total_pages } }` is a `PetPage` that also has + * `total_pages`, whichever is read first (workos/oagen#432: the first was + * kept, so `result` had `items` or `total_pages`, never both, and a + * `dataPath` through the other was rejected), while a `$ref` to an array, + * an enum, a primitive or a union component is no object to conjoin. Any + * other pair keeps the first as written. Returns `held` itself when `next` + * adds nothing to it, so a lone declaration keeps its identity. */ export function combineDeclarations(held: SchemaObject, next: SchemaObject): SchemaObject { if (!declaresType(held) && declaresType(next)) return { ...held, ...next }; @@ -2078,9 +2089,39 @@ function conjoinTypedDeclarations(first: SchemaObject, second: SchemaObject): Sc type: declaresNull(first) || declaresNull(second) ? ['array', 'null'] : 'array', }; } + if ( + (isInlineObjectSchema(first) && composesObject(second)) || + (isInlineObjectSchema(second) && composesObject(first)) + ) { + return { ...declarationKeywords(first), ...declarationKeywords(second), allOf: [first, second] }; + } return first; } +/** + * Whether a typing declaration that is no inline object — a `$ref`, or an + * `allOf` — is an object whose properties an `allOf` merge reads + * (`contributesProperties`): a `$ref` to a component that declares + * properties, its own or its `allOf` chain's, or an `allOf` that reaches + * one. A `$ref` to an array, a primitive, an enum or a union component, or + * one no component answers, is not, so no inline object beside it turns it + * into an object merge (`combineDeclarations`). + */ +function composesObject(schema: SchemaObject): boolean { + return (schema.$ref !== undefined || schema.allOf !== undefined) && contributesProperties(schema); +} + +/** + * What a declaration says of its property beside the type: the keywords a + * field reads from its own schema (`LIFTED_DECLARATION_KEYWORDS`) and its + * extensions, which an `allOf` conjoining it with another declaration + * carries for the field it types (`combineDeclarations`). + */ +function declarationKeywords(schema: SchemaObject): SchemaObject { + const keywords = Object.entries(schema).filter(([key]) => LIFTED_DECLARATION_KEYWORDS.includes(key)); + return { ...Object.fromEntries(keywords), ...extensionsOf(schema, SCHEMA_CONSUMED_EXTENSIONS) }; +} + /** * Record one declaration of `name` in `ledger`: the one precedence rule * every `allOf` merge applies, whether the declaration arrives from a direct diff --git a/src/parser/pagination.ts b/src/parser/pagination.ts index 56228b53..034d10c9 100644 --- a/src/parser/pagination.ts +++ b/src/parser/pagination.ts @@ -587,8 +587,11 @@ function objectProperties( * objects merge their properties — `pagination.next` from one member and * `pagination.total` from another both belong to the response, as they do * to the wrapper's model — two arrays combine their `items` by the same - * rule, and otherwise the first declaration that declares a type types it, a - * typeless one only annotating it. (Envelope detection reads the + * rule, an inline object and a `$ref` to an object component become the + * `allOf` of both, read through as any `allOf` member is (`result.items` + * from `PetPage` beside `result.total_pages`), and otherwise the first + * declaration that declares a type types it, a typeless one only annotating + * it. (Envelope detection reads the * members' own `properties`; a `oneOf`/`anyOf` member with more than one * non-null variant declares none, so it contributes no property here; it is * listed in `unions`, with every other `oneOf`/`anyOf` the walk meets — the diff --git a/test/parser/inline-allof-oneof.test.ts b/test/parser/inline-allof-oneof.test.ts index d4a1907f..e707ebef 100644 --- a/test/parser/inline-allof-oneof.test.ts +++ b/test/parser/inline-allof-oneof.test.ts @@ -1663,6 +1663,89 @@ describe('two typing declarations of one name conjoin as JSON Schema applies bot expect(fieldsOf(models['PageResult'])).toEqual(['items: array', 'total_pages?: integer']); expectSound(ir, ['A', 'B', 'Holder', 'Page']); }); + + it('conjoins a $ref to an object component and an inline object as the allOf of both, in either order (#432)', async () => { + const ref = (name: string): SchemaObject => ({ $ref: `#/components/schemas/${name}` }); + const page = ref('PetPage'); + const more = obj({ has_more: { type: 'boolean' } }); + // A component that is no object: the inline object beside it does not + // make it one, so the first declaration types the property, as before. + const pets = ref('Pets'); + const ir = await parseWithoutWarnings({ + openapi: '3.1.0', + info, + paths: {}, + components: { + schemas: { + Pet: obj({ name: { type: 'string' } }), + PetPage: obj({ items: { type: 'array', items: ref('Pet') }, owner: obj({ name: { type: 'string' } }) }), + Pets: { type: 'array', items: ref('Pet') }, + RefFirst: { allOf: [obj({ result: page }), obj({ result: total })] }, + InlineFirst: { allOf: [obj({ result: total }), obj({ result: page })] }, + // A third declaration conjoins with the conjunction, wherever the `$ref` sits. + RefThenTwo: { allOf: [obj({ result: page }), obj({ result: total }), obj({ result: more })] }, + RefBetween: { allOf: [obj({ result: total }), obj({ result: page }), obj({ result: more })] }, + // The flags and documentation a declaration gives the field stay the field's. + Documented: { + allOf: [obj({ result: { ...total, description: 'The page', readOnly: true } }), obj({ result: page })], + }, + ArrayFirst: { allOf: [obj({ result: pets }), obj({ result: total })] }, + ArrayLast: { allOf: [obj({ result: total }), obj({ result: pets })] }, + // A declaration inherited through a `$ref` member conjoins the same way. + RefHolder: obj({ result: page }), + Extended: { allOf: [ref('RefHolder'), obj({ result: total })] }, + // An inline `allOf` merge reads the same declaration. + Holder: obj({ value: { allOf: [obj({ result: total }), obj({ result: page })] } }), + // So do the items of two arrays (#431). + Listed: { + allOf: [ + obj({ data: { type: 'array', items: ref('Pet') } }), + obj({ data: { type: 'array', items: obj({ extra: { type: 'string' } }) } }), + ], + }, + }, + }, + }); + const models = modelsByName(ir); + const pageFields = ['items?: array', 'owner?: PetPageOwner']; + for (const [name, resultFields] of [ + ['RefFirst', [...pageFields, 'total_pages?: integer']], + ['InlineFirst', ['total_pages?: integer', ...pageFields]], + ['RefThenTwo', [...pageFields, 'total_pages?: integer', 'has_more?: boolean']], + ['RefBetween', ['total_pages?: integer', ...pageFields, 'has_more?: boolean']], + ['Documented', ['total_pages?: integer', ...pageFields]], + ['Extended', [...pageFields, 'total_pages?: integer']], + ['HolderValue', ['total_pages?: integer', ...pageFields]], + ] as const) { + // `result` is a `PetPage` that also has `total_pages`: the fields it + // inherits are typed as `PetPage` types them (`PetPageOwner`). + expect(fieldsOf(models[name]), name).toEqual([`result?: ${name}Result`]); + expect(fieldsOf(models[`${name}Result`]), name).toEqual(resultFields); + } + const documented = models['Documented']!.fields[0]!; + expect([documented.description, documented.readOnly]).toEqual(['The page', true]); + expect(models['ArrayFirst']!.fields[0]!.type).toEqual({ kind: 'array', items: model('Pet') }); + expect(fieldsOf(models['ArrayLast'])).toEqual(['result?: ArrayLastResult']); + expect(fieldsOf(models['ArrayLastResult'])).toEqual(['total_pages?: integer']); + expect(models['Listed']!.fields[0]!.type).toEqual({ kind: 'array', items: model('ListedData') }); + expect(fieldsOf(models['ListedData'])).toEqual(['name?: string', 'extra?: string']); + expectSound(ir, [ + 'Pet', + 'PetPage', + 'Pets', + 'RefFirst', + 'InlineFirst', + 'RefThenTwo', + 'RefBetween', + 'Documented', + 'ArrayFirst', + 'ArrayLast', + 'RefHolder', + 'Extended', + 'Holder', + 'Listed', + ]); + }); }); describe('the origin of an inherited field is the component whose model carries it (Greptile, #308 round 4)', () => { diff --git a/test/parser/pagination-override.test.ts b/test/parser/pagination-override.test.ts index 21570e31..050b10cb 100644 --- a/test/parser/pagination-override.test.ts +++ b/test/parser/pagination-override.test.ts @@ -128,6 +128,31 @@ describe('parseSpec — page pagination and x-pagination overrides', () => { expect(op.pagination?.endOfIteration).toEqual({ kind: 'totalPages', path: 'meta.total_pages' }); }); + it('reads the stop field of a companion one member declares as a $ref and another inline, in either order (#432)', async () => { + // Both declarations apply, so the companion is a `Meta` that also has + // `total_pages`, in the model and in the read alike. + const components = { schemas: { Meta: { type: 'object', properties: { page: { type: 'integer' } } } } }; + const withRef = envelope({ meta: { $ref: '#/components/schemas/Meta' } }); + const withTotal = { + type: 'object', + properties: { meta: { type: 'object', properties: { total_pages: { type: 'integer' } } } }, + }; + for (const members of [ + [withRef, withTotal], + [withTotal, withRef], + ]) { + const ir = await parseJson(petsSpec({ responses: jsonResponse({ allOf: members }) }, components)); + const op = onlyOperation(ir); + expect(op.pagination?.endOfIteration).toEqual({ kind: 'totalPages', path: 'meta.total_pages' }); + const [meta] = op.pagination?.envelopeFields ?? []; + const [companion] = meta ? modelNamesIn(meta.type) : []; + expect(companion).not.toBe('Meta'); + const fields = ir.models.find((m) => m.name === companion)?.fields.map((f) => f.name); + expect(fields?.sort()).toEqual(['page', 'total_pages']); + } + expect(warn).not.toHaveBeenCalled(); + }); + it('survives a companion whose component refers to itself, as a stop field or beside one (Greptile)', async () => { const schemas = { Flag: { oneOf: [{ $ref: '#/components/schemas/Flag' }, { type: 'null' }] } }; const flag = { $ref: '#/components/schemas/Flag' }; @@ -2006,6 +2031,60 @@ describe('parseSpec — page pagination and x-pagination overrides', () => { expect(warn).not.toHaveBeenCalled(); }); + it('conjoins a $ref to an object component and an inline object two members declare, in the model and in the read alike (#432)', async () => { + // The issue's shapes: `PetPage { items: Pet[] }`, and two `allOf` + // members declaring `result`, one an inline object with the stop field, + // one a `$ref` to `PetPage`. JSON Schema applies both, so `result` is a + // `PetPage` that also has `total_pages`, whichever member comes first. + const PetPage = { type: 'object', properties: { items: petArray } }; + const withTotal = { + type: 'object', + properties: { result: { type: 'object', properties: { total_pages: { type: 'integer' } } } }, + }; + const withPage = { type: 'object', properties: { result: { $ref: '#/components/schemas/PetPage' } } }; + const expected = { + ...page, + dataPath: 'result.items', + endOfIteration: { kind: 'totalPages', path: 'result.total_pages' }, + itemType: 'Pet', + }; + const modelOf = (ir: ApiSpec, name: string) => ir.models.find((m) => m.name === name); + const resultOf = (ir: ApiSpec, wrapper: string) => modelOf(ir, wrapper)?.fields.find((f) => f.name === 'result'); + for (const [label, members] of [ + ['inline first', [withTotal, withPage]], + ['ref first', [withPage, withTotal]], + ] as const) { + const PetPageResponse = { allOf: members }; + const inlineIr = await parseJson(listOp(PetPageResponse, { dataPath: 'result.items' }, { PetPage })); + const inline = onlyOperation(inlineIr); + expect(projection(inline), `${label}: inline wrapper`).toEqual(expected); + expect(resultOf(inlineIr, 'ListPetsResponse')?.type, `${label}: inline wrapper`).toEqual({ + kind: 'model', + name: 'ListPetsResponseResult', + }); + const inlineResult = modelOf(inlineIr, 'ListPetsResponseResult')?.fields.map((f) => f.name); + expect(inlineResult?.sort(), `${label}: inline wrapper`).toEqual(['items', 'total_pages']); + expectModelsConsistent(inlineIr, inline, ['Pet', 'PetList', 'PetPage']); + const referencedIr = await parseJson( + listOp( + { $ref: '#/components/schemas/PetPageResponse' }, + { dataPath: 'result.items' }, + { PetPage, PetPageResponse }, + ), + ); + const referenced = onlyOperation(referencedIr); + expect(projection(referenced), `${label}: $ref wrapper`).toEqual(expected); + expect(resultOf(referencedIr, 'PetPageResponse')?.type, `${label}: $ref wrapper`).toEqual({ + kind: 'model', + name: 'PetPageResponseResult', + }); + const referencedResult = modelOf(referencedIr, 'PetPageResponseResult')?.fields.map((f) => f.name); + expect(referencedResult?.sort(), `${label}: $ref wrapper`).toEqual(['items', 'total_pages']); + expectModelsConsistent(referencedIr, referenced, ['Pet', 'PetList', 'PetPage', 'PetPageResponse']); + } + expect(warn).not.toHaveBeenCalled(); + }); + it('reads the stop field beside a nested array under a parent of any name, before the top level (MichaelDeBoey)', async () => { const boolean = { type: 'boolean' }; const nested = (fields: Record) => ({ From c41e13bc17d26e1379004cde585443a9b6d0728a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 11:08:13 -0400 Subject: [PATCH 43/59] fix(parser)!: type variant declarations differing only in flags, limits or docs as one shape Since #334 a property only the oneOf variants of an inline allOf member declare is typed by unionOfDeclarations from its distinct declarations, compared by schemaShapeKey. That key ignores documentation but keeps `readOnly`, `writeOnly`, `default`, `deprecated: true` and the limits, so two `type: 'string'` declarations that differ in one of them became `(string | string)`, and `oagen diff` called marking one variant's property `readOnly` a breaking field-type change. When the declarations did share one shape, the first was kept whole, so the field (and, since #368, an inline enum) took the first's description though the other disagreed. The keywords a field reads from its own schema (LIFTED_DECLARATION_KEYWORDS: the flags, the documentation, `default` and the limits) are now left out of the shape the declarations are deduped by, and a single shape carries only the ones every declaration writes the same way, as a union of shapes already did. Declarations that differ only there are one `string` field without the keyword they dispute, and `oagen diff` reports nothing; an inline object deprecated on one branch only is one live model rather than a deprecated `` beside a `2`. A keyword that types a declaration (`format`, `const`, `enum`) still makes a shape of its own. Closes #397 --- docs/architecture/ir-types.md | 4 +- src/parser/inline-models.ts | 52 +++++---- test/parser/inline-allof-oneof.test.ts | 144 ++++++++++++++++++++++++- 3 files changed, 176 insertions(+), 24 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index b94c3649..3daf7dd8 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -81,7 +81,7 @@ The specification extensions (`x-*` keys) an OpenAPI object declares, each under **Source per node.** `Operation.extensions` is the Operation Object's own keys. `Model.extensions` is the keys of the schema the model was built from — a component's own, or the inline schema's for a request body, a response, a `oneOf` variant (each variant model carries its own variant's keys), an object nested in a field, array items; an `allOf` wrapper's own keys, a member's staying on that member and a `$ref` member's on its component, for a component `allOf`, an inline `allOf` merged into one model and an `allOf` response alike; a discriminated `oneOf` body merged into one model carries the wrapper's keys, not a variant's; a discriminated `allOf` + `oneOf` component's variant models carry each variant's own. `Field.extensions` is the field schema's own keys, read like `constraints`: keys written beside a `$ref` (OAS 3.1) are the field's, a bare `$ref` yields none — the referenced model, union or enum carries its own — unless it names an array component, whose keys the field then carries as it carries that component's `description`. A list envelope's `envelopeFields` carry theirs the same way. -**Several declarations of one property.** A property several `allOf` members always declare is typed by the first always-applying declaration that declares a type, its extensions included; a later typing declaration adds to it only where the two are conjoined, as JSON Schema applies both — two inline objects, an inline object and a `$ref` to an object component, two arrays (see Inline `allOf`) — the later's keys then over the earlier's, and a typeless one fills in only the keys the typing one leaves unset (`combineDeclarations`) — the precedence every keyword follows. A property only `oneOf`/`anyOf` variants declare carries exactly the keys every declaration as written agrees on, value for value — one reading, `agreedExtensions`, at every site that rebuilds or widens a property from several declarations without changing which declaration types it: `unionOfDeclarations` for an inline `allOf`; `declaredComponentProperty` for a component (a bare `oneOf` component or a union member of a component `allOf`, whose field is typed from the first branch); `mergeBranchPropertySchemas` for the nested sub-branches of a discriminated `allOf` + `oneOf` variant, where boolean or string consts widen to a plain boolean or string; and `flattenDiscriminatedUnionBody` for a discriminated inline request body, whose discriminator is rebuilt as an enum of the pinned consts and whose other properties are typed from the first variant that declares them — whether the declarations share one shape or become a union: a key any declaration leaves unset or writes another value for is nobody's, and a declaration that repeats an earlier shape still has its say — three variants writing `x-since` as `1`, `2` and `1` leave it off. The lifted keywords (`description`, `readOnly`, the limits, …) are read by the same agreement when the declarations become a union; when they share one shape, the first declaration gives them. `Enum.extensions` is the keys of the schema that declares the members: a component enum's own, or, for an enum lifted from an inline schema, that schema's — the field, parameter or header the schema types carries the same keys, since they are its schema's own — read as the enum's `description` and `externalDocs` are, through `collectInlineEnumFromRef`; when several inline schemas lift to one name, the first lifted gives the keys, as it gives the values. +**Several declarations of one property.** A property several `allOf` members always declare is typed by the first always-applying declaration that declares a type, its extensions included; a later typing declaration adds to it only where the two are conjoined, as JSON Schema applies both — two inline objects, an inline object and a `$ref` to an object component, two arrays (see Inline `allOf`) — the later's keys then over the earlier's, and a typeless one fills in only the keys the typing one leaves unset (`combineDeclarations`) — the precedence every keyword follows. A property only `oneOf`/`anyOf` variants declare carries exactly the keys every declaration as written agrees on, value for value — one reading, `agreedExtensions`, at every site that rebuilds or widens a property from several declarations without changing which declaration types it: `unionOfDeclarations` for an inline `allOf`; `declaredComponentProperty` for a component (a bare `oneOf` component or a union member of a component `allOf`, whose field is typed from the first branch); `mergeBranchPropertySchemas` for the nested sub-branches of a discriminated `allOf` + `oneOf` variant, where boolean or string consts widen to a plain boolean or string; and `flattenDiscriminatedUnionBody` for a discriminated inline request body, whose discriminator is rebuilt as an enum of the pinned consts and whose other properties are typed from the first variant that declares them — whether the declarations share one shape or become a union: a key any declaration leaves unset or writes another value for is nobody's, and a declaration that repeats an earlier shape still has its say — three variants writing `x-since` as `1`, `2` and `1` leave it off. In an inline `allOf`, the lifted keywords (`description`, `readOnly`, `default`, the limits, …) are read by the same agreement whether the declarations share one shape or become a union, and make no shape of their own: two `type: 'string'` declarations that differ only in them are one `string` field carrying what both write (#397). `Enum.extensions` is the keys of the schema that declares the members: a component enum's own, or, for an enum lifted from an inline schema, that schema's — the field, parameter or header the schema types carries the same keys, since they are its schema's own — read as the enum's `description` and `externalDocs` are, through `collectInlineEnumFromRef`; when several inline schemas lift to one name, the first lifted gives the keys, as it gives the values. **Collapsed wrappers.** A nullable `oneOf`/`anyOf` or single-member `allOf` wrapper the parser collapses carries its documentation layer onto what it unwraps to by one rule, `withWrapperDocs`, applied at every collapse step — by the type builder (`schemaToTypeRef`), by the model extractors (`unwrapNullableField` through `unwrapNullableSchema`, `unwrappedAllOfMember`) and by the response path — so the model built from the branch, the enum lifted from it and the field the wrapper types document themselves alike. The layer is every keyword that documents a schema without shaping it, the schema-side counterpart of `FIELD_DOC_KEYS`: `description` (when it has content), `externalDocs` (when usable), `example` (when the key is present), `examples` (when non-empty) and the `x-*` keys one by one, the consumed ones left out. The wrapper's value wins wherever both declare one and the branch keeps its own where the wrapper has none. Nested wrappers compose outermost-first: an outer wrapper's keys land on the inner wrapper before the inner wrapper's land on the branch, so with `x-since` on the outer and `x-owner` on the inner the branch carries both and an `x-since` on both is the outer's. `oneOf: [{ enum: [...] }, { type: "null" }]` and `type: ["string", "null"]` give the same enum the same keys. The field the wrapper types carries the outermost wrapper's keys alone, since that wrapper is its schema. @@ -761,7 +761,7 @@ An inline model that resolves to a component schema's name — a request body or An inline model never keeps a name an enum holds, whatever its shape or flag — an enum admits no model — so the model is always the one that steps, wherever the enum sits: a component enum, an inline enum lifted from a component schema's own fields, union variants or array items, or an inline enum an operation or webhook carries (a parameter's, a body's, a header's, one nested in a model). A request body or primary response named from its operation, a model nested under one, a list-envelope companion and a component field's object take the qualified name described above through the merge passes, which check every enum name in play (`reservedNamesByKind`): `GetPetResponse2` for the `getPet` body beside a component enum `GetPetResponse`; `CreatePetRequest2` for the `createPet` body beside a component enum `CreatePetRequest`, or beside a `request` query enum of its own service under that name (a `request` parameter object beside that enum steps in the shape registry to `CreatePetListPetsRequest`, and the enum keeps the name in both cases); `GetPetResponseOwner2` for the `owner` object nested under the `getPet` body beside a component enum `GetPetResponseOwner`; `ListPetsListMetadata` for the companion a `list_metadata` field of `listPets` mints beside a component enum `ListMetadata`; `PetOwner2` for the `owner` object of `Pet` beside a component enum `PetOwner`, settled with the fields before any operation is read; and `PetStatus2` for the `status` object of `Pet` beside a `status` query enum `PetStatus` of a `pet` service, settled at the merges once the enum is known — a header that shared the object's model follows it to the new name. Identical copies that must leave the name share one model under the new one, as they share the bare name: two identical `getPet` bodies are one `GetPetResponse2`, two identical bodies pinned by `object: widget` beside a component enum `Widget` one `GetAWidget`. The passes say so: `[oagen] Renamed inline model "GetPetResponse" → "GetPetResponse2": enum "GetPetResponse" owns the name`, with `(shared by 2 sites)` when two do. A parameter model, a header and a fallback, error or secondary body step past every enum name in play as they are named in the shape registry instead (see Response Headers and ErrorResponse), the names of the bodies that are themselves enums reserved first, so the same holds there: an object `404` body is `GetPetError404Body` beside an enum `404` body of another operation under the same stem, declared before or after it. The one enum that steps instead is one the collectors lift after a model of its name no pass can rename — a component model (`PetsMode2` for a `mode` parameter enum beside a component model `PetsMode`; see Response Headers). A name both a component schema and a component enum hold is the document's own clash and is settled as a component schema's. A model named like an enum that the parser could not rename — only a direct caller's — is kept beside the enum, with a warning. -**Inline `allOf`.** An inline schema composed with `allOf` is merged into one model by a single reading (`mergeAllOfMembers`), wherever it appears: a request body, a response body of every kind (primary, secondary media type, error, `default` and range), a response header, a webhook payload or parameter, a component or inline model's field, array items, map values, a union's `allOf` variant, and a field nested under a discriminated component variant. Members are read in document order. A `$ref` member contributes its component's properties (those of the component's own `allOf` chain included). A model built from the merge (`materializeMergedAllOf`: a field anywhere, a field nested under a discriminated component variant) types each inherited property as the component's model types it (`Base.meta` stays `BaseMeta`) and does not read into it again — the component being the one whose own model carries the field: an `allOf` component that inherits it, through a `$ref` or a nullable member, types everything reached inside its chain under its own name (`DogMeta`), while a bare alias (`Hop: { $ref: Base }`), a nullable alias (`Alias: { oneOf: [{ $ref: Base }, { type: 'null' }] }`) or a chain of either declares nothing of its own and builds no model, so a field reached through one is typed under the component that declares it (`Base.meta` is `BaseMeta` through `Alias`, never `AliasMeta`) — so a component that extends itself through a field (`Node.child: allOf: [{ $ref: Node }, …]`) is typed as the `NodeChild` that exists; a primary response body types inherited properties under its own name. A discriminated component variant's model is built as any inline object's is — its fields by the field extractor under the variant's name — so every shape under it (a nested object, typed or not; array items and map values of any shape; a union and its members; an `allOf` merge; an inline enum) is materialized under the name its type uses, and typing and extraction cannot disagree there. A ref no component schema answers contributes nothing (a response whose only `$ref` is unanswered is typed as that reference). A `oneOf`/`anyOf` member with exactly one non-null variant beside `null` is a nullable wrapper, not a union — decided by `nullableUnionBranch`, the definition `unwrapNullableSchema` collapses by — and applies as that variant wherever the member sits, a `$ref` member's component included: its `$ref` is followed, its `required` read and its properties always-applying, for the inline merge, for a component's `allOf` model (`readComponentAllOfMember`) and for a list envelope's companions (`collectObjectProperties`) alike, so `allOf: [{ oneOf: [{ $ref: Base }, { type: 'null' }] }, { properties: { data } }]` carries `Base`'s fields beside `data` (#308). An inline member contributes its own properties, those of its nested `allOf` members and — for a `oneOf`/`anyOf` member with more than one non-null variant — those of every inline object variant — read through the variant's own `allOf` members and nested unions the same way — as optional fields: `allOf: [{ properties: { name }, required: [name] }, { oneOf: [{ properties: { ownerId } }, { properties: { shelterId } }] }]` is `{ name, ownerId?, shelterId? }` inline exactly as it is as a component. A variant's `required` list is not read, since its properties are present only on that branch; a `$ref` under a variant — the variant itself, or a member of the variant's own `allOf` — is not followed (its component keeps its fields); and a union a `$ref` member's component carries is not read (that component's own model dispatches or flattens it). A property keeps the first always-applying declaration that declares a type, a typeless one before or after it only annotating it — two typing inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both (`result` is a `PetPage` that also has `total_pages`, its inherited fields typed as `PetPage` types them), two typing arrays combine their `items` by this same rule, and a declaration conjoined from two components' is the merged model's own (`HolderValueResult`), since no component model nests the whole — whatever a variant says; a property only variants declare is typed as the union of its distinct declarations (documentation ignored): one shape is that declaration, several shapes are a `oneOf` of them read as any inline union is — `kind: { const: cat }` beside `kind: { const: dog }` becomes an inline enum `Kind`, `const: true` beside `const: false` a union of literals, two inline objects a union of two variant models — so a field is never typed from one variant's value alone. A field reads its flags, documentation and limits from its own schema, never from a union's variants, so the combined declaration carries every such keyword the declarations agree on, value for value — `readOnly`, `writeOnly`, `deprecated`, `description`, `default`, `example`, `examples`, `externalDocs` and the validation keywords: two `kind` declarations both `readOnly: true` with different consts make a read-only enum field; a keyword the declarations disagree on, or only some declare, is not the field's. A `null` any declaration admits (`nullable: true` or a `type` list naming it) is a value the property takes on that branch, so the combined declaration admits it (`nullable`, `nullable`). The declarations themselves are kept as written inside the union, so an enum member or a variant model still carries its own description and limits. A schema reached twice is read once in each capacity it is reached in — as an inline member (unions included), through a `$ref` member (its unions left to its component) or under a variant — and only an inline reading stands for the others: an object shared between a variant and a member (a YAML anchor, an object a `transformSpec` hook reuses, a `$ref` written twice) still contributes its `required`, and is still followed, when the member is reached; a component's own object reused as an inline member (`allOf: [{ $ref: Shared }, Shared]`) still has its union read after the `$ref` reading left it to the component. An inline `allOf` is always flattened: the dispatcher form — a `discriminator` whose mapping names one model per variant — is built for component schemas only. How an inline `allOf` is read is one decision made everywhere — by the type builder, the name predictor, both extractors and the primary response body path (`readInlineAllOf`) — so a model is built exactly when a reference names it: a `$ref` member beside an inline object member (`type: 'object'`, or properties declared inline — its own, a nested `allOf` member's or a `oneOf`/`anyOf` variant's: `declaresInlineProperties`), or several inline members one of which declares properties, is the merged model `` (`allOf: [{ $ref: Base }, { oneOf: [...] }]` carries `Base`'s fields and the variants', not a bare reference to `Base`); a single non-`$ref` member with nothing beside it that adds to it is unwrapped and read as the field or body itself, the wrapper's limits, `deprecated` and link carried onto it (a `required` the wrapper writes beside a lone member that declares properties adds to it, so `{ allOf: [{ properties: { name } }], required: [name] }` is the merged model with `name` required; beside a lone array, primitive or enum member `required` constrains nothing and the member is unwrapped); a single `$ref` member with nothing beside it that adds to the component (`allOf: [{ $ref: Base }]`, `allOf: [{ $ref: Base }, { description }]`, `allOf: [{ $ref: Base }, { maxItems: 10 }]`) is a reference to that component, and nothing is minted — a component the document declares under the nested name is never replaced by an unused merged copy, and a response body written so is `Base`, not a copy of `Base` under the operation's name. A second `$ref` member, or a `required` list beside the `$ref` — the schema's own, or a member's — adds to the component, so `allOf: [{ $ref: Pet }, { $ref: Owner }]` and `allOf: [{ $ref: Pet }], required: [name]` are the merged model (``, `Response`), carrying every component's properties and every `required`, at every site alike. A `required` adds to the component only when the component declares properties the merge reads — its own, or its `allOf` chain's: JSON Schema applies `required` to an object instance only, so beside a `$ref` to an array, a primitive, an enum or a union component it constrains nothing, and the reference stands (`{ allOf: [{ $ref: Pets }], required: [name] }` with `Pets` an array component is `Pet[]`, as `{ allOf: [{ $ref: Pets }] }` is). A merged model is built with whatever fields the merge yields: an empty `properties: {}` is an inline object with no fields, as it is as a plain field or a union variant, so `{ allOf: [{ oneOf: [{ type: 'object', properties: {} }] }, { description }] }` is a model with no fields rather than a reference nothing creates. +**Inline `allOf`.** An inline schema composed with `allOf` is merged into one model by a single reading (`mergeAllOfMembers`), wherever it appears: a request body, a response body of every kind (primary, secondary media type, error, `default` and range), a response header, a webhook payload or parameter, a component or inline model's field, array items, map values, a union's `allOf` variant, and a field nested under a discriminated component variant. Members are read in document order. A `$ref` member contributes its component's properties (those of the component's own `allOf` chain included). A model built from the merge (`materializeMergedAllOf`: a field anywhere, a field nested under a discriminated component variant) types each inherited property as the component's model types it (`Base.meta` stays `BaseMeta`) and does not read into it again — the component being the one whose own model carries the field: an `allOf` component that inherits it, through a `$ref` or a nullable member, types everything reached inside its chain under its own name (`DogMeta`), while a bare alias (`Hop: { $ref: Base }`), a nullable alias (`Alias: { oneOf: [{ $ref: Base }, { type: 'null' }] }`) or a chain of either declares nothing of its own and builds no model, so a field reached through one is typed under the component that declares it (`Base.meta` is `BaseMeta` through `Alias`, never `AliasMeta`) — so a component that extends itself through a field (`Node.child: allOf: [{ $ref: Node }, …]`) is typed as the `NodeChild` that exists; a primary response body types inherited properties under its own name. A discriminated component variant's model is built as any inline object's is — its fields by the field extractor under the variant's name — so every shape under it (a nested object, typed or not; array items and map values of any shape; a union and its members; an `allOf` merge; an inline enum) is materialized under the name its type uses, and typing and extraction cannot disagree there. A ref no component schema answers contributes nothing (a response whose only `$ref` is unanswered is typed as that reference). A `oneOf`/`anyOf` member with exactly one non-null variant beside `null` is a nullable wrapper, not a union — decided by `nullableUnionBranch`, the definition `unwrapNullableSchema` collapses by — and applies as that variant wherever the member sits, a `$ref` member's component included: its `$ref` is followed, its `required` read and its properties always-applying, for the inline merge, for a component's `allOf` model (`readComponentAllOfMember`) and for a list envelope's companions (`collectObjectProperties`) alike, so `allOf: [{ oneOf: [{ $ref: Base }, { type: 'null' }] }, { properties: { data } }]` carries `Base`'s fields beside `data` (#308). An inline member contributes its own properties, those of its nested `allOf` members and — for a `oneOf`/`anyOf` member with more than one non-null variant — those of every inline object variant — read through the variant's own `allOf` members and nested unions the same way — as optional fields: `allOf: [{ properties: { name }, required: [name] }, { oneOf: [{ properties: { ownerId } }, { properties: { shelterId } }] }]` is `{ name, ownerId?, shelterId? }` inline exactly as it is as a component. A variant's `required` list is not read, since its properties are present only on that branch; a `$ref` under a variant — the variant itself, or a member of the variant's own `allOf` — is not followed (its component keeps its fields); and a union a `$ref` member's component carries is not read (that component's own model dispatches or flattens it). A property keeps the first always-applying declaration that declares a type, a typeless one before or after it only annotating it — two typing inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both (`result` is a `PetPage` that also has `total_pages`, its inherited fields typed as `PetPage` types them), two typing arrays combine their `items` by this same rule, and a declaration conjoined from two components' is the merged model's own (`HolderValueResult`), since no component model nests the whole — whatever a variant says; a property only variants declare is typed as the union of its distinct declarations (the keywords the field reads from its own schema, below, ignored): one shape is its first declaration, several shapes are a `oneOf` of them read as any inline union is — `kind: { const: cat }` beside `kind: { const: dog }` becomes an inline enum `Kind`, `const: true` beside `const: false` a union of literals, two inline objects a union of two variant models — so a field is never typed from one variant's value alone. A field reads its flags, documentation and limits from its own schema, never from a union's variants, so the combined declaration — one shape's or a union's — carries every such keyword the declarations agree on, value for value — `readOnly`, `writeOnly`, `deprecated`, `description`, `default`, `example`, `examples`, `externalDocs` and the validation keywords: two `kind` declarations both `readOnly: true` with different consts make a read-only enum field; a keyword the declarations disagree on, or only some declare, is not the field's, nor a second shape: `kind: { type: string, description: A }` beside `kind: { type: string, readOnly: true }` is a `string` field with neither (#397). A `null` any declaration admits (`nullable: true` or a `type` list naming it) is a value the property takes on that branch, so the combined declaration admits it (`nullable`, `nullable`). The declarations themselves are kept as written inside the union, so an enum member or a variant model still carries its own description and limits. A schema reached twice is read once in each capacity it is reached in — as an inline member (unions included), through a `$ref` member (its unions left to its component) or under a variant — and only an inline reading stands for the others: an object shared between a variant and a member (a YAML anchor, an object a `transformSpec` hook reuses, a `$ref` written twice) still contributes its `required`, and is still followed, when the member is reached; a component's own object reused as an inline member (`allOf: [{ $ref: Shared }, Shared]`) still has its union read after the `$ref` reading left it to the component. An inline `allOf` is always flattened: the dispatcher form — a `discriminator` whose mapping names one model per variant — is built for component schemas only. How an inline `allOf` is read is one decision made everywhere — by the type builder, the name predictor, both extractors and the primary response body path (`readInlineAllOf`) — so a model is built exactly when a reference names it: a `$ref` member beside an inline object member (`type: 'object'`, or properties declared inline — its own, a nested `allOf` member's or a `oneOf`/`anyOf` variant's: `declaresInlineProperties`), or several inline members one of which declares properties, is the merged model `` (`allOf: [{ $ref: Base }, { oneOf: [...] }]` carries `Base`'s fields and the variants', not a bare reference to `Base`); a single non-`$ref` member with nothing beside it that adds to it is unwrapped and read as the field or body itself, the wrapper's limits, `deprecated` and link carried onto it (a `required` the wrapper writes beside a lone member that declares properties adds to it, so `{ allOf: [{ properties: { name } }], required: [name] }` is the merged model with `name` required; beside a lone array, primitive or enum member `required` constrains nothing and the member is unwrapped); a single `$ref` member with nothing beside it that adds to the component (`allOf: [{ $ref: Base }]`, `allOf: [{ $ref: Base }, { description }]`, `allOf: [{ $ref: Base }, { maxItems: 10 }]`) is a reference to that component, and nothing is minted — a component the document declares under the nested name is never replaced by an unused merged copy, and a response body written so is `Base`, not a copy of `Base` under the operation's name. A second `$ref` member, or a `required` list beside the `$ref` — the schema's own, or a member's — adds to the component, so `allOf: [{ $ref: Pet }, { $ref: Owner }]` and `allOf: [{ $ref: Pet }], required: [name]` are the merged model (``, `Response`), carrying every component's properties and every `required`, at every site alike. A `required` adds to the component only when the component declares properties the merge reads — its own, or its `allOf` chain's: JSON Schema applies `required` to an object instance only, so beside a `$ref` to an array, a primitive, an enum or a union component it constrains nothing, and the reference stands (`{ allOf: [{ $ref: Pets }], required: [name] }` with `Pets` an array component is `Pet[]`, as `{ allOf: [{ $ref: Pets }] }` is). A merged model is built with whatever fields the merge yields: an empty `properties: {}` is an inline object with no fields, as it is as a plain field or a union variant, so `{ allOf: [{ oneOf: [{ type: 'object', properties: {} }] }, { description }] }` is a model with no fields rather than a reference nothing creates. ### Field Examples diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index 84e990a7..a57142f7 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -2211,7 +2211,8 @@ export function declareProperty( * variant can only narrow what every instance * carries. A property only variants declare is typed as the union of its * distinct declarations (`unionOfDeclarations`): one declaration, or several - * of one shape, is that declaration; several shapes become a `oneOf` of + * of one shape, is that declaration (the first, carrying only the flags, + * documentation and limits they agree on); several shapes become a `oneOf` of * them, which the type builder reads as it reads any inline union — string * consts pinned differently per variant (`kind: cat` | `kind: dog`) collapse * to an inline enum, other literals and types to a union — so the field is @@ -2332,7 +2333,8 @@ export function mergeAllOfMembers( * The keywords a field reads from its own schema — the flags and * documentation `buildFieldFromSchema` reads and the validation keywords * `extractConstraints` reads — which `unionOfDeclarations` lifts onto a - * combined declaration when every declaration agrees on them. + * combined declaration when every declaration agrees on them, and which + * therefore make no shape of their own there. */ const LIFTED_DECLARATION_KEYWORDS: readonly string[] = [ 'readOnly', @@ -2363,8 +2365,9 @@ const LIFTED_DECLARATION_KEYWORDS: readonly string[] = [ /** * The one schema that types a property several variants declare: the first - * declaration when every declaration has the same shape (`schemaShapeKey`, - * documentation ignored), else a `oneOf` of the distinct shapes, each the + * declaration when every declaration has the same shape (`schemaShapeKey` + * of the declaration less `LIFTED_DECLARATION_KEYWORDS`, so documentation, + * flags and limits ignored), else a `oneOf` of the distinct shapes, each the * first declaration of its shape, in order. See `mergeAllOfMembers`. * * A field reads its flags, documentation and limits from its own schema @@ -2374,11 +2377,13 @@ const LIFTED_DECLARATION_KEYWORDS: readonly string[] = [ * two `kind` declarations both `readOnly: true` with different consts make a * read-only enum field; a keyword the declarations disagree on, or one only * some declare, is not the field's. Every declaration as written has a say, - * a repeat of an earlier shape included. The extensions are read by the same - * agreement (`agreedExtensions`), and — unlike the keywords, which the first - * declaration gives when all share one shape — under both outcomes: the one - * declaration a single shape is typed from carries the agreed keys in place - * of its own. The declarations themselves are kept as + * a repeat of an earlier shape included. The keywords and the extensions + * (`agreedExtensions`) are read by this agreement under both outcomes: the + * one declaration a single shape is typed from carries the agreed keywords + * and keys in place of its own, so two `type: 'string'` declarations, one + * `readOnly` or documented otherwise, are a plain `string` field + * (workos/oagen#397: they were `(string | string)`, or the first's + * description). The declarations themselves are kept as * written inside the `oneOf`, so an enum member or a variant model still * carries its own description and limits. A `null` any declaration admits * (`nullable: true`, or a `type` list naming `null`) is a value the property @@ -2389,24 +2394,31 @@ const LIFTED_DECLARATION_KEYWORDS: readonly string[] = [ */ function unionOfDeclarations(declarations: readonly SchemaObject[]): SchemaObject { if (declarations.length === 1) return declarations[0]!; - // The extensions are decided first, across every declaration as written, - // apart from the shape dedupe below: a declaration dropped as a repeat of - // an earlier shape still has its say, so `x-since` written as `1`, `2` and - // `1` by three declarations is nobody's (workos/oagen#369 review, which - // read the keys off the distinct shapes alone and kept the `1`). + // The extensions and the keywords are decided first, across every + // declaration as written, apart from the shape dedupe below: a declaration + // dropped as a repeat of an earlier shape still has its say, so `x-since` + // written as `1`, `2` and `1` by three declarations is nobody's + // (workos/oagen#369 review, which read the keys off the distinct shapes + // alone and kept the `1`). const extensions = agreedExtensions(declarations); + const agreed = agreedDeclarationKeys(declarations, LIFTED_DECLARATION_KEYWORDS); + // The keywords the field reads by that agreement make no shape. + const lifted = new Set(LIFTED_DECLARATION_KEYWORDS); const distinct = new Map(); for (const declaration of declarations) { - const key = schemaShapeKey(declaration); + const key = schemaShapeKey(omitKeys(declaration, lifted)); if (!distinct.has(key)) distinct.set(key, declaration); } const shapes = [...distinct.values()]; - // One shape: the first declaration, as written, carrying the agreed + // One shape: the first declaration, carrying the agreed keywords and // extensions in place of its own. - if (shapes.length === 1) return withAgreedExtensions(shapes[0]!, extensions); - // The keywords are read across every declaration the same way: a dropped - // repeat of a shape that disagrees on a keyword leaves it off too. - const agreed: Record = { ...agreedDeclarationKeys(declarations, LIFTED_DECLARATION_KEYWORDS) }; + if (shapes.length === 1) { + const first = shapes[0]!; + const disputed = new Set( + LIFTED_DECLARATION_KEYWORDS.filter((key) => Object.hasOwn(first, key) && !Object.hasOwn(agreed, key)), + ); + return withAgreedExtensions(disputed.size > 0 ? omitKeys(first, disputed) : first, extensions); + } const anyAdmitsNull = shapes.some(admitsNull); const variants = anyAdmitsNull ? [...shapes.map(withoutNull), { type: 'null' }] : shapes; return { ...agreed, ...extensions, oneOf: variants }; diff --git a/test/parser/inline-allof-oneof.test.ts b/test/parser/inline-allof-oneof.test.ts index e707ebef..46ae910e 100644 --- a/test/parser/inline-allof-oneof.test.ts +++ b/test/parser/inline-allof-oneof.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; +import { diffSpecs } from '../../src/differ/diff.js'; import { parseSpec } from '../../src/parser/parse.js'; import { classifyAndExtractResponse } from '../../src/parser/responses.js'; import { extractSchemas, schemaToTypeRef, type SchemaObject } from '../../src/parser/schemas.js'; @@ -976,8 +977,10 @@ describe('round 1: recursive components, shared schemas, conflicting variant dec const models = modelsByName(ir); const request = models['CreatePetRequest']!; const typeOf = (field: string): TypeRef | undefined => request.fields.find((f) => f.name === field)?.type; - // Documentation does not make a second shape: the first declaration stands. - expect(request.fields.find((f) => f.name === 'same')).toMatchObject({ type: str, description: 'first' }); + // Documentation does not make a second shape: one declaration types the + // field, without the description the two disagree on (#397). + expect(request.fields.find((f) => f.name === 'same')).toMatchObject({ type: str }); + expect(request.fields.find((f) => f.name === 'same')?.description).toBeUndefined(); // Two literals: a union of them, never the first alone. expect(typeOf('flag')).toEqual({ kind: 'union', @@ -1170,6 +1173,143 @@ describe('round 2: keywords the declarations of a combined property agree on', ( }); }); +describe('declarations that differ only in a keyword the field reads by agreement are one shape (#397)', () => { + /** The issue's `POST /pets` 200 body: a required `name`, and `kind` declared once by each variant. */ + const createPet = (...kinds: SchemaObject[]) => ({ + openapi: '3.1.0', + info, + paths: { + '/pets': { + post: { + operationId: 'createPet', + responses: { + '200': jsonResponse({ + allOf: [ + { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + { oneOf: kinds.map((kind) => ({ type: 'object', properties: { kind } })) }, + ], + }), + }, + }, + }, + }, + }); + const kindOf = (ir: ApiSpec) => modelsByName(ir)['CreatePetResponse']?.fields.find((f) => f.name === 'kind'); + + it("types the issue's documents as one string with no keyword the declarations dispute, so none of them changes the field", async () => { + const first: SchemaObject = { type: 'string', description: 'A' }; + const same = await parseWithoutWarnings(createPet(first, { type: 'string', description: 'B' })); + // `A` and `B` disagree, so the description is nobody's. + expect(kindOf(same)).toEqual({ name: 'kind', type: str, required: false }); + for (const second of [ + { type: 'string', readOnly: true }, + { type: 'string', writeOnly: true }, + { type: 'string', default: 'b' }, + { type: 'string', deprecated: true }, + { type: 'string', maxLength: 5 }, + { type: 'string', example: 'b' }, + ]) { + const ir = await parseWithoutWarnings(createPet(first, second)); + // Not `(string | string)`, and without what only one declaration says. + expect(kindOf(ir), JSON.stringify(second)).toEqual({ name: 'kind', type: str, required: false }); + // `oagen diff --old same.json --new `: nothing changed. + expect(diffSpecs(same, ir).changes, JSON.stringify(second)).toEqual([]); + } + expectSound(same, []); + }); + + it('keeps on one shape every keyword the declarations write the same way, as a union of shapes does', async () => { + const shared = { description: 'The kind', readOnly: true, default: 'cat', deprecated: true, maxLength: 3 }; + const ir = await parseWithoutWarnings( + createPet({ type: 'string', ...shared }, { type: 'string', ...shared, example: 'dog' }), + ); + expect(kindOf(ir)).toEqual({ + name: 'kind', + type: str, + required: false, + description: 'The kind', + readOnly: true, + default: 'cat', + deprecated: true, + constraints: { maxLength: 3 }, + }); + expectSound(ir, []); + }); + + it('gives an inline enum or object of one shape only what its declarations agree on, as one enum or model', async () => { + const values = ['a', 'b']; + const enumOf = (ir: ApiSpec) => ir.enums.find((e) => e.name === 'CreatePetResponseKind'); + const kindEnum = { kind: 'enum', name: 'CreatePetResponseKind', values }; + // The text and the `x-since` the two declarations dispute are neither the + // field's nor the enum's. + const disputed = await parseWithoutWarnings( + createPet( + { type: 'string', enum: values, description: 'A', 'x-since': '1.0' }, + { type: 'string', enum: values, description: 'B', 'x-since': '2.0' }, + ), + ); + expect(kindOf(disputed)).toEqual({ name: 'kind', type: kindEnum, required: false }); + expect(enumOf(disputed)).not.toHaveProperty('description'); + expect(enumOf(disputed)).not.toHaveProperty('extensions'); + expect(enumOf(disputed)?.values.map((v) => v.value)).toEqual(values); + // Text both write is both the field's and the enum's. + const agreed = await parseWithoutWarnings( + createPet( + { type: 'string', enum: values, description: 'Kinds' }, + { type: 'string', enum: values, description: 'Kinds', readOnly: true }, + ), + ); + expect(kindOf(agreed)).toEqual({ name: 'kind', type: kindEnum, required: false, description: 'Kinds' }); + expect(enumOf(agreed)?.description).toBe('Kinds'); + expectSound(disputed, []); + + // An object deprecated on one branch only is one live model, not a + // deprecated `CreatePetResponseKind` beside a live `CreatePetResponseKind2`. + const size = { type: 'object', properties: { size: { type: 'integer' } } }; + const objects = await parseWithoutWarnings(createPet({ ...size, deprecated: true, description: 'A' }, size)); + expect(kindOf(objects)).toEqual({ name: 'kind', type: model('CreatePetResponseKind'), required: false }); + expect(objects.models.map((m) => m.name).sort()).toEqual(['CreatePetResponse', 'CreatePetResponseKind']); + expect(modelsByName(objects)['CreatePetResponseKind']).toEqual({ + name: 'CreatePetResponseKind', + fields: [{ name: 'size', type: { kind: 'primitive', type: 'integer' }, required: false }], + }); + expectSound(objects, []); + }); + + it('reads each shape once in a union of shapes, whatever keyword the field reads its repeats differ in', async () => { + const ir = await parseWithoutWarnings( + createPet( + { type: 'string', readOnly: true }, + { type: 'string', default: 'b' }, + { type: 'integer', readOnly: true }, + ), + ); + expect(kindOf(ir)).toEqual({ + name: 'kind', + type: { kind: 'union', compositionKind: 'oneOf', variants: [str, { kind: 'primitive', type: 'integer' }] }, + required: false, + }); + expectSound(ir, []); + }); + + it('drops from one shape only the lifted keywords the declarations dispute, and still tells apart what types them', () => { + const mergedKind = (...kinds: SchemaObject[]) => + mergeAllOfMembers({ allOf: [{ oneOf: kinds.map((kind) => ({ properties: { kind } })) }] }, undefined).properties; + expect( + mergedKind( + { type: 'string', format: 'uuid', readOnly: true, description: 'A' }, + { type: 'string', format: 'uuid', readOnly: true, description: 'B', default: 'x' }, + ), + ).toEqual([{ name: 'kind', origin: null, schema: { type: 'string', format: 'uuid', readOnly: true } }]); + // A `format` types the declaration, as a `const` or an `enum` does: still two shapes. + const uuid: SchemaObject = { type: 'string', format: 'uuid', readOnly: true }; + const plain: SchemaObject = { type: 'string', readOnly: true, description: 'Plain' }; + expect(mergedKind(uuid, plain)).toEqual([ + { name: 'kind', origin: null, schema: { readOnly: true, oneOf: [uuid, plain] } }, + ]); + }); +}); + describe('properties are matched by own name', () => { it('keeps a property named constructor, toString or __proto__ through the merge, from a member or a variant', async () => { const ir = await parseWithoutWarnings({ From 1d763d1977633711810c2bd01a9993ae217c7dea Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 11:24:31 -0400 Subject: [PATCH 44/59] fix(parser)!: name an allOf response body for its operation, not a variant's const Since #334 the inline object variants of an allOf member's oneOf add their properties to the merged response model, and extractDirectResource named that model with deriveModelName, which takes the first `object` or `type` string const, then any string const, whichever member or variant declared it. A const only one variant declares holds on that branch alone, yet it named the whole body: `Cat` for a body that is a cat or something else, or `Pet` renamed `CreatePetPet` off a component with a warning. A const added to another variant then renamed the body, and `oagen diff` reported a breaking model-removed and operation-modified for an additive change. mergeAllOfMembers now marks a property only variants declare as `conditional`, and extractDirectResource hands those names to deriveModelName, which skips their consts. Such a body keeps the operation's name (`CreatePetResponse`), as it had before #334, even when every variant declares the same const. A const an always-applying member declares, a nullable member's one variant included, still names the body, and the list envelope check still reads every property, so `object: list` beside a data array the variants declare never names it `List`. Closes #396 --- docs/architecture/ir-types.md | 2 +- src/parser/inline-models.ts | 10 +- src/parser/responses.ts | 22 ++++- test/parser/inline-allof-oneof.test.ts | 126 ++++++++++++++++++++++++- 4 files changed, 148 insertions(+), 12 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 3daf7dd8..d8e0e7de 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -753,7 +753,7 @@ Models come from `components/schemas` (named as written, after `cleanSchemaName` - **Without an `operationId`:** from the full path and method. Each non-parameter segment is PascalCased in order, each `{param}` segment becomes `By` + `PascalCase(param)`, a custom method written after a colon is read as its segment followed by the verb (`POST /pets/{id}:adopt` → `PetsByIdAdoptPostRequest`, the prefix of `/pets/{id}/adopt`), a segment with several colons outside braces is one literal even when a parameter opens it (`POST /v1/pets/{id}:adopt:now` → `V1PetsIdAdoptNowPostRequest`), then the method and `Request` / `Response` are appended, and the whole name is normalized with `toPascalCase` so an operation's body and response share a prefix (`POST /a/b` → `AbPostRequest` / `AbPostResponse`). `POST /notes` → `NotesPostRequest` / `NotesPostResponse`; `POST /notes/{id}/pin` → `NotesByIdPinPostResponse`; `GET /users/{user_id}/sessions` → `UsersByUserIdSessionsGetResponse`. This tells the common path shapes apart (list vs. item, sibling and nested sub-resources) but is not unique by construction: `/foo-bar` and `/foo/bar`, `/notes/by-id` and `/notes/{id}`, `/notes` and `/notes/`, or `/pets/{id}:adopt` and `/pets/{id}/adopt` flatten to the same words. Names already taken are reserved first: every component schema name and, for every operation with an `operationId`, the stem all of its models start with (`PascalCase(operationId)` after `cleanSchemaName`). A path-based prefix is treated as colliding when any reserved name starts with it, because one prefix is the stem of several emitted names (`…Request`, `…Response`, a list's `…Item`, nested inline models such as `…ResponseOwner`, envelope companions such as `…ListMetadata`). Names derived from a path-based prefix use it verbatim — body `oneOf` variants included (`NotesPostRequest`, `NotesPostRequest2`, `EmailNotesPostRequest`) — and are never re-cleaned, so no later step can move them out from under the reservation; as a second line of defence the check also runs on the cleaned spellings of the prefix (`cleanSchemaName`, the project's `schemaNameTransform`, `ListItem`-marker stripping), so an unnamed `GET /notes` also yields to a component `NoteGetItem`. Names that embed the prefix behind a const value are covered as well: a body `oneOf` variant pinning `kind: email` is `EmailTagsPostRequest` and a nested `oneOf`/`anyOf` variant is ``, so every string `const` (or single-value `enum`) found in the operation's inline body and 2xx response variants, top level and nested, is prepended to every spelling before the check, and a hashed prefix lands inside those names too (`EmailTags151Dd5PostRequest`). Names that do not embed the prefix at all — a response `oneOf` variant named from its bare const (`Email`) or a `title`-mapped variant — are outside what a disambiguator can separate and are left as they are. This is deliberately a superset: a component `AuditEventsGetter`, or an operationId `auditEventsGetSummary`, also pushes an unnamed `GET /audit/events` onto a hashed name although no emitted name would coincide; determinism and never sharing a name wrongly matter more than the rare extra hash. The reservation is unconditional, so an inline body is never merged into a same-named component even when their shapes agree. When two distinct paths with the same method would collide, the path spelled most plainly keeps the plain name (no trailing slash, `{id}` rather than a literal `by-id`; precisely, the lowest `[trailing slash, custom methods written after a colon, literal segments with a character outside a-z0-9]`, so `/pets/{id}/adopt` keeps the plain name over `/pets/{id}:adopt` and `/pets/{id}/batchAdopt` over `/pets/{id}:batchAdopt`, while a parameter segment is never irregular and `/tags/{tag}.json` beside `/tags/{tag}/json` ties), and every other path in the group gets a six-character disambiguator derived from its raw path (the start of its SHA-1, inserted before the method): `POST /foo/bar` next to `POST /foo-bar` → `FooBarPostResponse` and `FooBar546F12PostResponse`; `GET /notes/{id}` next to `GET /notes/by-id` → `NotesByIdGetResponse` and `NotesByIdBa41B5GetResponse`. When two paths tie for plainest (`/foo-bar` and `/foo_bar`), or the path-based name matches a reserved one (an unnamed `POST /audit/events` next to `operationId: auditEventsPost` or next to `components/schemas/AuditEventsPostRequest`), nobody keeps the plain name and every path in the group is disambiguated. The result depends only on the document's paths, `operationId`s and component names and never on their order; `operationId`-derived and component names never change, and paths that do not collide keep the plain name. - **Shared through `components/responses`:** a response written as `{ $ref: '#/components/responses/PetFound' }` is resolved before it is read (chains are followed; a circular chain fails the parse; a `$ref` that points outside `components/responses` — into another section such as `#/components/schemas/Pet`, or a bundler-written pointer such as `#/paths/~1pets/get/responses/200` — is warned about (`Unresolved response $ref "#/components/schemas/Pet" in 404 response of getPet: no matching entry under components/responses — reading the response as written, without a body or headers`) and read as written, with no body or headers, while a `$ref` into `components/responses` that names no entry never reaches the parser: the bundler rejects it, and `parseSpec` throws `Failed to parse spec: Can't resolve $ref`; a sibling `description` beside the `$ref` wins in an OAS 3.1 document, outermost first, and is ignored in an OAS 3.0 one). Its inline body is named after the component, not the operation — `PascalCase(componentName)` + `Response`, passed through `cleanSchemaName`, the suffix not doubled when the component is already named `…Response` (`PetFound` → `PetFoundResponse`, `ErrorResponse` → `ErrorResponse`) — so every operation that returns it shares one model instead of minting a `Response` copy each — whether it returns it at an explicit status code, as `default` or in a `1XX`–`5XX` range: a `default` and a `4XX` both written as `$ref: '#/components/responses/Problem'` are one `ProblemResponse`, shared with the `404` of another operation that points at it; a chain is named after the ref the operation wrote. Everything else the component declares that mints a model is named from the stem of that shared body name — `Response` less its `Response` (`Card`), or `CardResponse2` when the body stepped to it — too: an inline object under another media type is `Response` (`Card` with an `application/yaml` body → `CardApplicationYamlResponse`) and an object-shaped header is `
` (`Card` with `X-Meta` → `CardXMeta`), nested objects under either qualified by it. These names do not depend on the operation, its service, or the status code or fallback key (`404`, `302`, `default`, `4XX`) the operation mounts the component at, so every operation that returns the component shares one model of each, as it shares the body; a name a component schema or another site already owns with a shape it does not share steps by number (`CardXMeta2`, `CardApplicationYamlResponse2`), as the component's body steps to `CardResponse2`, never by the operation or the status code — those qualifiers belong to an inline response's sites — so a stepped name depends on the component alone too, and removing one of the operations that return the component leaves the others' names unchanged; an inline enum such a site carries — a header's, a secondary body's, the body's — steps by number the same way when the collectors promote it (`CardXMode2`), never by the status code or fallback label (see Response Headers and Response Content Types above). The component's stem is reserved like an `operationId`'s, so an unnamed `GET /owner` beside a component response `OwnerGet` takes a hashed name. The names a body mints are reserved under every reading an operation can give it — a list envelope unwrapped for an iterator (`NotePage: { data: { title }[], has_more }` → `NotePageItem`) and kept as the wire shape (`NotePageResponse`, `NotePageResponseData`) — since which reading an operation takes depends on its own query parameters and override, and two operations returning one component can differ (`GET /notes` keeps it, `GET /notes/recent?after` unwraps it): beside a schema `NotePageResponseData` or `NotePageItem` the stem steps to `NotePageResponse2` for every operation, so a kept body's item model is never renamed late and both readings move together. The component's headers resolve through `components/headers` as inline ones do. -A response object with a string `const` discriminant (`object: "note"`) is named from that value instead (`Note`), except a list envelope kept as a plain model: its const (`object: "list"`) names the kind every envelope shares, not what it lists, so it keeps the operation's name (`ListPetsResponse`, never `List`); nested inline objects are qualified with their parent model's name. The items of an array primary success body (`Operation.response`: `type: 'array'` or bare `items`, nullable in any spelling or not; a secondary media type's, fallback or error body keeps its own name, `ListPetsApplicationVndPetJsonResponse`, `ListPetsError409`) and of a list envelope's data array are typed as a field named `Item` — the `Response` suffix replaced with `Item` — under no parent, whatever their shape, so every model they reference is created under the name the reference uses: a plain object is `ListPetsItem`; a nullable object is `nullable`; the inline object variants of a `oneOf`/`anyOf` take the union's names from it (`ListPetsItem`, `ListPetsItemWithSide`, `CircleListPetsItem`); an inline enum is `ListPetsItem`; the objects of a nested array or map, and anything under any of these, are qualified under it (`ListPetsItemOwner`); a `$ref` stays the reference and mints nothing. The names are settled against the spec-wide registry of model shapes as the operation is read (see Response Headers): when a name the items mint — `Item` itself, a union variant, a nested object — already belongs to another shape, a component model or enum (`ListShapesItem { id }`), a component's field model or an earlier operation's model, the items are retried as `ResponseItem` (`ListShapesResponseItem`, `ListShapesResponseItemWithSide`), then `Item2`, `Item3`, …, the first candidate that clashes with nothing; items identical to a name's owner share it and mint nothing, so an array of `{ name }` beside a component `ListPetsItem { name }` references the component. A request body or parameter that is an array is typed as the body or parameter itself, so its inline object items are `CreatePetsRequest` and `PetsFilter`, not `…Item`. +A response object with a string `const` discriminant (`object: "note"`) is named from that value instead (`Note`), except a list envelope kept as a plain model: its const (`object: "list"`) names the kind every envelope shares, not what it lists, so it keeps the operation's name (`ListPetsResponse`, never `List`). Of an inline `allOf` body, only a const an always-applying member declares can name it: a property only the `oneOf`/`anyOf` variants of a member declare holds on a branch, not on every body, so `allOf: [{ properties: { name } }, { oneOf: [{ properties: { kind: { const: cat } } }, { properties: { bark } }] }]` keeps the operation's name (`CreatePetResponse`, never `Cat`), even when every variant declares the same const, and a const added to a variant renames nothing (#396). Nested inline objects are qualified with their parent model's name. The items of an array primary success body (`Operation.response`: `type: 'array'` or bare `items`, nullable in any spelling or not; a secondary media type's, fallback or error body keeps its own name, `ListPetsApplicationVndPetJsonResponse`, `ListPetsError409`) and of a list envelope's data array are typed as a field named `Item` — the `Response` suffix replaced with `Item` — under no parent, whatever their shape, so every model they reference is created under the name the reference uses: a plain object is `ListPetsItem`; a nullable object is `nullable`; the inline object variants of a `oneOf`/`anyOf` take the union's names from it (`ListPetsItem`, `ListPetsItemWithSide`, `CircleListPetsItem`); an inline enum is `ListPetsItem`; the objects of a nested array or map, and anything under any of these, are qualified under it (`ListPetsItemOwner`); a `$ref` stays the reference and mints nothing. The names are settled against the spec-wide registry of model shapes as the operation is read (see Response Headers): when a name the items mint — `Item` itself, a union variant, a nested object — already belongs to another shape, a component model or enum (`ListShapesItem { id }`), a component's field model or an earlier operation's model, the items are retried as `ResponseItem` (`ListShapesResponseItem`, `ListShapesResponseItemWithSide`), then `Item2`, `Item3`, …, the first candidate that clashes with nothing; items identical to a name's owner share it and mint nothing, so an array of `{ name }` beside a component `ListPetsItem { name }` references the component. A request body or parameter that is an array is typed as the body or parameter itself, so its inline object items are `CreatePetsRequest` and `PetsFilter`, not `…Item`. If two inline models still resolve to the same name with different fields or field types (two `operationId`s that PascalCase to the same word, for example), the parser keeps the one with more fields — the first definition on a tie — and prints `[oagen] Warning: Inline model "…" has more than one definition with different fields or field types`. One kind of same-named model is never settled that way: a model whose own field or variant references a model the merge passes renamed — a parent whose nested object left a shared name, in this pass or an earlier one (`InlineModelNaming.minted`; a reference the site minted, never an explicit `$ref` or one an `allOf` merge copied from another component's field). Its shape is its site's own, so it leaves the shared name the way a model a component does not admit does — clustered by shape with the others like it, each cluster qualified by its first site — and the merge passes say so: `[oagen] Renamed inline model "Widget" → "GetBWidget": it references a renamed model and "Widget" keeps another definition`. Folded by name onto the first definition, it would leave the renamed child referenced by nothing; kept as the larger definition, it would hand every other operation sharing the name the deprecated child the passes had just kept off it. The models that follow no rename are settled as above; when every model of a name follows one, the first cluster keeps the name, as the first definition would; and a model under a name a rename minted — the leader of a cluster, which the other sites of the cluster reference — keeps it. The schema pass also lifts a component field's own object, array items or union variant into the models under the field extractor's name (`PetWidget` for `Pet.widget`); such a model is not a component, so it belongs to the field's site: the field extractor's copy of the same schema folds onto it when the fields are settled, and a rename of one of the field's nested models is repointed in it as in the copy, so `Pet.widget` keeps resolving to a `PetWidget` that references the renamed `PetWidgetOwner2`. diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index a57142f7..fb429dea 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -1794,6 +1794,8 @@ export interface MergedProperty { name: string; schema: SchemaObject; origin: { name: string; schema: SchemaObject } | null; + /** `true` when only `oneOf`/`anyOf` variants declare it: it holds on a branch, not on every instance. */ + conditional?: true; } /** An inline `allOf` read as one object shape; see `mergeAllOfMembers`. */ @@ -2216,8 +2218,10 @@ export function declareProperty( * them, which the type builder reads as it reads any inline union — string * consts pinned differently per variant (`kind: cat` | `kind: dog`) collapse * to an inline enum, other literals and types to a union — so the field is - * never typed from one variant's value alone. Properties keep the order they - * were first declared in. + * never typed from one variant's value alone. Such a property is marked + * `conditional`: it holds on a branch, so its `const` does not name a + * response body (`extractDirectResource`, workos/oagen#396). Properties keep + * the order they were first declared in. * * A schema reached twice is read once in each capacity it is reached in — * as an inline member (the merged model's own, unions included), through a @@ -2312,7 +2316,7 @@ export function mergeAllOfMembers( const properties: MergedProperty[] = [...declared.values()].map(({ name, unconditional, conditional }) => unconditional ? { name, schema: unconditional.schema, origin: unconditional.origin } - : { name, schema: unionOfDeclarations(conditional), origin: null }, + : { name, schema: unionOfDeclarations(conditional), origin: null, conditional: true }, ); // Limits conjoin the way properties merge, so the merged model declares what // an instance must satisfy; a `$ref` member's target keeps its own limits. diff --git a/src/parser/responses.ts b/src/parser/responses.ts index 06b35539..f575e2cf 100644 --- a/src/parser/responses.ts +++ b/src/parser/responses.ts @@ -955,7 +955,9 @@ function extractDirectResource( // Fields merged from a $ref carry the referenced model's const // discriminant (e.g. object: 'magic_auth'); deriving the name from it // would collide with the component model, so keep the context name. - const modelName = mergedRefFields ? toPascalCase(contextName) : deriveModelName(rebuilt, contextName); + // A const only variants declare holds on a branch: it names nothing. + const branchOnly = new Set(merged.properties.filter((p) => p.conditional).map((p) => p.name)); + const modelName = mergedRefFields ? toPascalCase(contextName) : deriveModelName(rebuilt, contextName, branchOnly); inlineModels.push(...extractInlineModel(modelName, rebuilt, componentSchemas)); return { response: { kind: 'model', name: modelName }, @@ -1062,7 +1064,17 @@ function nullableIf(schema: SchemaObject, ref: TypeRef): TypeRef { return declaresNull(schema) ? { kind: 'nullable', inner: ref } : ref; } -function deriveModelName(schema: SchemaObject, fallback: string): string { +/** + * The name a response object takes from a string `const` it declares + * (`object: 'note'` names it `Note`), else `fallback`. A property in + * `branchOnly` holds on one branch of the body, not on every instance (only + * an inline `allOf`'s `oneOf`/`anyOf` variants declare it: + * `MergedProperty.conditional`), so its const names nothing + * (workos/oagen#396: one variant's `kind: cat` named the body `Cat`, and a + * `kind: dog` added to another variant renamed it); it still counts toward + * the list envelope, which is the whole body's shape. + */ +function deriveModelName(schema: SchemaObject, fallback: string, branchOnly?: ReadonlySet): string { if (!schema.properties) return toPascalCase(fallback); // A list envelope's const (`object: 'list'`) names the envelope's kind, which // every envelope of the API shares, not what it lists (workos/oagen#418). @@ -1071,15 +1083,15 @@ function deriveModelName(schema: SchemaObject, fallback: string): string { // Prefer well-known discriminant properties for backward compatibility const preferred = ['object', 'type']; for (const name of preferred) { - const field = schema.properties[name]; + const field = branchOnly?.has(name) ? undefined : schema.properties[name]; if (field && field.const && typeof field.const === 'string') { return toPascalCase(field.const); } } // Fall back to any property with a const string value - for (const [, field] of Object.entries(schema.properties)) { - if (field && field.const && typeof field.const === 'string') { + for (const [name, field] of Object.entries(schema.properties)) { + if (!branchOnly?.has(name) && field && field.const && typeof field.const === 'string') { return toPascalCase(field.const); } } diff --git a/test/parser/inline-allof-oneof.test.ts b/test/parser/inline-allof-oneof.test.ts index 46ae910e..64187f4b 100644 --- a/test/parser/inline-allof-oneof.test.ts +++ b/test/parser/inline-allof-oneof.test.ts @@ -1168,7 +1168,12 @@ describe('round 2: keywords the declarations of a combined property agree on', ( undefined, ); expect(merged.properties).toEqual([ - { name: 'kind', origin: null, schema: { readOnly: true, description: 'Kind', oneOf: [cat, dog] } }, + { + name: 'kind', + origin: null, + schema: { readOnly: true, description: 'Kind', oneOf: [cat, dog] }, + conditional: true, + }, ]); }); }); @@ -1300,14 +1305,129 @@ describe('declarations that differ only in a keyword the field reads by agreemen { type: 'string', format: 'uuid', readOnly: true, description: 'A' }, { type: 'string', format: 'uuid', readOnly: true, description: 'B', default: 'x' }, ), - ).toEqual([{ name: 'kind', origin: null, schema: { type: 'string', format: 'uuid', readOnly: true } }]); + ).toEqual([ + { name: 'kind', origin: null, schema: { type: 'string', format: 'uuid', readOnly: true }, conditional: true }, + ]); // A `format` types the declaration, as a `const` or an `enum` does: still two shapes. const uuid: SchemaObject = { type: 'string', format: 'uuid', readOnly: true }; const plain: SchemaObject = { type: 'string', readOnly: true, description: 'Plain' }; expect(mergedKind(uuid, plain)).toEqual([ - { name: 'kind', origin: null, schema: { readOnly: true, oneOf: [uuid, plain] } }, + { name: 'kind', origin: null, schema: { readOnly: true, oneOf: [uuid, plain] }, conditional: true }, + ]); + }); +}); + +describe('a const only a variant declares names no inline allOf response body (#396)', () => { + /** The issue's `POST /pets` 200 body: the `allOf` of `members`, beside `schemas` as components. */ + const createPet = (members: SchemaObject[], schemas?: Record) => ({ + openapi: '3.1.0', + info, + paths: { + '/pets': { + post: { operationId: 'createPet', responses: { '200': jsonResponse({ allOf: members }) } }, + }, + }, + ...(schemas && { components: { schemas } }), + }); + const object = (properties: Record): SchemaObject => ({ type: 'object', properties }); + const text = (value?: string): SchemaObject => ({ type: 'string', ...(value !== undefined && { const: value }) }); + /** The issue's always-applying member: a required `name`. */ + const base: SchemaObject = { ...object({ name: text() }), required: ['name'] }; + const responseOf = (ir: ApiSpec) => opsOf(ir)['createPet']!.response; + + it("names the issue's documents after the operation, so a const another variant adds renames nothing", async () => { + // object.json: was `CreatePetPet`, renamed off the component `Pet` with a warning. + const pet = await parseWithoutWarnings( + createPet([base, { oneOf: [object({ object: text('pet') }), object({ x: text() })] }], { + Pet: object({ other: text() }), + }), + ); + expect(responseOf(pet)).toEqual(model('CreatePetResponse')); + expect(pet.models.map((m) => m.name)).toEqual(['Pet', 'CreatePetResponse']); + expect(fieldsOf(modelsByName(pet)['CreatePetResponse'])).toEqual([ + 'name: string', + 'object?: literal', + 'x?: string', + ]); + expectSound(pet, ['Pet']); + // kind-old.json was `Cat`; kind-new.json, `dog` added to the other variant, `CreatePetResponse`. + const cat = object({ kind: text('cat'), meow: text() }); + const kindOld = await parseWithoutWarnings(createPet([base, { oneOf: [cat, object({ bark: text() })] }])); + const kindNew = await parseWithoutWarnings( + createPet([base, { oneOf: [cat, object({ kind: text('dog'), bark: text() })] }]), + ); + for (const ir of [kindOld, kindNew]) { + expect(responseOf(ir)).toEqual(model('CreatePetResponse')); + expect(ir.models.map((m) => m.name)).toEqual(['CreatePetResponse']); + expect(modelsByName(ir)['CreatePetResponse']?.fields.map((f) => f.name)).toEqual([ + 'name', + 'kind', + 'meow', + 'bark', + ]); + expectSound(ir, []); + } + // `oagen diff --old kind-old.json --new kind-new.json`: the operation is + // unchanged and its model is modified, not removed (`Cat`) beside one + // added; the model's `kind` widens from the literal `cat` to an enum of both. + expect(diffSpecs(kindOld, kindNew).changes).toMatchObject([ + { kind: 'model-modified', name: 'CreatePetResponse', fieldChanges: [{ fieldName: 'kind' }] }, + { kind: 'enum-added', name: 'CreatePetResponseKind' }, ]); }); + + it('still names the body after a const every instance carries, and reads the list envelope off every property', async () => { + const cat = object({ kind: text('cat') }); + const bark = object({ bark: text() }); + // An always-applying member's const, before or after the variants, and a + // nullable member's one variant's, which applies as the member does. + for (const members of [ + [{ ...base, properties: { ...base.properties, object: text('pet') } }, { oneOf: [cat, bark] }], + [{ oneOf: [cat, bark] }, object({ object: text('pet') }), base], + [base, { oneOf: [object({ object: text('pet') }), { type: 'null' }] }, { oneOf: [cat, bark] }], + ]) { + const ir = await parseWithoutWarnings(createPet(members)); + expect(responseOf(ir)).toEqual(model('Pet')); + const names = modelsByName(ir)['Pet']?.fields.map((f) => f.name); + expect(names?.sort()).toEqual(['bark', 'kind', 'name', 'object']); + expectSound(ir, []); + } + // A const every variant declares alike still holds on a branch only: a + // variant added without it would rename the body. + const both = await parseWithoutWarnings( + createPet([ + base, + { oneOf: [object({ object: text('pet'), a: text() }), object({ object: text('pet'), b: text() })] }, + ]), + ); + expect(responseOf(both)).toEqual(model('CreatePetResponse')); + expect(fieldsOf(modelsByName(both)['CreatePetResponse'])).toEqual([ + 'name: string', + 'object?: literal', + 'a?: string', + 'b?: string', + ]); + // A member's `object: list` beside a data array and a stop field only + // variants declare is a list envelope's kind, never the body's name (#418). + const page = object({ data: { type: 'array', items: text() }, has_more: { type: 'boolean' } }); + const list = await parseWithoutWarnings(createPet([object({ object: text('list') }), { oneOf: [page, bark] }])); + expect(responseOf(list)).toEqual(model('CreatePetResponse')); + }); + + it('marks a property only variants declare as conditional, and one a member declares as not', () => { + const union: SchemaObject = { oneOf: [object({ name: text('x'), kind: text('cat') }), object({ bark: text() })] }; + for (const members of [ + [base, union], + [union, base], + ]) { + const merged = mergeAllOfMembers({ allOf: members }, undefined); + expect(merged.properties.map((p) => `${p.name}${p.conditional ? ' (conditional)' : ''}`).sort()).toEqual([ + 'bark (conditional)', + 'kind (conditional)', + 'name', + ]); + } + }); }); describe('properties are matched by own name', () => { From fb538f5fc69b47dca26d15c093fbbbcabb23091a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 11:38:54 -0400 Subject: [PATCH 45/59] fix(parser)!: keep a response whose one property is a union of tagged objects A success body with one property, or one required property, holding an object with a string const is a single-resource wrapper and is unwrapped (`{ pet: { object: 'pet', ... } }` is `Pet`). isSingleResourceWrapper also accepted any `oneOf` with at least one such object variant, to cover a nullable resource (`oneOf: [object, null]`), and so caught a `oneOf` of several tagged objects: extractWrappedResource typed the response as the first variant's model and dropped the body, its other properties and every other variant without a warning (`getShape` returned `Circle`). The `oneOf` now makes a wrapper only when one variant is left once every null variant (isNullSchema) is set aside, and that variant is an object with a string const. A union of several non-null variants, titled or not, beside a null or not, of any kind, stays the body's own field and is read as a body with a second property is: `GetShapeResponse { shape }`, `shape` the union of both variant models. `oneOf: [object, null]` and a lone `oneOf: [object]` still unwrap as before. The IR, warnings included, of every fixture spec, the github-subset example and the WorkOS specs is unchanged. Closes #415 --- src/parser/responses.ts | 13 ++++-- test/parser/deprecated.test.ts | 18 ++++++++- test/parser/responses.test.ts | 72 ++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/parser/responses.ts b/src/parser/responses.ts index f575e2cf..df9c3cb3 100644 --- a/src/parser/responses.ts +++ b/src/parser/responses.ts @@ -731,9 +731,14 @@ function isSingleResourceWrapper(schema: SchemaObject): boolean { // Direct object with a discriminant const field if (propSchema.type === 'object' && hasDiscriminantConstField(propSchema) !== null) return true; - // oneOf with object + null (nullable resource) + // A `oneOf` of one such object, with or without `null` variants (a + // nullable resource): what is left once every `null` variant is set aside + // (`isNullSchema`). A union of several non-null variants stays the body's + // own field, however many carry a const; unwrapping it would type the + // response as its first variant and drop the rest (#415). if (propSchema.oneOf) { - return propSchema.oneOf.some((v) => v.type === 'object' && hasDiscriminantConstField(v) !== null); + const [variant, ...others] = propSchema.oneOf.filter((v) => !isNullSchema(v)); + return others.length === 0 && variant?.type === 'object' && hasDiscriminantConstField(variant) !== null; } return false; @@ -759,8 +764,8 @@ function extractWrappedResource( const modelName = deriveModelName(objectVariant, resourceName); // `oneOf: [object, null]` is a nullable wrapper: the model built from // its one branch takes the wrapper's `deprecated` and limits, as every - // other nullable unwrap gives it (`collapsedNullableBranch`); a - // multi-variant union's variant is built as written. + // other nullable unwrap gives it (`collapsedNullableBranch`); otherwise + // the variant is built as written. const source = collapsedNullableBranch(propSchema) ?? objectVariant; inlineModels.push(...extractInlineModel(modelName, source, componentSchemas)); diff --git a/test/parser/deprecated.test.ts b/test/parser/deprecated.test.ts index 2d1afda8..9dfe7d52 100644 --- a/test/parser/deprecated.test.ts +++ b/test/parser/deprecated.test.ts @@ -379,7 +379,8 @@ describe('extractOperations – deprecated', () => { // `type: ['object', 'null']` beside a multi-variant `oneOf` unwraps to a // copy that still carries the union; it is not a branch, so each variant // is built from itself, keeps its fields and its own flag, and the - // wrapper's flag is not carried onto it. + // wrapper's flag is not carried onto it. Nor is such a union a wrapped + // resource (#415): `data` stays the body's own field, every variant kept. const { inlineModels } = extractOperations({ '/a': body( { @@ -418,8 +419,21 @@ describe('extractOperations – deprecated', () => { "k", ], }, - "ThingX": { + "GetCResponse": { "deprecated": undefined, + "fields": [ + "data", + ], + }, + "GetCResponseData": { + "deprecated": undefined, + "fields": [ + "object", + "k", + ], + }, + "ThingYGetCResponseData": { + "deprecated": true, "fields": [ "object", "k", diff --git a/test/parser/responses.test.ts b/test/parser/responses.test.ts index 8e4265ca..320a3b77 100644 --- a/test/parser/responses.test.ts +++ b/test/parser/responses.test.ts @@ -196,6 +196,78 @@ describe('classifyAndExtractResponse', () => { } }); + it('keeps a body whose one property is a union of several const-tagged objects, as a second property keeps it (#415)', () => { + // The issue's `getShape` 200 body: `shape` is a circle or a square, each + // an object pinning `kind`. Unwrapping it typed the response as the + // first variant and dropped the body, its other properties and every + // other variant. + const circle: SchemaObject = { + type: 'object', + properties: { kind: { const: 'circle' }, radius: { type: 'number' } }, + }; + const square: SchemaObject = { + type: 'object', + properties: { kind: { const: 'square' }, side: { type: 'number' } }, + }; + const id: SchemaObject = { type: 'string' }; + const shape: SchemaObject = { oneOf: [circle, square] }; + const classify = (properties: Record, required?: string[]) => + classifyAndExtractResponse({ type: 'object', properties, ...(required && { required }) }, 'GetShapeResponse'); + + // The body with a second, optional property was never a wrapper. + const two = classify({ id, shape }); + expect(two.response).toEqual({ kind: 'model', name: 'GetShapeResponse' }); + const [body, ...variants] = two.inlineModels; + expect(body.fields.map((f) => [f.name, f.required])).toEqual([ + ['id', false], + ['shape', false], + ]); + expect(variants.map((m) => m.name)).toEqual(['GetShapeResponseShape', 'SquareGetShapeResponseShape']); + const [idField, shapeField] = body.fields; + expect(shapeField.type).toMatchObject({ + kind: 'union', + variants: [ + { kind: 'model', name: 'GetShapeResponseShape' }, + { kind: 'model', name: 'SquareGetShapeResponseShape' }, + ], + }); + const withFields = (fields: typeof body.fields) => ({ ...two, inlineModels: [{ ...body, fields }, ...variants] }); + + // one.json: `shape` alone; required-one.json: `id` beside a required `shape`. + expect(classify({ shape })).toEqual(withFields([shapeField])); + expect(classify({ id, shape }, ['shape'])).toEqual(withFields([idField, { ...shapeField, required: true }])); + + // Titled variants, a `null` beside them or another kind of variant are + // still several variants: the body keeps them all. + const titled = classify({ + shape: { + oneOf: [ + { ...circle, title: 'Circle' }, + { ...square, title: 'Square' }, + ], + }, + }); + expect(titled.response).toEqual({ kind: 'model', name: 'GetShapeResponse' }); + expect(titled.inlineModels.map((m) => m.name)).toEqual(['GetShapeResponse', 'Circle', 'Square']); + const nullable = classify({ shape: { oneOf: [circle, square, { type: 'null' }] } }); + expect(nullable.response).toEqual({ kind: 'model', name: 'GetShapeResponse' }); + expect(nullable.inlineModels[0].fields).toEqual([ + { ...shapeField, type: { kind: 'nullable', inner: shapeField.type } }, + ]); + for (const other of [{ $ref: '#/components/schemas/Square' }, { type: ['string', 'null'] }] as SchemaObject[]) { + const mixed = classify({ shape: { oneOf: [circle, other] } }); + expect(mixed.response, JSON.stringify(other)).toEqual({ kind: 'model', name: 'GetShapeResponse' }); + expect(mixed.inlineModels[0].fields.map((f) => f.name)).toEqual(['shape']); + } + + // A `oneOf` of one object, with or without `null` beside it, is still the resource it wraps. + expect(classify({ shape: { oneOf: [circle] } }).response).toEqual({ kind: 'model', name: 'Circle' }); + expect(classify({ shape: { oneOf: [circle, { type: 'null' }] } }).response).toEqual({ + kind: 'nullable', + inner: { kind: 'model', name: 'Circle' }, + }); + }); + it('does not match wrapper with multiple required properties', () => { const schema = { type: 'object', From 4c1faa16106a91338b0151991fbcd16c2ac23c31 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 11:46:13 -0400 Subject: [PATCH 46/59] fix(parser)!: keep a union written beside a wrapped object's own const isSingleResourceWrapper accepted the property as one tagged object before it looked at the `oneOf`, so `type: 'object'` with its own string const beside a `oneOf` of several variants still unwrapped: the response was the first variant's model (`Circle`), dropping the other variants and the const the union carries into each. Beside an `anyOf` of several, the response was the bare object (`Shape { object }`), dropping every variant. A `oneOf`/`anyOf` that types the property (classifyInlineSchema) with more than one variant once the null ones are set aside now makes it no wrapper before anything else is checked, so the body keeps `shape` as a union whose variants carry the const, as a field typed so does. A `oneOf` of bare constraint sets beside a tagged object types nothing and still unwraps, as does a single variant. The IR, warnings included, of every fixture spec, the github-subset example and the WorkOS specs is still unchanged. --- src/parser/responses.ts | 18 ++++++++++++------ test/parser/responses.test.ts | 26 +++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/parser/responses.ts b/src/parser/responses.ts index df9c3cb3..15842139 100644 --- a/src/parser/responses.ts +++ b/src/parser/responses.ts @@ -728,17 +728,23 @@ function isSingleResourceWrapper(schema: SchemaObject): boolean { const propSchema = schema.properties[wrapperKey]; if (!propSchema) return false; + // A `oneOf`/`anyOf` that types the property (`classifyInlineSchema`) with + // more than one variant once every `null` one is set aside (`isNullSchema`) + // is a union, the body's own field, however many variants carry a const + // and whatever properties are written beside it: unwrapping it would type + // the response as one variant, or as those properties alone, and drop the + // rest (#415). A union of bare constraint sets does not type the property; + // it only limits the object. + const variants = (propSchema.oneOf ?? propSchema.anyOf)?.filter((v) => !isNullSchema(v)) ?? []; + if (variants.length > 1 && classifyInlineSchema(propSchema) === 'union') return false; + // Direct object with a discriminant const field if (propSchema.type === 'object' && hasDiscriminantConstField(propSchema) !== null) return true; // A `oneOf` of one such object, with or without `null` variants (a - // nullable resource): what is left once every `null` variant is set aside - // (`isNullSchema`). A union of several non-null variants stays the body's - // own field, however many carry a const; unwrapping it would type the - // response as its first variant and drop the rest (#415). + // nullable resource). if (propSchema.oneOf) { - const [variant, ...others] = propSchema.oneOf.filter((v) => !isNullSchema(v)); - return others.length === 0 && variant?.type === 'object' && hasDiscriminantConstField(variant) !== null; + return variants.length === 1 && variants[0].type === 'object' && hasDiscriminantConstField(variants[0]) !== null; } return false; diff --git a/test/parser/responses.test.ts b/test/parser/responses.test.ts index 320a3b77..5d7ecbeb 100644 --- a/test/parser/responses.test.ts +++ b/test/parser/responses.test.ts @@ -260,12 +260,36 @@ describe('classifyAndExtractResponse', () => { expect(mixed.inlineModels[0].fields.map((f) => f.name)).toEqual(['shape']); } - // A `oneOf` of one object, with or without `null` beside it, is still the resource it wraps. + // Nor does a const written beside the union, under `oneOf` or `anyOf`, + // make it one tagged object: each variant carries it, as on a field. + const tagged: SchemaObject = { type: 'object', properties: { object: { const: 'shape' } } }; + for (const beside of [ + { ...tagged, oneOf: [circle, square] }, + { ...tagged, anyOf: [circle, square] }, + ]) { + const kept = classify({ shape: beside }); + expect(kept.response, JSON.stringify(beside)).toEqual({ kind: 'model', name: 'GetShapeResponse' }); + expect(kept.inlineModels.map((m) => [m.name, m.fields.map((f) => f.name)])).toEqual([ + ['GetShapeResponse', ['shape']], + ['GetShapeResponseShape', ['object', 'kind', 'radius']], + ['SquareGetShapeResponseShape', ['object', 'kind', 'side']], + ]); + } + + // A `oneOf` of one object, with or without `null` beside it, is still + // the resource it wraps, and so is a tagged object a `oneOf` of bare + // constraint sets only limits. expect(classify({ shape: { oneOf: [circle] } }).response).toEqual({ kind: 'model', name: 'Circle' }); expect(classify({ shape: { oneOf: [circle, { type: 'null' }] } }).response).toEqual({ kind: 'nullable', inner: { kind: 'model', name: 'Circle' }, }); + const pet: SchemaObject = { + type: 'object', + properties: { object: { const: 'pet' }, a: { type: 'string' }, b: { type: 'string' } }, + oneOf: [{ required: ['a'] }, { required: ['b'] }], + }; + expect(classify({ pet }).response).toEqual({ kind: 'model', name: 'Pet' }); }); it('does not match wrapper with multiple required properties', () => { From 799ec9992888283cb103e771a619ddff9daab2e2 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 12:02:29 -0400 Subject: [PATCH 47/59] fix(parser)!: keep a variant's const or enum over a wider property written beside its oneOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #333, `properties` written beside a `oneOf`/`anyOf` are folded into each inline object variant, and conjoinSiblingProperties kept the schema's own declaration wherever a variant declared the same name. JSON Schema applies both, so a variant's `kind: { const: 'cat' }` beside an own `kind: { type: 'string' }` is `'cat'`; replacing it with `string` left the discriminator mapping empty, named the variants `…WithBark` instead of from their values, and hid a discriminator value change from `oagen diff`. Where both declare a name, the variant's declaration is now kept when it is the narrower (narrowsDeclaration): it is typed by the values it pins (`const` or `enum`, as classifyInlineSchema reads it) and the own declaration pins none, or pins every one of them and more. The own declaration stays otherwise: an own enum beside a variant's plain type, the same values, or values the own does not all admit. The issue's `kind.json` again gives `GetPetResponsePet { kind: 'cat' }` and `DogGetPetResponsePet { kind: 'dog' }` with the mapping naming both, at every site the fold reaches, and `oagen diff` to `wolf` exits 2. The IR, warnings included, of every fixture spec, the github-subset example and the WorkOS specs is unchanged. Closes #392 --- docs/architecture/ir-types.md | 2 +- src/parser/schemas.ts | 54 ++++++++-- test/parser/inline-array-items.test.ts | 138 +++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 12 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index d8e0e7de..bd684d00 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -171,7 +171,7 @@ Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. T Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context, so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and their children, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it, or an earlier variant of the union minted it; when the chosen context still mints a claimed name it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). -**`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the schema's own declaration kept where the variant declares the same property and the `required` names the union of both lists; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both, two of arrays combine their `items` by this same rule, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it; a union no discriminator dispatches is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), an inline object and a `$ref` to a component that declares properties (its own or its `allOf` chain's), or an `allOf` that reaches one, conjoin as the `allOf` of both, read as an inline `allOf` is (`result: { $ref: PetPage }` beside `result: { properties: { total_pages } }` is a `PageResult` with `PetPage`'s `items` and `total_pages`, whichever member comes first, `items` typed as `PetPage` types it, and the flags and documentation either declaration gives the field stay the field's), while a `$ref` to an array, enum, primitive or union component is no object to conjoin and the first declaration stays; two typing arrays combine their `items` by this same rule (`items: { description }` only annotates `items: { $ref: Pet }`, so `data` stays `Pet[]`) — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). +**`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the `required` naming the union of both lists. Where the variant declares the same property, the narrower declaration is kept, as JSON Schema applies both: the variant's when it pins the property's values with a `const` or an `enum` and the schema's own pins none, or pins those values and more — so `kind: { const: 'cat' }` beside an own `kind: { type: 'string' }` or `kind: { enum: [cat, dog] }` stays `'cat'`, the variants are named from their values (`HolderThing`, `DogHolderThing`) and a discriminated union keeps its mapping — and the schema's own otherwise, so an own `kind` enum beside a variant's plain `kind` stays the enum; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both, two of arrays combine their `items` by this same rule, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it; a union no discriminator dispatches is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), an inline object and a `$ref` to a component that declares properties (its own or its `allOf` chain's), or an `allOf` that reaches one, conjoin as the `allOf` of both, read as an inline `allOf` is (`result: { $ref: PetPage }` beside `result: { properties: { total_pages } }` is a `PageResult` with `PetPage`'s `items` and `total_pages`, whichever member comes first, `items` typed as `PetPage` types it, and the flags and documentation either declaration gives the field stay the field's), while a `$ref` to an array, enum, primitive or union component is no object to conjoin and the first declaration stays; two typing arrays combine their `items` by this same rule (`items: { description }` only annotates `items: { $ref: Pet }`, so `data` stays `Pet[]`) — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). ### UnionType diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index 30ce5b48..afeb02a4 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -2210,14 +2210,16 @@ function unionTypesSchema(variants: readonly SchemaObject[]): boolean { * with `type: 'object'`, no `type`, or a type list naming `object`, so a * nullable or multi-typed object variant included) gains them, the schema's * own first and its own declaration kept where the variant declares the same - * property, so the union keeps every variant and each carries the shared - * properties. Every other variant is typed as written: a `$ref` names a - * component the parser never merges properties into (it may be an enum or a - * union it could not augment); a primitive, an array or an enum is no object, - * so `properties` constrains nothing in it; a nested composition or a - * nullable union wrapper is left to its own variants. `schemaToTypeRef` - * reports the variants that may be objects and did not take the properties - * (`warnOnUncarriedProperties`). + * property unless the variant's is the narrower (`narrowsDeclaration`: a + * variant's `kind: { const: 'cat' }` beside an own `kind: { type: 'string' }` + * stays `'cat'`), so the union keeps every variant, each carries the shared + * properties and a discriminated one keeps its mapping. Every other variant + * is typed as written: a `$ref` names a component the parser never merges + * properties into (it may be an enum or a union it could not augment); a + * primitive, an array or an enum is no object, so `properties` constrains + * nothing in it; a nested composition or a nullable union wrapper is left to + * its own variants. `schemaToTypeRef` reports the variants that may be + * objects and did not take the properties (`warnOnUncarriedProperties`). * * Applies only when the schema's `type` admits an object (`object`, a type * list naming it, or none), it declares at least one property of its own @@ -2291,14 +2293,19 @@ function componentAllOfMembers(schema: SchemaObject): readonly SchemaObject[] { /** * `variant` carrying `own`'s properties and required names when it is an * inline object (`isInlineObjectSchema`), itself otherwise. The schema's own - * properties come first and win where both declare a name; the required - * names are the union of both lists. + * properties come first and win where both declare a name, unless the + * variant's declaration is the narrower (`narrowsDeclaration`): JSON Schema + * applies both, so a variant's `kind: { const: 'cat' }` beside an own + * `kind: { type: 'string' }` is `'cat'`, and a discriminated union keeps its + * mapping. The required names are the union of both lists. */ function conjoinSiblingProperties(variant: SchemaObject, own: SchemaObject): SchemaObject { if (!isInlineObjectSchema(variant)) return variant; const properties: Record = { ...own.properties }; for (const [name, property] of Object.entries(variant.properties ?? {})) { - if (!Object.hasOwn(properties, name)) properties[name] = property; + if (!Object.hasOwn(properties, name) || narrowsDeclaration(property, properties[name])) { + properties[name] = property; + } } const required = [...new Set([...(own.required ?? []), ...(variant.required ?? [])])]; const folded: SchemaObject = { ...variant, properties, ...(required.length > 0 && { required }) }; @@ -2306,6 +2313,31 @@ function conjoinSiblingProperties(variant: SchemaObject, own: SchemaObject): Sch return folded; } +/** + * Whether a variant's declaration of a property is narrower than the + * schema's own (`conjoinSiblingProperties`): it is typed by the values it + * pins (`const` or `enum`, `classifyInlineSchema`), and the own declaration + * pins none, or pins every one of them and more. Any other pair — the same + * values, values the own does not all admit, a variant that pins none — is + * not, and the own declaration stays. + */ +function narrowsDeclaration(variant: SchemaObject | undefined, own: SchemaObject | undefined): boolean { + const narrow = pinnedValues(variant); + if (narrow === undefined) return false; + const wide = pinnedValues(own); + return ( + wide === undefined || + (narrow.every((value) => wide.includes(value)) && wide.some((value) => !narrow.includes(value))) + ); +} + +/** The values a declaration's `const` or `enum` pins it to, when that is what types it; `undefined` otherwise. */ +function pinnedValues(schema: SchemaObject | undefined): readonly unknown[] | undefined { + if (schema === undefined) return undefined; + const kind = classifyInlineSchema(schema); + return kind === 'const' ? [schema.const] : kind === 'enum' ? schema.enum : undefined; +} + /** The document's variant each folded copy was made from (`conjoinSiblingProperties`). */ const foldedVariantOrigins = new WeakMap(); diff --git a/test/parser/inline-array-items.test.ts b/test/parser/inline-array-items.test.ts index 1a3f9ad3..8f6716c9 100644 --- a/test/parser/inline-array-items.test.ts +++ b/test/parser/inline-array-items.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterAll, vi } from 'vitest'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; +import { diffSpecs } from '../../src/differ/diff.js'; import { parseSpec } from '../../src/parser/parse.js'; import { classifyAndExtractResponse } from '../../src/parser/responses.js'; import { @@ -674,6 +675,143 @@ describe('(2) `properties` beside a composition are carried into it, wherever th expect(warnings).toEqual([]); expectSound(spec, ['Drawing', 'Pet']); }); + + it("keeps a variant's `const` beside the schema's own plain declaration, so the union keeps its mapping (#392)", async () => { + // The issue, verbatim: an own `kind: string` beside variants pinning `kind` to `cat` and `dog`. + const doc = (dog: string) => + specDoc({ + '/pets/{id}': { + get: { + operationId: 'getPet', + parameters: [{ name: 'id', in: 'path', required: true, schema: str }], + responses: ok( + obj({ + name: str, + pet: obj( + { kind: str }, + { + discriminator: { propertyName: 'kind' }, + oneOf: [obj({ kind: { const: 'cat' }, meow: str }), obj({ kind: { const: dog }, bark: str })], + }, + ), + }), + ), + }, + }, + }); + const { spec, warnings } = await parse('kind', doc('dog')); + const pet = modelOf(spec, 'GetPetResponse').fields[1].type; + expect(pet.kind === 'union' && pet.discriminator).toEqual({ + property: 'kind', + mapping: { cat: 'GetPetResponsePet', dog: 'DogGetPetResponsePet' }, + }); + expect(spec.models.map(describeModel)).toEqual([ + 'GetPetResponse { name: string, pet: (GetPetResponsePet | DogGetPetResponsePet) }', + 'GetPetResponsePet { kind: "cat", meow: string }', + 'DogGetPetResponsePet { kind: "dog", bark: string }', + ]); + expect(warnings).toEqual([]); + expectSound(spec); + // So `oagen diff` sees the value change: the `dog` variant is removed and a `wolf` one added. + const { spec: wolf } = await parse('kind-wolf', doc('wolf')); + const report = diffSpecs(spec, wolf); + expect(report.changes.map((c) => [c.kind, 'name' in c ? c.name : undefined, c.classification])).toEqual([ + ['model-added', 'WolfGetPetResponsePet', 'additive'], + ['model-removed', 'DogGetPetResponsePet', 'breaking'], + ['model-modified', 'GetPetResponse', 'breaking'], + ]); + expect(report.summary.breaking).toBeGreaterThan(0); + }); + + it('keeps the narrower of two declarations of one property, a variant pinning its values, at every site (#392)', async () => { + const kinded = (own: SchemaObject, cat: SchemaObject): SchemaObject => + obj( + { kind: own }, + { + discriminator: { propertyName: 'kind' }, + oneOf: [obj({ kind: cat, meow: str }), obj({ kind: { const: 'dog' }, bark: str })], + }, + ); + /** Each site's union but the request body's, with its base name, taken out of its array at a list site. */ + const siteUnions = (spec: ApiSpec): Array<[string, TypeRef]> => { + const ops = opsOf(spec); + const refs: Array<[string, TypeRef]> = [ + [bases.response, ops.createThing.response], + [bases.item, ops.listThings.response], + [bases.page, ops.listPages.response], + [bases.param, ops.createThing.queryParams[0].type], + [bases.header, ops.createThing.responseHeaders![0].type], + [bases.field, modelOf(spec, 'Holder').fields[0].type], + ]; + return refs.map(([base, ref]) => [base, ref.kind === 'array' ? ref.items : ref]); + }; + // An own plain `kind`, or an own enum of every value, beside variants pinning one value each. + const owns: Record = { plain: str, enum: { type: 'string', enum: ['cat', 'dog'] } }; + for (const [label, own] of Object.entries(owns)) { + const { spec, warnings } = await parse(`narrower-${label}`, everySite(kinded(own, { const: 'cat' }))); + for (const [base, union] of siteUnions(spec)) { + expect(show(union), `${label} ${base}`).toBe(`(${base} | Dog${base})`); + expect(union.kind === 'union' && union.discriminator, `${label} ${base}`).toEqual({ + property: 'kind', + mapping: { cat: base, dog: `Dog${base}` }, + }); + expect(describeModel(modelOf(spec, base)), `${label} ${base}`).toBe(`${base} { kind: "cat", meow: string }`); + expect(describeModel(modelOf(spec, `Dog${base}`)), `${label} ${base}`).toBe( + `Dog${base} { kind: "dog", bark: string }`, + ); + } + // The request body, discriminated by the pinned values, is flattened into one object whose + // `kind` is the enum of those values, as a discriminated body is (see Request Body Encoding). + expect(show(opsOf(spec).createThing.requestBody!), label).toBe('CreateThingRequest'); + expect(describeModel(modelOf(spec, 'CreateThingRequest')), label).toBe( + 'CreateThingRequest { kind: CreateThingRequestKind, meow: string, bark: string }', + ); + expect( + spec.enums.map((e) => [e.name, e.values.map((v) => v.value)]), + label, + ).toEqual([['CreateThingRequestKind', ['cat', 'dog']]]); + expect(warnings, label).toEqual([]); + expectSound(spec, everySiteComponents); + } + // A variant's `enum` is kept the same way, as the variant's own enum rather than `string`. + const { spec, warnings } = await parse('narrower-variant-enum', everySite(kinded(str, { enum: ['cat', 'tiger'] }))); + for (const base of Object.values(bases)) { + expect(describeModel(modelOf(spec, base)), base).toBe(`${base} { kind: ${base}Kind, meow: string }`); + } + expect(Object.fromEntries(spec.enums.map((e) => [e.name, e.values.map((v) => v.value)]))).toEqual( + Object.fromEntries(Object.values(bases).map((base) => [`${base}Kind`, ['cat', 'tiger']])), + ); + expect(warnings).toEqual([]); + expectSound(spec, everySiteComponents); + }); + + it("keeps the schema's own declaration of a property a variant also declares unless the variant's is narrower (#392)", () => { + const kinds: SchemaObject = { type: 'string', enum: ['cat', 'dog'] }; + const cases: Array<[string, SchemaObject, SchemaObject, 'own' | 'variant']> = [ + ['a const beside a plain type', str, { const: 'cat' }, 'variant'], + ['an enum beside a plain type', str, { enum: ['cat', 'tiger'] }, 'variant'], + ['a const beside an enum naming it', kinds, { const: 'cat' }, 'variant'], + ['an enum of some of the own values', { enum: ['cat', 'dog', 'tiger'] }, kinds, 'variant'], + [ + 'a typed const beside a component', + { $ref: '#/components/schemas/Kind' }, + { type: 'string', const: 'cat' }, + 'variant', + ], + ['a plain type beside an enum', kinds, str, 'own'], + ['an enum beside a const', { const: 'cat' }, kinds, 'own'], + ['the same values', { type: 'string', enum: ['cat'] }, { const: 'cat' }, 'own'], + ['values the own enum does not all name', kinds, { enum: ['cat', 'tiger'] }, 'own'], + ['a const the own enum does not name', kinds, { const: 'wolf' }, 'own'], + ['limits only', { type: 'string', maxLength: 3 }, str, 'own'], + ]; + for (const keyword of ['oneOf', 'anyOf'] as const) { + for (const [label, own, variant, kept] of cases) { + const folded = foldSiblingProperties(obj({ kind: own }, { [keyword]: [obj({ kind: variant }), note] })); + expect(folded[keyword]?.[0].properties?.kind, `${keyword}: ${label}`).toBe(kept === 'own' ? own : variant); + } + } + }); }); describe('review round 1', () => { From afbb947b20ebc54a9e810c31f1a24d7def5a1015 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 12:11:49 -0400 Subject: [PATCH 48/59] fix(parser): keep the own declaration's flags and docs beside a variant's narrower const When a variant's `const` or `enum` was kept over the schema's own declaration of the same property, the own declaration was dropped whole: an own `kind: { type: 'string', readOnly: true, description: 'Pet kind' }` beside a variant's `kind: { const: 'cat' }` became `{ const: 'cat' }`, so the field lost `readOnly`, its description, its limits and its `x-*` keys, although JSON Schema still applies the own declaration. The kept declaration now carries the own declaration's field keywords (declarationKeywords, the ones an allOf conjoining two declarations carries) wherever the variant's sets none; the variant's own keys win where both set one, and no typing keyword of the own is copied. The IR, warnings included, of every fixture spec, the github-subset example and the WorkOS specs is still unchanged. --- docs/architecture/ir-types.md | 2 +- src/parser/inline-models.ts | 6 ++-- src/parser/schemas.ts | 19 ++++++++----- test/parser/inline-array-items.test.ts | 39 +++++++++++++++++++++++--- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index bd684d00..1bd2f137 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -171,7 +171,7 @@ Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. T Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context, so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and their children, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it, or an earlier variant of the union minted it; when the chosen context still mints a claimed name it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). -**`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the `required` naming the union of both lists. Where the variant declares the same property, the narrower declaration is kept, as JSON Schema applies both: the variant's when it pins the property's values with a `const` or an `enum` and the schema's own pins none, or pins those values and more — so `kind: { const: 'cat' }` beside an own `kind: { type: 'string' }` or `kind: { enum: [cat, dog] }` stays `'cat'`, the variants are named from their values (`HolderThing`, `DogHolderThing`) and a discriminated union keeps its mapping — and the schema's own otherwise, so an own `kind` enum beside a variant's plain `kind` stays the enum; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both, two of arrays combine their `items` by this same rule, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it; a union no discriminator dispatches is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), an inline object and a `$ref` to a component that declares properties (its own or its `allOf` chain's), or an `allOf` that reaches one, conjoin as the `allOf` of both, read as an inline `allOf` is (`result: { $ref: PetPage }` beside `result: { properties: { total_pages } }` is a `PageResult` with `PetPage`'s `items` and `total_pages`, whichever member comes first, `items` typed as `PetPage` types it, and the flags and documentation either declaration gives the field stay the field's), while a `$ref` to an array, enum, primitive or union component is no object to conjoin and the first declaration stays; two typing arrays combine their `items` by this same rule (`items: { description }` only annotates `items: { $ref: Pet }`, so `data` stays `Pet[]`) — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). +**`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the `required` naming the union of both lists. Where the variant declares the same property, the narrower declaration is kept, as JSON Schema applies both: the variant's when it pins the property's values with a `const` or an `enum` and the schema's own pins none, or pins those values and more — so `kind: { const: 'cat' }` beside an own `kind: { type: 'string' }` or `kind: { enum: [cat, dog] }` stays `'cat'`, the variants are named from their values (`HolderThing`, `DogHolderThing`) and a discriminated union keeps its mapping, while the own declaration's flags, documentation, limits and extensions (`readOnly`, `description`, `x-*`) stay the field's wherever the variant's sets none — and the schema's own otherwise, so an own `kind` enum beside a variant's plain `kind` stays the enum; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both, two of arrays combine their `items` by this same rule, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it; a union no discriminator dispatches is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), an inline object and a `$ref` to a component that declares properties (its own or its `allOf` chain's), or an `allOf` that reaches one, conjoin as the `allOf` of both, read as an inline `allOf` is (`result: { $ref: PetPage }` beside `result: { properties: { total_pages } }` is a `PageResult` with `PetPage`'s `items` and `total_pages`, whichever member comes first, `items` typed as `PetPage` types it, and the flags and documentation either declaration gives the field stay the field's), while a `$ref` to an array, enum, primitive or union component is no object to conjoin and the first declaration stays; two typing arrays combine their `items` by this same rule (`items: { description }` only annotates `items: { $ref: Pet }`, so `data` stays `Pet[]`) — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). ### UnionType diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index fb429dea..c97b57e9 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -2117,9 +2117,11 @@ function composesObject(schema: SchemaObject): boolean { * What a declaration says of its property beside the type: the keywords a * field reads from its own schema (`LIFTED_DECLARATION_KEYWORDS`) and its * extensions, which an `allOf` conjoining it with another declaration - * carries for the field it types (`combineDeclarations`). + * carries for the field it types (`combineDeclarations`), as a variant's + * narrower declaration carries a schema's own beside it + * (`conjoinSiblingProperties`). */ -function declarationKeywords(schema: SchemaObject): SchemaObject { +export function declarationKeywords(schema: SchemaObject): SchemaObject { const keywords = Object.entries(schema).filter(([key]) => LIFTED_DECLARATION_KEYWORDS.includes(key)); return { ...Object.fromEntries(keywords), ...extensionsOf(schema, SCHEMA_CONSUMED_EXTENSIONS) }; } diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index afeb02a4..2de45ba7 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -2296,15 +2296,20 @@ function componentAllOfMembers(schema: SchemaObject): readonly SchemaObject[] { * properties come first and win where both declare a name, unless the * variant's declaration is the narrower (`narrowsDeclaration`): JSON Schema * applies both, so a variant's `kind: { const: 'cat' }` beside an own - * `kind: { type: 'string' }` is `'cat'`, and a discriminated union keeps its - * mapping. The required names are the union of both lists. + * `kind: { type: 'string', readOnly: true }` is a read-only `'cat'`, and a + * discriminated union keeps its mapping. The own declaration's flags, + * documentation, limits and extensions (`declarationKeywords`) stay the + * field's wherever the variant's sets none. The required names are the + * union of both lists. */ function conjoinSiblingProperties(variant: SchemaObject, own: SchemaObject): SchemaObject { if (!isInlineObjectSchema(variant)) return variant; const properties: Record = { ...own.properties }; for (const [name, property] of Object.entries(variant.properties ?? {})) { - if (!Object.hasOwn(properties, name) || narrowsDeclaration(property, properties[name])) { - properties[name] = property; + const held = properties[name]; + if (!Object.hasOwn(properties, name)) properties[name] = property; + else if (held && property && narrowsDeclaration(property, held)) { + properties[name] = { ...declarationKeywords(held), ...property }; } } const required = [...new Set([...(own.required ?? []), ...(variant.required ?? [])])]; @@ -2321,7 +2326,7 @@ function conjoinSiblingProperties(variant: SchemaObject, own: SchemaObject): Sch * values, values the own does not all admit, a variant that pins none — is * not, and the own declaration stays. */ -function narrowsDeclaration(variant: SchemaObject | undefined, own: SchemaObject | undefined): boolean { +function narrowsDeclaration(variant: SchemaObject, own: SchemaObject): boolean { const narrow = pinnedValues(variant); if (narrow === undefined) return false; const wide = pinnedValues(own); @@ -2332,8 +2337,7 @@ function narrowsDeclaration(variant: SchemaObject | undefined, own: SchemaObject } /** The values a declaration's `const` or `enum` pins it to, when that is what types it; `undefined` otherwise. */ -function pinnedValues(schema: SchemaObject | undefined): readonly unknown[] | undefined { - if (schema === undefined) return undefined; +function pinnedValues(schema: SchemaObject): readonly unknown[] | undefined { const kind = classifyInlineSchema(schema); return kind === 'const' ? [schema.const] : kind === 'enum' ? schema.enum : undefined; } @@ -4923,6 +4927,7 @@ export function typesAsUnknown(schema: SchemaObject): boolean { import { buildInlineModel, collectProperties, + declarationKeywords, declareProperty, declaresInlineProperties, elementContextName, diff --git a/test/parser/inline-array-items.test.ts b/test/parser/inline-array-items.test.ts index 8f6716c9..7287a876 100644 --- a/test/parser/inline-array-items.test.ts +++ b/test/parser/inline-array-items.test.ts @@ -745,8 +745,13 @@ describe('(2) `properties` beside a composition are carried into it, wherever th ]; return refs.map(([base, ref]) => [base, ref.kind === 'array' ? ref.items : ref]); }; - // An own plain `kind`, or an own enum of every value, beside variants pinning one value each. - const owns: Record = { plain: str, enum: { type: 'string', enum: ['cat', 'dog'] } }; + // An own plain `kind`, an own enum of every value, or an own `kind` with flags and documentation, + // beside variants pinning one value each. + const owns: Record = { + plain: str, + enum: { type: 'string', enum: ['cat', 'dog'] }, + documented: { type: 'string', readOnly: true, description: 'Pet kind' }, + }; for (const [label, own] of Object.entries(owns)) { const { spec, warnings } = await parse(`narrower-${label}`, everySite(kinded(own, { const: 'cat' }))); for (const [base, union] of siteUnions(spec)) { @@ -759,6 +764,13 @@ describe('(2) `properties` beside a composition are carried into it, wherever th expect(describeModel(modelOf(spec, `Dog${base}`)), `${label} ${base}`).toBe( `Dog${base} { kind: "dog", bark: string }`, ); + // The own declaration's flags and documentation stay the field's. + for (const name of [base, `Dog${base}`]) { + const { readOnly, description } = modelOf(spec, name).fields[0]; + expect({ readOnly, description }, `${label} ${name}`).toEqual( + label === 'documented' ? { readOnly: true, description: 'Pet kind' } : {}, + ); + } } // The request body, discriminated by the pinned values, is flattened into one object whose // `kind` is the enum of those values, as a discriminated body is (see Request Body Encoding). @@ -805,11 +817,30 @@ describe('(2) `properties` beside a composition are carried into it, wherever th ['a const the own enum does not name', kinds, { const: 'wolf' }, 'own'], ['limits only', { type: 'string', maxLength: 3 }, str, 'own'], ]; + const kindOf = (keyword: 'oneOf' | 'anyOf', own: SchemaObject, variant: SchemaObject) => + foldSiblingProperties(obj({ kind: own }, { [keyword]: [obj({ kind: variant }), note] }))[keyword]?.[0].properties + ?.kind; + // The own declaration still applies where the variant's wins: its flags, documentation, limits + // and extensions stay the field's wherever the variant's sets none. + const documented: SchemaObject = { + type: 'string', + readOnly: true, + description: 'Pet kind', + maxLength: 8, + 'x-since': '2', + }; + const carried = { readOnly: true, description: 'Pet kind', maxLength: 8, 'x-since': '2' }; for (const keyword of ['oneOf', 'anyOf'] as const) { for (const [label, own, variant, kept] of cases) { - const folded = foldSiblingProperties(obj({ kind: own }, { [keyword]: [obj({ kind: variant }), note] })); - expect(folded[keyword]?.[0].properties?.kind, `${keyword}: ${label}`).toBe(kept === 'own' ? own : variant); + expect(kindOf(keyword, own, variant), `${keyword}: ${label}`).toEqual(kept === 'own' ? own : variant); } + expect(kindOf(keyword, documented, { const: 'cat' }), keyword).toEqual({ ...carried, const: 'cat' }); + expect(kindOf(keyword, documented, { const: 'cat', description: 'A cat' }), keyword).toEqual({ + ...carried, + const: 'cat', + description: 'A cat', + }); + expect(kindOf(keyword, documented, str), keyword).toBe(documented); } }); }); From 7486e4f46dc62920babd4daf99f0233fe10a2c60 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 12:24:45 -0400 Subject: [PATCH 49/59] fix(parser)!: build a component field's array items from the oneOf or allOf beside their properties extractNestedSchema, the extractSchemas backstop for a component field's nested models, built the model of an array's items from `items.properties` whatever composition sat beside them. For items writing `properties` beside a `oneOf`/`anyOf` or `allOf`, that left a model of the items' own properties alone (`HolderList { id }`) under the name the items' real models own. The field pass, which builds those models from the folded items, then found the name taken, called it a component schema and renamed them (`HolderList2 { id, name }`), leaving the `{ id }` models unreferenced in `spec.models` beside a rename note for a component the document does not have. The array branch now builds the items' model only when the items are the object schemaToTypeRef typed them as (classifyInlineSchema), as it already did for items that are a bare union. A composition among the items is left to the field pass, which names its models as it names the same items under a map. The issue's items.json gives `HolderList { id, name }` and `HolderListWithText { id, text }`, items-allof.json `HolderList { id, name }`, with no other model and no note, however the array or the composition is spelled. The IR, warnings included, of every fixture spec, the github-subset example and the WorkOS specs is unchanged. Closes #393 --- src/parser/schemas.ts | 8 +- test/parser/inline-array-items.test.ts | 130 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index 2de45ba7..8485e2f7 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -1538,8 +1538,12 @@ function extractNestedSchema( // Handle array with inline items — typed, nullable, or `items` with no // `type`: `isArraySchema` is `classifyInlineSchema`'s answer once the // nullable type list is unwrapped, the same test `schemaToTypeRef` applied - // when it named the ref. - if (isArraySchema(schema) && schema.items?.properties) { + // when it named the ref. Only items it typed as an object are built here: + // `properties` beside a `oneOf`/`anyOf` or `allOf` that types the items are + // carried into that composition, whose models the field pass builds + // (`extractComponentFieldModels`) — a model of the properties alone would + // take their name. + if (isArraySchema(schema) && schema.items?.properties && classifyInlineSchema(schema.items) === 'object') { const requiredSet = new Set(schema.items.required ?? []); const siblings = collectProperties(schema.items); const fields: Field[] = []; diff --git a/test/parser/inline-array-items.test.ts b/test/parser/inline-array-items.test.ts index 7287a876..466bf43b 100644 --- a/test/parser/inline-array-items.test.ts +++ b/test/parser/inline-array-items.test.ts @@ -843,6 +843,136 @@ describe('(2) `properties` beside a composition are carried into it, wherever th expect(kindOf(keyword, documented, str), keyword).toBe(documented); } }); + + /** `Holder.list` typed by `list`, `Holder` returned by `getHolder`, beside `schemas`. */ + const holderDoc = (list: SchemaObject, schemas: Record = {}) => + specDoc( + { + '/holders/{id}': { + get: { + operationId: 'getHolder', + parameters: [{ name: 'id', in: 'path', required: true, schema: str }], + responses: ok({ $ref: '#/components/schemas/Holder' }), + }, + }, + }, + { Holder: obj({ list }), ...schemas }, + ); + + it("builds a component field's array items once, the properties beside their `oneOf` or `allOf` carried (#393)", async () => { + // The issue, verbatim (`items.json`, `items-allof.json`), and the same items as map values, which match. + const cases: Array<[string, SchemaObject, Record, string[]]> = [ + [ + 'items', + { type: 'array', items: tagged }, + {}, + [ + 'Holder { list: (HolderList | HolderListWithText)[] }', + 'HolderList { id: string, name: string }', + 'HolderListWithText { id: string, text: string }', + ], + ], + [ + 'items-allof', + { type: 'array', items: extended }, + { Pet: pet }, + ['Holder { list: HolderList[] }', 'Pet { name: string }', 'HolderList { id: string, name: string }'], + ], + [ + 'values', + { type: 'object', additionalProperties: tagged }, + {}, + [ + 'Holder { list: map<(HolderList | HolderListWithText)> }', + 'HolderList { id: string, name: string }', + 'HolderListWithText { id: string, text: string }', + ], + ], + ]; + for (const [label, list, schemas, models] of cases) { + const { spec, warnings } = await parse(label, holderDoc(list, schemas)); + expect(spec.models.map(describeModel), label).toEqual(models); + expect(warnings, label).toEqual([]); + expectSound(spec, ['Holder', ...Object.keys(schemas)]); + } + }); + + it('does so for every spelling of the array and of the composition beside the items (#393)', async () => { + const kinded = obj( + { kind: str }, + { + discriminator: { propertyName: 'kind' }, + oneOf: [obj({ kind: { const: 'cat' }, meow: str }), obj({ kind: { const: 'dog' }, bark: str })], + }, + ); + const titled = obj( + { id: str }, + { + oneOf: [ + obj({ kind: { const: 'circle' }, radius: str }, { title: 'Circle' }), + obj({ kind: { const: 'square' }, side: str }, { title: 'Square' }), + ], + }, + ); + const cases: Array<[string, SchemaObject, string[]]> = [ + [ + 'an array with no `type`', + { items: tagged }, + [ + 'Holder { list: (HolderList | HolderListWithText)[] }', + 'HolderList { id: string, name: string }', + 'HolderListWithText { id: string, text: string }', + ], + ], + [ + 'a nullable array', + { type: ['array', 'null'], items: tagged }, + [ + 'Holder { list: (HolderList | HolderListWithText)[] | null }', + 'HolderList { id: string, name: string }', + 'HolderListWithText { id: string, text: string }', + ], + ], + [ + 'an anyOf', + { type: 'array', items: obj({ id: str }, { anyOf: [pet, note] }) }, + [ + 'Holder { list: (HolderList | HolderListWithText)[] }', + 'HolderList { id: string, name: string }', + 'HolderListWithText { id: string, text: string }', + ], + ], + [ + 'an allOf of inline members', + { type: 'array', items: obj({ id: str }, { allOf: [pet, note] }) }, + ['Holder { list: HolderList[] }', 'HolderList { id: string, name: string, text: string }'], + ], + [ + 'variants pinning a discriminator', + { type: 'array', items: kinded }, + [ + 'Holder { list: (HolderList | DogHolderList)[] }', + 'HolderList { kind: "cat", meow: string }', + 'DogHolderList { kind: "dog", bark: string }', + ], + ], + [ + 'titled variants', + { type: 'array', items: titled }, + [ + 'Holder { list: (Circle | Square)[] }', + 'Circle { id: string, kind: "circle", radius: string }', + 'Square { id: string, kind: "square", side: string }', + ], + ], + ]; + for (const [label, list, models] of cases) { + const { spec, warnings } = await parse('spelling', holderDoc(list)); + expect(spec.models.map(describeModel), label).toEqual(models); + expect(warnings, label).toEqual([]); + expectSound(spec, ['Holder']); + } + }); }); describe('review round 1', () => { From 922845bcaf4097a5eac608e65e69a5e158e18978 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 12:41:00 -0400 Subject: [PATCH 50/59] fix(parser)!: map a wrapped named-union variant by the one model it wraps A named union derives its discriminator mapping from each variant's const paired with the variant's model name, and gives up for the whole union as soon as one variant has no name. A variant that wraps a reference, `anyOf: [{ $ref: Bird }, { type: 'null' }]` (typed `nullable`) or `oneOf: [{ $ref: Bird }, { type: 'string' }]` (`union`), had none. Adding one emptied the mapping under the `discriminator` keyword and dropped the implicit discriminator without it, so `oagen diff` reported a breaking `discriminator-changed` where a bare `$ref` variant is an additive `variant-added`. namedUnionMembers now reads such a variant as the one reference it wraps (wrappedVariantReference), through the wrappers the differ reads a variant's models through: a nullable wrapper, an `allOf` read as its reference, and a nested `oneOf`/`anyOf` whose other members name no model. A wrapper around two references, or around one beside a model the extractor names (an inline object, a merged `allOf`), stays unnamed, since a key read off one would leave the other out. The issue's new.json gives `{ cat: 'Cat', dog: 'Dog', bird: 'Bird' }` and an additive `variant-added nullable mapping added for 'bird'` (exit 1), with or without the keyword. The IR, warnings included, of every fixture spec, the github-subset example and the WorkOS specs is unchanged. Closes #427 --- docs/architecture/ir-types.md | 2 +- src/parser/schemas.ts | 59 ++++++++++++++++++++++++---- test/differ/named-unions.test.ts | 52 +++++++++++++++++++++++++ test/parser/named-unions.test.ts | 67 ++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 9 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 1bd2f137..06cfbe44 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -727,7 +727,7 @@ The variants are built exactly as the same union written inline is (`UnionType`) Inline object variants are named by the inline-variant rules with the union's model name as the base, except that the base itself is taken by the union's own model, so variant 0 steps off it like every later variant: a const-discriminator prefix (`CatPet` for `kind: cat`), its title as a prefix (`CircleShape`), the first property no other variant declares (`UpdateAuditLogsRetentionDtoWithRetentionPeriod` beside `…WithRetentionPeriodInDays`), else the position counter (`Pet1`, `Pet2`). Every component schema's name is reserved, so a variant can never shadow a component (a variant titled `Cat` beside a component `Cat` takes `CatPet`), and so is every context-free title another named union keeps for a variant of a different shape — the comparison sibling field unions already make (`schemaShapeKey`), read symmetrically: when `Circle` is `{ radius }` on `Shape` and `{ diameter }` on `Marker`, both fall back (`CircleShape`, `CircleMarker`) and the mappings follow; when the shapes are identical, both keep `Circle` and one model is emitted. The component schemas are one root for the depth rule above as well, so a title a field union nested in any component gives a different shape (`Drawing.marker.shape`'s `Circle` as `{ diameter }`) is contested for the named union too, and both fall back (`CircleShape`, `CircleDrawingMarkerShape`). Each variant and everything nested under it — objects with or without `type`, array items, map values, `allOf`s, nested unions, the members of a multi-type variant — is materialized by the same inline extractor a field's union variants go through, so a reference always has a model behind it. Each extracted variant model is a complete model of its own, so the union's merged fields and the variant's fields are typed independently (an inline `config` object appears as both `PetConfig` and `CatPetConfig`). -`discriminator` is resolved from the same sources, in the same order, as an inline union's, with a `$ref` variant's const read from the component it points at: an explicit `discriminator:` with a `mapping:` is the spec's own, each target reduced to the IR model name it refers to (the last `$ref` segment through `schemaNameTransform`, so `#/components/schemas/Cat` → `Cat`) — an entry pointing outside the variant list is kept as written, and an entry naming an inline variant's const redirects that variant's object member to its target, as it does inline, while the items or values of a multi-type variant's other members stay named from the model extracted for the variant (`PetWithKindItem`, never `CatItem`), so they always resolve; an explicit `discriminator:` without `mapping:` pairs each variant's const on the property with its model name (`{ cat: "Cat", dog: "Dog" }`), and is `{}` when any variant pins none, as it is inline; a `oneOf` with no keyword whose every variant is nameable (a `$ref`, a titled or inline object) is discriminated implicitly on the first property the first variant pins whose values are distinct across all of them — a constant every variant shares (`version: 'v1'`) is passed over, not taken as proof there is none. An `anyOf` is never discriminated implicitly, and the OpenAPI default of "the discriminator value is the schema name" is not synthesized. Mappings are built as own keys, so a value spelled like an inherited property (`constructor`) is neither rejected nor lost. As for an allOf+oneOf model, a set `discriminator` means emitters should dispatch on it, and its mapping values are IR model names. That is the one place the two forms differ. Both reduce `#/components/schemas/Cat` to `Cat`, but `Model.discriminator.mapping` holds IR model names — each target's last `$ref` segment through `resolveSchemaName`, so `cleanSchemaName` and the project's `schemaNameTransform` apply (`#/components/schemas/cat_dto` → `CatDto`, or whatever the transform makes of it), as an allOf+oneOf model's always has — while an inline union's `discriminator.mapping` only strips a leading `#/components/schemas/` and otherwise keeps each target as the document spelled it (`cat_dto` stays `cat_dto`; a target in another section, such as `#/components/responses/Cat`, stays whole). An emitter reading a named union's mapping can use its values as model names directly; one reading an inline union's mapping has to clean and transform them itself. +`discriminator` is resolved from the same sources, in the same order, as an inline union's, with a `$ref` variant's const read from the component it points at: an explicit `discriminator:` with a `mapping:` is the spec's own, each target reduced to the IR model name it refers to (the last `$ref` segment through `schemaNameTransform`, so `#/components/schemas/Cat` → `Cat`) — an entry pointing outside the variant list is kept as written, and an entry naming an inline variant's const redirects that variant's object member to its target, as it does inline, while the items or values of a multi-type variant's other members stay named from the model extracted for the variant (`PetWithKindItem`, never `CatItem`), so they always resolve; an explicit `discriminator:` without `mapping:` pairs each variant's const on the property with its model name (`{ cat: "Cat", dog: "Dog" }`), and is `{}` when any variant pins none, as it is inline; a `oneOf` with no keyword whose every variant is nameable (a `$ref`, a titled or inline object) is discriminated implicitly on the first property the first variant pins whose values are distinct across all of them — a constant every variant shares (`version: 'v1'`) is passed over, not taken as proof there is none. Both derivations read a variant that wraps one `$ref` as that reference, its const and model name alike, as the differ reads the models under a variant's wrapper: a nullable wrapper (`anyOf: [{ $ref: Bird }, { type: 'null' }]`, typed `nullable`), a `oneOf`/`anyOf` whose other members name no model (`oneOf: [{ $ref: Bird }, { type: 'string' }]`, typed `union`), or an `allOf` read as its reference — so adding such a variant adds its key, `{ bird: "Bird" }`, beside the others. A wrapper around two references, or around one beside an inline object, names no model, since a key read off one would leave the other out. An `anyOf` is never discriminated implicitly, and the OpenAPI default of "the discriminator value is the schema name" is not synthesized. Mappings are built as own keys, so a value spelled like an inherited property (`constructor`) is neither rejected nor lost. As for an allOf+oneOf model, a set `discriminator` means emitters should dispatch on it, and its mapping values are IR model names. That is the one place the two forms differ. Both reduce `#/components/schemas/Cat` to `Cat`, but `Model.discriminator.mapping` holds IR model names — each target's last `$ref` segment through `resolveSchemaName`, so `cleanSchemaName` and the project's `schemaNameTransform` apply (`#/components/schemas/cat_dto` → `CatDto`, or whatever the transform makes of it), as an allOf+oneOf model's always has — while an inline union's `discriminator.mapping` only strips a leading `#/components/schemas/` and otherwise keeps each target as the document spelled it (`cat_dto` stays `cat_dto`; a target in another section, such as `#/components/responses/Cat`, stays whole). An emitter reading a named union's mapping can use its values as model names directly; one reading an inline union's mapping has to clean and transform them itself. Three shapes are deliberately not unions. A `oneOf`/`anyOf` whose every non-null variant is a string `const` is an enum (see Enum). A `oneOf` written beside `properties` keeps today's object model (the properties are its fields; the branches are not read), and a component that is only a nullable wrapper around one plain inline object — `oneOf: [{ type: object, … }, { type: 'null' }]`, the object typed `object`, untyped, or `['object', 'null']` — stays the object its merged fields describe, as an inline wrapper collapses to `nullable` rather than a union. A wrapper around one `$ref`, one primitive, or one object that also admits another type (`type: ['object', 'string']`) is a union of one, so the other type is kept. diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index 8485e2f7..6ce642ea 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -2877,13 +2877,19 @@ function extractModel(name: string, schema: SchemaObject, schemas?: Record`, + * `union`). `null` for a variant that is none of those (a + * primitive, an inline enum, an array), which a derived discriminator + * cannot map. */ name: string | null; } @@ -3041,7 +3047,9 @@ function namedUnionEntry( * The non-null variants of a named union, each with the schema its const * values and properties are read from (a `$ref` resolved against the * components, so a discriminator pinned inside the referenced schema is - * seen) and the model name it is referenced by. + * seen) and the model name it is referenced by. A variant that wraps one + * reference (`wrappedVariantReference`) is read as that reference, so its + * const reaches a derived mapping as a bare `$ref` variant's does (#427). */ function namedUnionMembers( schema: SchemaObject, @@ -3051,17 +3059,52 @@ function namedUnionMembers( const members: NamedUnionMember[] = []; for (const raw of schema.oneOf ?? schema.anyOf ?? []) { if (isNullSchema(raw)) continue; + const named = raw.$ref || inlineNames.has(raw) ? raw : (wrappedVariantReference(raw) ?? raw); let resolved = raw; - if (raw.$ref) { - const refName = raw.$ref.split('/').pop() ?? ''; + if (named.$ref) { + const refName = named.$ref.split('/').pop() ?? ''; const target = schemas && Object.hasOwn(schemas, refName) ? schemas[refName] : undefined; if (target) resolved = target; } - members.push({ raw, resolved, name: inlineNames.get(raw) ?? resolveVariantModelName(raw) }); + members.push({ raw, resolved, name: inlineNames.get(raw) ?? resolveVariantModelName(named) }); } return members; } +/** + * The one `$ref` a named union's variant wraps, read through the wrappers the + * differ reads a variant's models through (`variantModelNames`): a nullable + * wrapper (`unwrapNullableSchema`: `anyOf: [{ $ref: Bird }, { type: 'null' }]`), + * an `allOf` read as its reference (`allOfReadAsMember`), and a nested + * `oneOf`/`anyOf`, whose primitive, enum, array or map members name no model + * (`oneOf: [{ $ref: Bird }, { type: 'string' }]`). `undefined` when it wraps + * no reference, two, or one beside a model the extractor names (an inline + * object, a merged `allOf`): a mapping key read off that one reference would + * leave the other model out. + */ +function wrappedVariantReference(variant: SchemaObject): SchemaObject | undefined { + const refs: SchemaObject[] = []; + const visit = (schema: SchemaObject): boolean => { + const bare = unwrapNullableSchema(schema); + switch (classifyInlineSchema(bare)) { + case 'ref': + refs.push(bare); + return true; + case 'allOf': { + const member = allOfReadAsMember(bare); + return member !== undefined && visit(member); + } + case 'union': + return (bare.oneOf ?? bare.anyOf ?? []).every((member) => isNullSchema(member) || visit(member)); + case 'object': + return false; + default: + return true; + } + }; + return visit(variant) && refs.length === 1 ? refs[0] : undefined; +} + /** * Name the inline object variants of a named union component the way a * field's union names its own (`nameInlineVariants`), with the union's model diff --git a/test/differ/named-unions.test.ts b/test/differ/named-unions.test.ts index 37f2bf77..bb373a88 100644 --- a/test/differ/named-unions.test.ts +++ b/test/differ/named-unions.test.ts @@ -777,4 +777,56 @@ describe('diffSpecs — a variant added to a discriminated union, end to end (is variantChanges: ["variant-removed mapping removed for 'pet.updated': breaking"], }); }); + + /** `petsDocument(['Cat', 'Dog'])` with `extra` appended to `Pet.oneOf`, discriminated by the keyword or implicitly. */ + const derivedDocument = (extra: object[], keyword: boolean) => { + const document = petsDocument(['Cat', 'Dog']); + const { oneOf, discriminator } = document.components.schemas.Pet; + const pet = { oneOf: [...oneOf, ...extra], ...(keyword ? { discriminator } : {}) }; + return { ...document, components: { schemas: { ...document.components.schemas, Pet: pet } } }; + }; + + it.each([ + ['keyword', 'nullable', true, { anyOf: [{ $ref: ref('Bird') }, { type: 'null' }] }], + ['keyword', 'union', true, { oneOf: [{ $ref: ref('Bird') }, { type: 'string' }] }], + ['implicit', 'nullable', false, { anyOf: [{ $ref: ref('Bird') }, { type: 'null' }] }], + ['implicit', 'union', false, { oneOf: [{ $ref: ref('Bird') }, { type: 'string' }] }], + ] as const)( + "%s, %s: a wrapped variant's derived mapping key comes with it, additive, and goes with it, breaking (#427)", + async (pair, label, keyword, wrapped) => { + const oldSpec = await parse(`427-${pair}-${label}-old`, derivedDocument([], keyword)); + const newSpec = await parse(`427-${pair}-${label}-new`, derivedDocument([wrapped], keyword)); + expect(newSpec.models.find((m) => m.name === 'Pet')?.discriminator).toEqual({ + property: 'kind', + mapping: { cat: 'Cat', dog: 'Dog', bird: 'Bird' }, + }); + const added = diffSpecs(oldSpec, newSpec); + expect(added.changes).toMatchObject([ + { + kind: 'model-modified', + name: 'Pet', + classification: 'additive', + variantChanges: [ + { kind: 'variant-added', variant: label, classification: 'additive', details: "mapping added for 'bird'" }, + ], + }, + ]); + expect(added.summary).toMatchObject({ breaking: 0, additive: 1 }); + expect(diffSpecs(newSpec, oldSpec).changes).toMatchObject([ + { + kind: 'model-modified', + name: 'Pet', + classification: 'breaking', + variantChanges: [ + { + kind: 'variant-removed', + variant: label, + classification: 'breaking', + details: "mapping removed for 'bird'", + }, + ], + }, + ]); + }, + ); }); diff --git a/test/parser/named-unions.test.ts b/test/parser/named-unions.test.ts index c8bb183d..d796618f 100644 --- a/test/parser/named-unions.test.ts +++ b/test/parser/named-unions.test.ts @@ -951,6 +951,73 @@ components: expect(model(ir, 'Pet').discriminator).toEqual({ property: 'kind', mapping: { cat: 'Cat', dog: 'Dog' } }); }); + + /** `Pet` over `Cat`, `Dog` and `third`, discriminated on `kind` by the keyword or implicitly; `Bird` and `Fish` pin `kind` too. */ + const wrappedPets = (third: string, keyword: boolean) => + parseInline(`${header} +components: + schemas: + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + - ${third} +${keyword ? ' discriminator:\n propertyName: kind\n' : ''} Bird: + type: object + properties: + kind: { const: bird } + required: [kind] + Fish: + type: object + properties: + kind: { const: fish } + required: [kind] +${animals}`); + + it.each([ + [ + 'a nullable wrapper', + "{ anyOf: [{ $ref: '#/components/schemas/Bird' }, { type: 'null' }] }", + { kind: 'nullable', inner: modelRef('Bird') }, + ], + [ + 'a multi-type wrapper', + "{ oneOf: [{ $ref: '#/components/schemas/Bird' }, { type: string }] }", + { kind: 'union', variants: [modelRef('Bird'), { kind: 'primitive', type: 'string' }], compositionKind: 'oneOf' }, + ], + [ + 'an allOf read as its reference', + "{ allOf: [{ $ref: '#/components/schemas/Bird' }], description: A bird }", + modelRef('Bird'), + ], + ] as const)( + 'reads the const of a variant under %s from the one model it wraps, keyword or not (#427)', + async (_, third, variant) => { + for (const keyword of [true, false]) { + const pet = model(await wrappedPets(third, keyword), 'Pet'); + expect(pet.variants, `keyword: ${keyword}`).toEqual([modelRef('Cat'), modelRef('Dog'), variant]); + expect(pet.discriminator, `keyword: ${keyword}`).toEqual({ + property: 'kind', + mapping: { cat: 'Cat', dog: 'Dog', bird: 'Bird' }, + }); + } + }, + ); + + it.each([ + ['two references', "{ oneOf: [{ $ref: '#/components/schemas/Bird' }, { $ref: '#/components/schemas/Fish' }] }"], + [ + 'a reference beside an inline object', + "{ oneOf: [{ $ref: '#/components/schemas/Bird' }, { type: object, properties: { kind: { const: fish } } }] }", + ], + [ + 'a reference merged into a model of its own', + "{ allOf: [{ $ref: '#/components/schemas/Bird' }, { type: object, properties: { wings: { type: integer } } }] }", + ], + ])('names no variant whose wrapper holds %s, so nothing is mapped (#427)', async (_, third) => { + expect(model(await wrappedPets(third, true), 'Pet').discriminator).toEqual({ property: 'kind', mapping: {} }); + expect(model(await wrappedPets(third, false), 'Pet').discriminator).toBeUndefined(); + }); }); describe('named unions: renames and folds', () => { From d330ce5ec78a6397c533157b82ae5759c6778494 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 13:46:53 -0400 Subject: [PATCH 51/59] fix(parser)!: keep a discriminated variant off its component's name `deriveDiscriminatedVariantName` returned the component's own name when that name started with, or was, a variant's const, so `CatEvent`'s `cat` variant was built as `CatEvent` and took the dispatcher's place: the IR kept one variant under the component's name, without the discriminator, and the other variant was a model nothing referenced. Renaming the component to `PetEvent` gave the dispatcher with `PetEventCat` and `PetEventDog`. A one-word const now always follows the component's name (`CatEventCat`), and a const that spells the component's whole name, one word or several, does too (`Cat`'s `cat` is `CatCat`). A variant whose const gives no name (`''`, `'-'`) still falls back to the component's name, so the dispatcher detection now treats the component's name as taken: such a union is flattened, as one whose two variants share a name already is. BREAKING CHANGE: a discriminated allOf + oneOf/anyOf component whose name starts with or equals a variant's const now has a dispatcher model and variant models named ``; the variant models it had under the component's own name (and the orphaned siblings) change names, and `oagen diff` reports the models added and removed. Closes #430 --- docs/architecture/ir-types.md | 2 +- src/parser/schemas.ts | 13 ++- test/parser/allof-sibling-properties.test.ts | 101 ++++++++++++++++++- 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 06cfbe44..6193bda4 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -171,7 +171,7 @@ Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. T Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context, so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and their children, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it, or an earlier variant of the union minted it; when the chosen context still mints a claimed name it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). -**`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the `required` naming the union of both lists. Where the variant declares the same property, the narrower declaration is kept, as JSON Schema applies both: the variant's when it pins the property's values with a `const` or an `enum` and the schema's own pins none, or pins those values and more — so `kind: { const: 'cat' }` beside an own `kind: { type: 'string' }` or `kind: { enum: [cat, dog] }` stays `'cat'`, the variants are named from their values (`HolderThing`, `DogHolderThing`) and a discriminated union keeps its mapping, while the own declaration's flags, documentation, limits and extensions (`readOnly`, `description`, `x-*`) stay the field's wherever the variant's sets none — and the schema's own otherwise, so an own `kind` enum beside a variant's plain `kind` stays the enum; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both, two of arrays combine their `items` by this same rule, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it; a union no discriminator dispatches is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), an inline object and a `$ref` to a component that declares properties (its own or its `allOf` chain's), or an `allOf` that reaches one, conjoin as the `allOf` of both, read as an inline `allOf` is (`result: { $ref: PetPage }` beside `result: { properties: { total_pages } }` is a `PageResult` with `PetPage`'s `items` and `total_pages`, whichever member comes first, `items` typed as `PetPage` types it, and the flags and documentation either declaration gives the field stay the field's), while a `$ref` to an array, enum, primitive or union component is no object to conjoin and the first declaration stays; two typing arrays combine their `items` by this same rule (`items: { description }` only annotates `items: { $ref: Pet }`, so `data` stays `Pet[]`) — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). +**`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the `required` naming the union of both lists. Where the variant declares the same property, the narrower declaration is kept, as JSON Schema applies both: the variant's when it pins the property's values with a `const` or an `enum` and the schema's own pins none, or pins those values and more — so `kind: { const: 'cat' }` beside an own `kind: { type: 'string' }` or `kind: { enum: [cat, dog] }` stays `'cat'`, the variants are named from their values (`HolderThing`, `DogHolderThing`) and a discriminated union keeps its mapping, while the own declaration's flags, documentation, limits and extensions (`readOnly`, `description`, `x-*`) stay the field's wherever the variant's sets none — and the schema's own otherwise, so an own `kind` enum beside a variant's plain `kind` stays the enum; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both, two of arrays combine their `items` by this same rule, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it, each named for its const — one of several words alone (`user.created` → `UserCreated`), one word after the component's name (`PetEvent`'s `cat` → `PetEventCat`, and `CatEvent`'s → `CatEventCat`), as is a const that spells the component's own name (`Cat`'s `cat` → `CatCat`), so a variant never takes the dispatcher's name; a union no discriminator dispatches — two of its variants named alike, or one whose const spells no name (`''`, `'-'`) — is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), an inline object and a `$ref` to a component that declares properties (its own or its `allOf` chain's), or an `allOf` that reaches one, conjoin as the `allOf` of both, read as an inline `allOf` is (`result: { $ref: PetPage }` beside `result: { properties: { total_pages } }` is a `PageResult` with `PetPage`'s `items` and `total_pages`, whichever member comes first, `items` typed as `PetPage` types it, and the flags and documentation either declaration gives the field stay the field's), while a `$ref` to an array, enum, primitive or union component is no object to conjoin and the first declaration stays; two typing arrays combine their `items` by this same rule (`items: { description }` only annotates `items: { $ref: Pet }`, so `data` stays `Pet[]`) — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). ### UnionType diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index 6ce642ea..61e38029 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -4053,7 +4053,10 @@ function detectAllOfVariantDiscriminator( if (!candidateProperty) return null; const mapping: Record = {}; - const seenModelNames = new Set(); + // The component's own name is the dispatcher's: a variant named so — one + // whose const gives no name, `''` or `'-'` — is as ambiguous as two + // variants of one name (workos/oagen#430). + const seenModelNames = new Set([fallbackName]); for (const v of variants) { const value = getEffectiveConstPropertyValue(v, candidateProperty); if (value === null) return null; @@ -4383,11 +4386,13 @@ function deriveDiscriminatedVariantName(schema: SchemaObject, fallbackName: stri // it with the parent name so the variant resolves to e.g. `ConnectApplicationOAuth` // / `ConnectApplicationM2M`. Multi-word consts like `action.authentication.denied` // are already specific enough on their own, matching the established - // EventSchema variant naming. + // EventSchema variant naming. Either way a const never names the variant as + // the parent itself, whose name is the dispatcher's: `CatEvent`'s `cat` is + // `CatEventCat` as `PetEvent`'s is `PetEventCat`, and `Cat`'s is `CatCat` + // (workos/oagen#430). const wordCount = splitWords(constValue).length; + if (pascal === fallbackName) return `${fallbackName}${pascal}`; if (wordCount >= 2) return pascal; - if (pascal === fallbackName) return pascal; - if (fallbackName.startsWith(pascal)) return fallbackName; if (pascal.endsWith(fallbackName)) return pascal; return `${fallbackName}${pascal}`; } diff --git a/test/parser/allof-sibling-properties.test.ts b/test/parser/allof-sibling-properties.test.ts index 647ecbeb..b94b0a3a 100644 --- a/test/parser/allof-sibling-properties.test.ts +++ b/test/parser/allof-sibling-properties.test.ts @@ -1122,9 +1122,9 @@ describe('workos/oagen#307: properties written beside allOf', () => { Pet: pet, AnyOne: { allOf: [{ anyOf: [only] }] }, OneOne: { allOf: [{ oneOf: [only] }] }, - // Consts of more than one letter: `deriveDiscriminatedVariantName` hands - // a variant the parent's own name when the parent starts with the const - // (`AnyTwo` starts with `A`), a naming rule this PR leaves as it is. + // Consts of more than one letter: `deriveDiscriminatedVariantName` handed + // a variant the parent's own name when the parent started with the const + // (`AnyTwo` starts with `A`), until workos/oagen#430. AnyTwo: { allOf: [ ref('Pet'), @@ -1151,3 +1151,98 @@ describe('workos/oagen#307: properties written beside allOf', () => { }); }); }); + +/** + * workos/oagen#430: a discriminated `allOf` variant was named for its const + * prefixed with the component's name, except when the name already started + * with the const: `CatEvent`'s `cat` variant was named `CatEvent` and took + * the dispatcher's place, while the same document with the component named + * `PetEvent` dispatched to `PetEventCat` and `PetEventDog`. + */ +describe("workos/oagen#430: a discriminated variant never takes its component's name", () => { + const event = obj({ id: str }, { required: ['id'] }); + const variant = (kind: string, own: Record): SchemaObject => + obj({ kind: { type: 'string', const: kind }, ...own }, { required: ['kind'] }); + const cat = variant('cat', { meow: str }); + const dog = variant('dog', { bark: str }); + + for (const keyword of ['oneOf', 'anyOf'] as const) { + it(`the issue's document: \`CatEvent\` dispatches as \`PetEvent\` does (${keyword})`, async () => { + for (const name of ['CatEvent', 'PetEvent']) { + const { spec, warnings } = await parse( + `${name}-${keyword}`, + specDoc( + { '/cat-events': getOp('getCatEvent', ref(name)) }, + { Event: event, [name]: { allOf: [ref('Event'), { [keyword]: [cat, dog] }] } }, + ), + ); + expect(show(opsOf(spec).getCatEvent!.response), name).toBe(name); + expect(fieldsOf(spec, name), name).toEqual(['id: string']); + expect(modelOf(spec, name).discriminator, name).toEqual({ + property: 'kind', + mapping: { cat: `${name}Cat`, dog: `${name}Dog` }, + }); + expect(fieldsOf(spec, `${name}Cat`), name).toEqual(['id: string', 'kind: "cat"', 'meow?: string']); + expect(fieldsOf(spec, `${name}Dog`), name).toEqual(['id: string', 'kind: "dog"', 'bark?: string']); + expect(modelNames(spec).sort(), name).toEqual(['Event', name, `${name}Cat`, `${name}Dog`].sort()); + expect(warnings, name).toEqual([]); + expectSound(spec, ['Event', name]); + } + }); + } + + it("a const that is the component's whole name, one word or several, is prefixed with it too", async () => { + const { spec, warnings } = await parse( + 'const-is-component-name', + specDoc( + {}, + { + Event: event, + Cat: { allOf: [ref('Event'), { oneOf: [cat, dog] }] }, + CatEvent: { + allOf: [ + ref('Event'), + { oneOf: [variant('cat_event', { meow: str }), variant('dog_event', { bark: str })] }, + ], + }, + }, + ), + ); + expect(fieldsOf(spec, 'Cat')).toEqual(['id: string']); + expect(modelOf(spec, 'Cat').discriminator).toEqual({ property: 'kind', mapping: { cat: 'CatCat', dog: 'CatDog' } }); + expect(fieldsOf(spec, 'CatCat')).toEqual(['id: string', 'kind: "cat"', 'meow?: string']); + expect(fieldsOf(spec, 'CatDog')).toEqual(['id: string', 'kind: "dog"', 'bark?: string']); + // A const of several words stands alone, as before, unless it is the component's name. + expect(fieldsOf(spec, 'CatEvent')).toEqual(['id: string']); + expect(modelOf(spec, 'CatEvent').discriminator).toEqual({ + property: 'kind', + mapping: { cat_event: 'CatEventCatEvent', dog_event: 'DogEvent' }, + }); + expect(fieldsOf(spec, 'CatEventCatEvent')).toEqual(['id: string', 'kind: "cat_event"', 'meow?: string']); + expect(fieldsOf(spec, 'DogEvent')).toEqual(['id: string', 'kind: "dog_event"', 'bark?: string']); + expect(warnings).toEqual([]); + expectSound(spec, ['Event', 'Cat', 'CatEvent']); + }); + + it("a const that gives no name would name its variant as the component: the union is flattened, as two variants of one name's is", async () => { + for (const nameless of ['', '-']) { + const { spec, warnings } = await parse( + `nameless-const-${nameless.length}`, + specDoc( + {}, + { Event: event, CatEvent: { allOf: [ref('Event'), { oneOf: [variant(nameless, { meow: str }), dog] }] } }, + ), + ); + expect(modelOf(spec, 'CatEvent').discriminator, nameless).toBeUndefined(); + expect(fieldsOf(spec, 'CatEvent'), nameless).toEqual([ + 'id: string', + `kind?: ${JSON.stringify(nameless)}`, + 'meow?: string', + 'bark?: string', + ]); + expect(modelNames(spec).sort(), nameless).toEqual(['CatEvent', 'Event']); + expect(warnings, nameless).toEqual([]); + expectSound(spec, ['Event', 'CatEvent']); + } + }); +}); From c2944485a690fe931c54b3ec8695cca752ac186d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 14:22:38 -0400 Subject: [PATCH 52/59] fix(parser)!: give a field an array component's limits and docs, a nullable oneOf one included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #335 an array component has no model, so a field that names it with a bare `$ref` is the only node left to carry what the component declares beside its items. It took the component's text, flag and link, but not its `minItems`/`maxItems`/`uniqueItems`, `readOnly`, `writeOnly` or `default` (a limit written beside the `$ref` was the field's only one); its own `x-*` keys replaced the component's instead of merging with them; and through an alias (`PetsAlias: { $ref: Pets }`, `PetsList: { allOf: [{ $ref: Pets }] }`) it read only the alias, which declares nothing. A component written as `oneOf: [{ type: array, items }, { type: 'null' }]` was not an array component at all: it stayed a fieldless named-union model, every `$ref` to it was `MaybePets | null`, and a list envelope over it was not paginated. buildFieldFromSchema now reads every entry the reference passes on its way to the array (arrayComponentEntries, the alias walk isArrayComponentEntry already made), nearest first: limits conjoin, as JSON Schema applies every level (`{ $ref: Tags, maxItems: 3 }` gives `{ minItems: 1, maxItems: 3, uniqueItems: true }`); `x-*` keys merge under the field's own; `deprecated`, `readOnly` and `writeOnly` hold when any level says so; text, link, `default` and examples come from the nearest level that declares them. The lone member of an `allOf` entry is that entry's own declaration, not an entry. isArrayComponentSchema and the alias walk read a component with its nullable `oneOf`/`anyOf` wrapper removed (unwrapNullableSchema), so `oneOf: [array, null]` is an array component and `oneOf: [{ $ref: Pets }, null]` an alias of one, unless the component writes `properties` of its own, which keep the object model. Such a component is no longer registered as a nullable union name, so an object another key resolves to the same name stays non-nullable. The issue's tags.json, alias.json and maybe-oneof.json give its Expected; the IR, warnings included, of every fixture spec, the github-subset example and three WorkOS specs is unchanged. BREAKING CHANGE: a component written as a nullable `oneOf`/`anyOf` around an array, or around a `$ref` to an array component, no longer becomes a model: a `$ref` to it is typed `nullable>` and a list envelope whose data is such a `$ref` is paginated. A field that `$ref`s an array component now carries the component's (and its aliases') limits, `readOnly`, `writeOnly`, `default` and merged `x-*` keys, and its text, link and flag through an alias. Closes #399 --- docs/architecture/emitter-contract.md | 2 +- docs/architecture/ir-types.md | 16 +- skills/check-emitter-parity/SKILL.md | 2 +- .../references/generator-guide.md | 4 +- src/ir/types.ts | 55 ++- src/parser/schemas.ts | 171 ++++++--- test/parser/array-components.test.ts | 354 +++++++++++++++++- 7 files changed, 508 insertions(+), 96 deletions(-) diff --git a/docs/architecture/emitter-contract.md b/docs/architecture/emitter-contract.md index 213ede43..f9041137 100644 --- a/docs/architecture/emitter-contract.md +++ b/docs/architecture/emitter-contract.md @@ -124,7 +124,7 @@ Emitters should handle these additional variants introduced for open-source read - **Request body required:** Optional `requestBodyRequired: boolean` on `Operation`, present exactly when `requestBodyContentTypes` is — a body carried in `requestBodyContents` alone has it too, so it does not imply `requestBody`; `false` when the spec omits the flag (the OpenAPI default) - **Request body description:** Optional `requestBodyDescription: string` on `Operation` and `Webhook` — the Request Body Object's own `description` (after a `components/requestBodies` `$ref` is resolved), else the `description` written on the schema `requestBody` was read from (a `$ref` not followed) — present only when one of the two has non-whitespace content, e.g. for the body argument's doc tag; an inline object body's model carries its schema's `description` as any other inline model does - **Request body content types:** Optional `requestBodyContentType: string` on `Operation` and `Webhook` — the essence of the media type `requestBody` was read from (`application/merge-patch+json`; `application/json` for `application/json; charset=utf-8`), set exactly when `requestBody` is: send it as the `Content-Type`, producing parameters such as a multipart boundary yourself — and optional `requestBodyContentTypes: string[]` / `requestBodyContents: RequestBodyContent[]` on both (every media type the body declares, verbatim and in document order, with a type per media type; the entry for the picked media type shares `requestBody`'s type), present only when the body declares at least one media type; `requestBody` is the fixed-order pick -- **Deprecation:** Optional `deprecated: boolean` on `Operation`, `Parameter`, `ResponseHeader`, `Field`, `Model` and `Enum`, each read from its own source: `Operation` from the Operation Object's own keyword; `Parameter` from any of the Parameter Object (after a `components.parameters` `$ref` is resolved), its inline `schema` and the component schema its `schema.$ref` points at — deprecated when any of the three says `deprecated: true`, so a `false` on one level does not stop a `true` on another; `ResponseHeader` from the Header Object only (after a `components/headers` `$ref` is resolved), never from its schema; `Field` from the property schema itself, a keyword written beside a `$ref` included, while the referenced model keeps its own and an `allOf` member's is not read — except that a bare `$ref` to an array component, which has no model to keep the flag, deprecates the field when the component says `deprecated: true`, as the field takes that component's `description`, `externalDocs`, `extensions` and `example`; `Model` from the schema the model was built from — a component's own top-level keyword, or the inline schema an inline model is materialized from — never an `allOf` member's; `Enum` from the component enum schema's own top-level keyword, while an inline enum lifted into `spec.enums` carries no flag of its own: the field, parameter or header that declares it carries `deprecated`. At every site the parser tests `=== true`: a `false`, absent or non-boolean keyword (`"yes"`, `1`) leaves the key absent, so the IR never carries anything but a literal `true` and `if (node.deprecated)` is the whole check. Emit the language's deprecation marker on the generated symbol (a `@deprecated` doc tag, `[Obsolete]`, `#[deprecated]`, a runtime warning where the language has no annotation). `EnumValue.deprecated` is present, and `true`, when the enum is declared as a `oneOf`/`anyOf` of `const` schemas and the value's own member says `deprecated: true` — the OAS 3.1 spelling for deprecating one value; a plain `enum:` list has no per-value keyword, so its values never carry it. Emit the same deprecation marker on that member alone +- **Deprecation:** Optional `deprecated: boolean` on `Operation`, `Parameter`, `ResponseHeader`, `Field`, `Model` and `Enum`, each read from its own source: `Operation` from the Operation Object's own keyword; `Parameter` from any of the Parameter Object (after a `components.parameters` `$ref` is resolved), its inline `schema` and the component schema its `schema.$ref` points at — deprecated when any of the three says `deprecated: true`, so a `false` on one level does not stop a `true` on another; `ResponseHeader` from the Header Object only (after a `components/headers` `$ref` is resolved), never from its schema; `Field` from the property schema itself, a keyword written beside a `$ref` included, while the referenced model keeps its own and an `allOf` member's is not read — except that a bare `$ref` to an array component, which has no model to keep the flag, deprecates the field when the component, or an alias the reference passes, says `deprecated: true`, as the field takes that component's `description`, `externalDocs`, `extensions`, `example`, limits, `readOnly`, `writeOnly` and `default`; `Model` from the schema the model was built from — a component's own top-level keyword, or the inline schema an inline model is materialized from — never an `allOf` member's; `Enum` from the component enum schema's own top-level keyword, while an inline enum lifted into `spec.enums` carries no flag of its own: the field, parameter or header that declares it carries `deprecated`. At every site the parser tests `=== true`: a `false`, absent or non-boolean keyword (`"yes"`, `1`) leaves the key absent, so the IR never carries anything but a literal `true` and `if (node.deprecated)` is the whole check. Emit the language's deprecation marker on the generated symbol (a `@deprecated` doc tag, `[Obsolete]`, `#[deprecated]`, a runtime warning where the language has no annotation). `EnumValue.deprecated` is present, and `true`, when the enum is declared as a `oneOf`/`anyOf` of `const` schemas and the value's own member says `deprecated: true` — the OAS 3.1 spelling for deprecating one value; a plain `enum:` list has no per-value keyword, so its values never carry it. Emit the same deprecation marker on that member alone - **Response content types:** Optional `responseContentTypes: string[]` and `responseContents: ResponseContent[]` on `Operation` (the primary response's media types, verbatim and in document order, with a type per media type) and `contentTypes` / `contents` on each `SuccessResponse` / `ErrorResponse`, present only when the response declares at least one media type (an error carries them only when they add to its `type`); `response` is the fixed-order pick - **Fallback responses:** Optional `defaultResponse: DefaultResponse` (the `default` response) and `rangeResponses: RangeResponse[]` (`1XX`–`5XX`) on `Operation`, present only when the spec declares them, each with its own `type`, `headers`, `description` and — in the shape a `SuccessResponse` carries them — `contentTypes` / `contents`; resolve a status as the explicit entry, then its range, then `defaultResponse`. Neither feeds `response` or `errors`: `defaultResponse.type` types the errors an operation does not list, and the `2XX` range's `type`, then `defaultResponse.type`, is the success body of an operation that declares no explicit 2xx (`response` is `unknown` there) - **Idempotency:** `injectIdempotencyKey` is spec-driven: `true` exactly when the operation declares an `Idempotency-Key` header parameter (the name compared case-insensitively), which is then left out of `headerParams` — the SDK injects the header rather than typing it as an argument — so its `description`, `required` flag and schema do not reach the IR diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 6193bda4..f8d279c7 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -79,7 +79,7 @@ The specification extensions (`x-*` keys) an OpenAPI object declares, each under **Consumed keys.** A key the parser already reads into a typed field is not repeated in `extensions`. On an Operation Object: `x-pagination` (`pagination`), `x-oagen-async` (`async`), `x-mutually-exclusive-parameter-groups` and `x-mutually-exclusive-body-groups` (`parameterGroups`). On a Schema Object: `x-enum-descriptions` and `x-enumDescriptions` (each `EnumValue.description`). The sets are `OPERATION_CONSUMED_EXTENSIONS` and `SCHEMA_CONSUMED_EXTENSIONS` in `src/parser/extensions.ts`. A key is consumed by the object it is read from: `x-mutually-exclusive-body-groups` is read from the Operation Object only, so the same key written on a request body schema is carried on that schema's model. -**Source per node.** `Operation.extensions` is the Operation Object's own keys. `Model.extensions` is the keys of the schema the model was built from — a component's own, or the inline schema's for a request body, a response, a `oneOf` variant (each variant model carries its own variant's keys), an object nested in a field, array items; an `allOf` wrapper's own keys, a member's staying on that member and a `$ref` member's on its component, for a component `allOf`, an inline `allOf` merged into one model and an `allOf` response alike; a discriminated `oneOf` body merged into one model carries the wrapper's keys, not a variant's; a discriminated `allOf` + `oneOf` component's variant models carry each variant's own. `Field.extensions` is the field schema's own keys, read like `constraints`: keys written beside a `$ref` (OAS 3.1) are the field's, a bare `$ref` yields none — the referenced model, union or enum carries its own — unless it names an array component, whose keys the field then carries as it carries that component's `description`. A list envelope's `envelopeFields` carry theirs the same way. +**Source per node.** `Operation.extensions` is the Operation Object's own keys. `Model.extensions` is the keys of the schema the model was built from — a component's own, or the inline schema's for a request body, a response, a `oneOf` variant (each variant model carries its own variant's keys), an object nested in a field, array items; an `allOf` wrapper's own keys, a member's staying on that member and a `$ref` member's on its component, for a component `allOf`, an inline `allOf` merged into one model and an `allOf` response alike; a discriminated `oneOf` body merged into one model carries the wrapper's keys, not a variant's; a discriminated `allOf` + `oneOf` component's variant models carry each variant's own. `Field.extensions` is the field schema's own keys, read like `constraints`: keys written beside a `$ref` (OAS 3.1) are the field's, a bare `$ref` yields none — the referenced model, union or enum carries its own — unless it names an array component, whose keys the field then carries under its own, the field's winning per key, as it carries that component's `description` (see Array components). A list envelope's `envelopeFields` carry theirs the same way. **Several declarations of one property.** A property several `allOf` members always declare is typed by the first always-applying declaration that declares a type, its extensions included; a later typing declaration adds to it only where the two are conjoined, as JSON Schema applies both — two inline objects, an inline object and a `$ref` to an object component, two arrays (see Inline `allOf`) — the later's keys then over the earlier's, and a typeless one fills in only the keys the typing one leaves unset (`combineDeclarations`) — the precedence every keyword follows. A property only `oneOf`/`anyOf` variants declare carries exactly the keys every declaration as written agrees on, value for value — one reading, `agreedExtensions`, at every site that rebuilds or widens a property from several declarations without changing which declaration types it: `unionOfDeclarations` for an inline `allOf`; `declaredComponentProperty` for a component (a bare `oneOf` component or a union member of a component `allOf`, whose field is typed from the first branch); `mergeBranchPropertySchemas` for the nested sub-branches of a discriminated `allOf` + `oneOf` variant, where boolean or string consts widen to a plain boolean or string; and `flattenDiscriminatedUnionBody` for a discriminated inline request body, whose discriminator is rebuilt as an enum of the pinned consts and whose other properties are typed from the first variant that declares them — whether the declarations share one shape or become a union: a key any declaration leaves unset or writes another value for is nobody's, and a declaration that repeats an earlier shape still has its say — three variants writing `x-since` as `1`, `2` and `1` leave it off. In an inline `allOf`, the lifted keywords (`description`, `readOnly`, `default`, the limits, …) are read by the same agreement whether the declarations share one shape or become a union, and make no shape of their own: two `type: 'string'` declarations that differ only in them are one `string` field carrying what both write (#397). `Enum.extensions` is the keys of the schema that declares the members: a component enum's own, or, for an enum lifted from an inline schema, that schema's — the field, parameter or header the schema types carries the same keys, since they are its schema's own — read as the enum's `description` and `externalDocs` are, through `collectInlineEnumFromRef`; when several inline schemas lift to one name, the first lifted gives the keys, as it gives the values. @@ -714,14 +714,14 @@ interface Field { default?: unknown; example?: unknown; // The schema's `example`, else `examples[0]` examples?: unknown[]; // The JSON Schema `examples` list; only when non-empty - constraints?: Constraints; // Validation keywords on the field's schema, siblings of a $ref included; a bare $ref yields none + constraints?: Constraints; // Validation keywords on the field's schema, siblings of a $ref included; a bare $ref to a model yields none, one to an array component conjoins the component's (see Array components) extensions?: Extensions; // The field schema's own x-* keys, read like constraints (see Extensions) } ``` ### Named unions -A component schema that is itself a `oneOf`/`anyOf` — no `allOf`, no `properties` of its own, and not every non-null variant a string `const` (that is an enum; see Enum) — is a named union. It still becomes a `Model` (every component other than an enum or an array component does; see Array components below), and a `$ref` to it stays `{ kind: "model" }`, but the model carries the union: `variants` holds one `TypeRef` per non-null variant in document order, `compositionKind` says which keyword produced them, and `discriminator` is set when the union is discriminated. `fields` holds the inline variants' properties merged as optional fields (`UpdateOrganizationMembership`'s `role_slug` / `role_slugs`), so an emitter that reads only `fields` sees a plain object model, while one that reads `variants` emits the union from them. +A component schema that is itself a `oneOf`/`anyOf` — no `allOf`, no `properties` of its own, not every non-null variant a string `const` (that is an enum; see Enum), and not a `null` variant beside one array or `$ref` to an array component (that is an array component; see Array components) — is a named union. It still becomes a `Model` (every component other than an enum or an array component does; see Array components below), and a `$ref` to it stays `{ kind: "model" }`, but the model carries the union: `variants` holds one `TypeRef` per non-null variant in document order, `compositionKind` says which keyword produced them, and `discriminator` is set when the union is discriminated. `fields` holds the inline variants' properties merged as optional fields (`UpdateOrganizationMembership`'s `role_slug` / `role_slugs`), so an emitter that reads only `fields` sees a plain object model, while one that reads `variants` emits the union from them. The variants are built exactly as the same union written inline is (`UnionType`): a `$ref` variant is a model or enum ref; an inline object variant is referenced by a model extracted for it; a nullable (`type: ['object', 'null']`) or multi-type (`['object', 'string']`) inline variant keeps its wrapper around that model; a primitive stays a primitive; the `null` variant (in any spelling, see UnionType) is dropped from `variants` — an inline union lifts it into a `nullable` wrapper, and a `Model` has no wrapper to carry it — so, as for a nullable enum component, the nullability travels on the reference instead: every `$ref` to a named union that admits `null` (a `null` variant, a `type` list with `'null'`, or OAS 3.0 `nullable: true`) resolves to `nullable`, the shape the same union written inline has. Every other variant — a primitive, an inline enum, an array, a map, an `allOf`, a nested union — is typed and extracted as the `variant_` property of one synthetic object under the union's name, where n is the variant's 1-based position among the non-null variants, so whatever it mints is named `Variant…` by the one rule a field's inline models follow (an inline enum second in the list is `PetVariant2`, an array's inline items third in the list are `PetVariant3`, a nested object under them `PetVariant3Child`), and two variants of the same kind can never share a name or a value list. When a name such a variant would mint is already a component's, or another named union's inline variant's, of a different shape, the context steps exactly as an array's or map's does beside a claiming sibling — `variant_1_item` / `variant_1_value` (`PetVariant1Item`, `PetVariant1Value`), then `variant_1_array_item` / `variant_1_member_value`, then numbered, with the same no-progress and attempt-cap guards — and every name nested under the variant moves with it, since both the reference and the extractor read the chosen context; a claimant of the identical shape is shared instead (the array's items then point at the component). @@ -735,15 +735,15 @@ Every pass that walks a model's type refs — reachability for emission, service ### Array components -A component schema that is an array — `type: 'array'`, an OAS 3.1 type list `['array', 'null']`, OAS 3.0 `nullable: true` beside `type: 'array'`, or `items` with no `type`, in every case with `items` declared (`isArrayComponentSchema`) — or one `schemaToTypeRef` types as an array through its readings of a `$ref` and an `allOf`: a bare `$ref` to it; an `allOf` read as a plain reference (`readInlineAllOf`: `$ref` members with nothing inline beside them — `allOf: [{ $ref: Pets }]`, `allOf: [{ $ref: Pets }, { maxItems: 10 }]`), read as its first `$ref` member; an `allOf` of one member, read as that member; followed through aliases of aliases (`isArrayComponentEntry`) — becomes no `Model`. The parser knows an array component by its `components/schemas` key, the key a `$ref` names, not by its IR name: `cleanSchemaName` singularizes the leading word, so `Note: { type: object }` and `Notes: { type: array, items: { $ref: Note } }` both resolve to `Note`, and keying by name would send `$ref: Note` into the array; keyed by `Notes`, the object keeps its model and `$ref: Notes` is `array`. A `$ref` is resolved by its key before any name-based test, so `$ref: Notes` is the array even when another key resolving to `Note` is an enum. A `$ref` to an array component resolves to the array the component declares, the shape the same array written inline has: with `Pets: { type: array, items: { $ref: Widget } }`, every `$ref` to `Pets` is `{ kind: "array", items: { kind: "model", name: "Widget" } }`, and a component that admits `null` wraps that in `nullable`, as a `$ref` to a nullable enum component is wrapped. This holds at every reference site — a component field, an `allOf` member (`allOf: [{ $ref: Pets }]`, with or without limits beside it), a named union's variant, a request body, a parameter in any location, a response header, a success, error, `default` or range response body, a webhook payload — because every site types a `$ref` through `schemaToTypeRef`; and a list envelope whose data array is a `$ref` to an array component, under any nullable wrapper or an `allOf` read as the reference (`allOf: [{ $ref: Pets }]`), is detected and unwrapped as one whose array is written inline, iterating the component's items. `spec.models` holds no `Pets`, so an emitter never sees an empty class, or a list typed as one object. +A component schema that is an array — `type: 'array'`, an OAS 3.1 type list `['array', 'null']`, OAS 3.0 `nullable: true` beside `type: 'array'`, a `oneOf`/`anyOf` whose one variant beside a `null` one is such an array (`oneOf: [{ type: 'array', items }, { type: 'null' }]`, unwrapped as `unwrapNullableSchema` unwraps it everywhere else, unless the component declares `properties` of its own, which keep it the object model), or `items` with no `type`, in every case with `items` declared (`isArrayComponentSchema`) — or one `schemaToTypeRef` types as an array through its readings of a nullable wrapper, a `$ref` and an `allOf`: a bare `$ref` to it; such a nullable `oneOf`/`anyOf` around a `$ref` to it (`oneOf: [{ $ref: Pets }, { type: 'null' }]`); an `allOf` read as a plain reference (`readInlineAllOf`: `$ref` members with nothing inline beside them — `allOf: [{ $ref: Pets }]`, `allOf: [{ $ref: Pets }, { maxItems: 10 }]`), read as its first `$ref` member; an `allOf` of one member, read as that member; followed through aliases of aliases (`isArrayComponentEntry`) — becomes no `Model`. The parser knows an array component by its `components/schemas` key, the key a `$ref` names, not by its IR name: `cleanSchemaName` singularizes the leading word, so `Note: { type: object }` and `Notes: { type: array, items: { $ref: Note } }` both resolve to `Note`, and keying by name would send `$ref: Note` into the array; keyed by `Notes`, the object keeps its model and `$ref: Notes` is `array`. A `$ref` is resolved by its key before any name-based test, so `$ref: Notes` is the array even when another key resolving to `Note` is an enum. A `$ref` to an array component resolves to the array the component declares, the shape the same array written inline has: with `Pets: { type: array, items: { $ref: Widget } }`, every `$ref` to `Pets` is `{ kind: "array", items: { kind: "model", name: "Widget" } }`, and a component that admits `null` wraps that in `nullable`, as a `$ref` to a nullable enum component is wrapped. This holds at every reference site — a component field, an `allOf` member (`allOf: [{ $ref: Pets }]`, with or without limits beside it), a named union's variant, a request body, a parameter in any location, a response header, a success, error, `default` or range response body, a webhook payload — because every site types a `$ref` through `schemaToTypeRef`; and a list envelope whose data array is a `$ref` to an array component, under any nullable wrapper or an `allOf` read as the reference (`allOf: [{ $ref: Pets }]`), is detected and unwrapped as one whose array is written inline, iterating the component's items. `spec.models` holds no `Pets`, so an emitter never sees an empty class, or a list typed as one object. What the items reference is extracted under the component's name, by the inline extractor a field's array goes through, as the `item` property of a synthetic object under the component: an inline object item is `Item` (`PetsItem`), with everything nested under it named from that (`PetsItemOwner`); the object variants of an `items.oneOf` are named by the inline-variant rules from `PetsItem` (`PetsItem`, `PetsItemWithSide`); an inline enum is `PetsItem`, lifted into `spec.enums` whether or not anything refers to the component (an inline object item's model is likewise emitted for a component nothing refers to); the objects of a nested array or map are `PetsItem` too, by the one rule a field's items and values follow. The component's name is the one `cleanSchemaName` and `schemaNameTransform` give it, so a component `pet_list` renamed to `PetCollection` gives `PetCollectionItem`. A `$ref` item mints nothing: it stays a reference to its model or enum, and a `$ref` to another array component resolves in turn (`array>`). Every name the whole tree mints — the item model, an object nested under it (`PetsItemOwner`), an inline enum a nested field holds, a union variant — is read off a rehearsal of the extraction itself before any plan is decided, so a nested name counts exactly as the item model's does. When any of them is already claimed for a different shape — by any name any extraction pass takes from another `components/schemas` entry, read off a rehearsal of those passes: the entry's own model (compared by `modelFingerprint`), enum (by its members) or array component's name, a discriminated allOf+oneOf variant (an `Event` whose variant pins `type: 'pets_item'` owns `PetsItem`), a named union's variant or `variant_` member, an object or enum the entry's fields mint, and everything nested under those; or by another array component's tree — the whole tree's context steps exactly as a named union's `variant_` does (see Named unions): `item_item` (`PetsItemItem`, `PetsItemItemOwner`), then `item_array_item`, then numbered, until no name under it is claimed; a claimant of the identical shape is shared, and the tree references it. Shapes are compared as the real pass types them, under the names the plans settle on. The plans are decided in rounds: every tree starts under `item`; a round registers every tree under its current context, so a `$ref` to an array component resolves to its array in every rehearsal — `{ value: { $ref: Notes } }` and `{ value: { $ref: Note } }` differ, as `array` and `Note` do, although both keys resolve to `Note` — while the names a tree mints are read with none registered, so an enum another component's items hold is never counted as the tree's own; then the trees are decided one at a time, in the order of their keys sorted by code point — never the document's order — each against every component and every other tree's current names, a tree that moves being re-registered at once so the trees after it resolve its items under its new name. A round that moves nothing is the last, so no sharing rests on a name a tree then steps away from (`Pets`'s items holding `{ value: { $ref: Palette } }` are shared with a component `PetsItem { value: array }` only while `Palette`'s items are still `PaletteItem`; once `Palette` steps to `PaletteItemItem`, the next round reads `array`, and `Pets`'s items step to `PetsItemItem`), no two trees end on one name for different shapes however many step (two trees whose names nest into each other, `Pets` and `PetsItemItemCat`, both step, the second past the first's names), and the decision does not depend on declaration order; two trees that mint one name for the identical shape keep it and one model is emitted. Two rounds suffice unless trees chase each other's names; after eight the last decision is kept with a warning. The reference and the extractor read one plan, decided once per document before any reference is typed, so the two agree. A name an operation mints — `operationId: listPets` names an inline array response's items `ListPetsItem`, as a component `ListPets` names its own — is not read here: the component's name is settled first, and the operation's items are settled afterwards against the spec-wide registry of model shapes (see Inline model naming), so a name the component's items own with another shape sends them to `ListPetsResponseItem`, and an identical shape shares the model. A component whose items reach back to it — `Tree: { type: array, items: { $ref: Tree } }`, directly or through another array component — has no finite type in the IR: the inner reference is typed as `unknown` (`array`), with one `[oagen] Warning:` per component however often it is referenced. -The component's own `description`, `deprecated` and `externalDocs` sit on no model, so the one node that names the component with a bare `$ref` and has a place for them carries them. A parameter whose schema is a `$ref` to the component takes the component's `description`, `deprecated`, `default`, `example` and `externalDocs` as fallbacks and merges the component's limits (`minItems`, `maxItems`) underneath its own. A field whose schema is a bare `$ref` to an array component takes the component's `description` and `externalDocs` when it declares none of its own, and is deprecated when either side says so — as it takes the component's `example` and `examples` — on every path that builds a field, an `allOf` merge's inherited property included; a `$ref` to a model, named union or enum leaves them on that node, and a `$ref` under a nullable wrapper or an `allOf` reads none, as `example` reads none there. A bare `$ref` field still has no `constraints`: the limits a parameter merges in are not carried onto a field. The items' own keywords are the item model's, as for any inline object. No named alias is left for an emitter: an SDK gets `Pet[]` where the document wrote `Pets`, not a `type Pets = Pet[]`. +The component's own `description`, `deprecated`, `externalDocs`, limits, `readOnly`, `writeOnly`, `default` and `x-*` keys sit on no model, so the one node that names the component with a bare `$ref` and has a place for them carries them. A parameter whose schema is a `$ref` to the component takes the component's `description`, `deprecated`, `default`, `example` and `externalDocs` as fallbacks and merges the component's limits (`minItems`, `maxItems`) underneath its own. A field whose schema is a bare `$ref` to an array component carries what the same array written inline carries: the component's `description`, `externalDocs` and `default` when it declares none of its own, as it takes the component's `example` and `examples`; `deprecated`, `readOnly` and `writeOnly` when either side says so; the component's limits conjoined with any written beside the `$ref`, as JSON Schema applies both (`Tags: { minItems: 1, maxItems: 5, uniqueItems: true }` with `{ $ref: Tags, maxItems: 3 }` gives `{ minItems: 1, maxItems: 3, uniqueItems: true }`, and a wider `maxItems: 10` beside it keeps `5`); and the component's `x-*` keys under the field's own, the field's winning per key. The reference is read through every alias it passes on its way to the array — `PetsAlias: { $ref: Pets }`, `PetsList: { allOf: [{ $ref: Pets }] }`, an alias of an alias — each entry under the nearer one, so an alias's own `description` is the field's and `Pets`'s is read where no alias declares one; the lone member of an `allOf` entry is that entry's own declaration, whose limits count, as `constraints` always conjoins an `allOf`'s members, and whose text and flags do not, as a component model built from one does not read them. This holds on every path that builds a field, an `allOf` merge's inherited property included; a `$ref` to a model, named union or enum leaves all of it but `example` and `examples` on that node, and a `$ref` under a nullable wrapper or an `allOf` reads none, as `example` reads none there. The items' own keywords are the item model's, as for any inline object. No named alias is left for an emitter: an SDK gets `Pet[]` where the document wrote `Pets`, not a `type Pets = Pet[]`. -Two shapes are not array components. A `type: 'array'` with no `items` still becomes a fieldless model, and a `$ref` to it stays `{ kind: "model" }`, since `schemaToTypeRef` types the inline form as `unknown` with a warning. A composition beside the array — an `enum:` list, a `oneOf`/`anyOf` with a typing variant, an `allOf` with a member that declares an object inline — is read first, as `isArraySchema` reads it, so such a component is what the composition makes it (an enum, a named union, an `allOf` model with the inline member's fields); an `allOf` of a `$ref` to an array component and limits alone is an alias of it (above), as the same `allOf` on a field is the array. The two kinds of alias differ in what a `$ref` to them resolves to: an alias of an object component (`WidgetAlias: { allOf: [{ $ref: Widget }] }`, with or without limits beside the member) is a model of its own, `WidgetAlias`, carrying `Widget`'s fields and its own `description`, `deprecated`, `externalDocs`, `extensions` and limits (a `maxProperties` written beside the member is `WidgetAlias`'s), and a `$ref` to it resolves to `{ kind: "model", name: "WidgetAlias" }`, never to `Widget`; an alias of an array component (`PetsAlias: { allOf: [{ $ref: Pets }] }`) has no model to resolve to, so a `$ref` to it is the array `Pets` names (`array`), as a bare `$ref` to `Pets` is. +Two shapes are not array components. A `type: 'array'` with no `items` still becomes a fieldless model, and a `$ref` to it stays `{ kind: "model" }`, since `schemaToTypeRef` types the inline form as `unknown` with a warning. A composition beside the array — an `enum:` list, a `oneOf`/`anyOf` with a typing variant (one that is a nullable wrapper around an array makes the component that array, above), an `allOf` with a member that declares an object inline — is read first, as `isArraySchema` reads it, so such a component is what the composition makes it (an enum, a named union, an `allOf` model with the inline member's fields); an `allOf` of a `$ref` to an array component and limits alone is an alias of it (above), as the same `allOf` on a field is the array. The two kinds of alias differ in what a `$ref` to them resolves to: an alias of an object component (`WidgetAlias: { allOf: [{ $ref: Widget }] }`, with or without limits beside the member) is a model of its own, `WidgetAlias`, carrying `Widget`'s fields and its own `description`, `deprecated`, `externalDocs`, `extensions` and limits (a `maxProperties` written beside the member is `WidgetAlias`'s), and a `$ref` to it resolves to `{ kind: "model", name: "WidgetAlias" }`, never to `Widget`; an alias of an array component (`PetsAlias: { allOf: [{ $ref: Pets }] }`) has no model to resolve to, so a `$ref` to it is the array `Pets` names (`array`), as a bare `$ref` to `Pets` is. ### Inline model naming @@ -809,7 +809,7 @@ Every member is optional and present only when the document declares it. A schem A 3.1 document's numeric form passes through unchanged, including the legal-but-unusual case of an inclusive and an exclusive bound on the same side. -**Scope.** The keywords declared on the field, parameter or model schema are collected, conjoined with those of its inline `allOf` members: the parser unwraps a single-member `allOf`, so `{ allOf: [{ type: string, maxLength: 3 }] }` is a string field carrying `{ maxLength: 3 }`, and a `$ref` member contributes the keywords written beside the reference while the referenced model keeps its own. Limits nested inside array `items` or map values (e.g. `items: { maxLength: 10 }`) are not carried. The one exception is a nullable wrapper — a `oneOf`/`anyOf` with exactly one non-null, non-`$ref` branch, which the IR collapses to `nullable` — whose branch keywords are conjoined with the wrapper's own (and its `allOf` members'), so `anyOf: [{ type: string, maxLength: 8 }, { type: "null" }]` gives `{ maxLength: 8 }` and `{ allOf: [{ maxLength: 30 }], anyOf: [{ type: string, minLength: 20 }, { type: "null" }] }` gives `{ maxLength: 30, minLength: 20 }`, and an inline model built from that branch carries the same conjoined keywords, so `minProperties` written beside `anyOf: [{ type: object, … }, { type: "null" }]` reaches both the field and its model. Limits on a general multi-variant union are not carried. Keywords written beside a `$ref` (OpenAPI 3.1 allows them) are the field's own and are kept; a bare `$ref` field gets no `constraints`, because its limits belong to the referenced model. A `Model` carries whatever its own schema declares — for an object schema that is `minProperties`, `maxProperties` and `dependentRequired`; for an `allOf` model — a component, or an inline response, request body or field the parser rebuilds from `allOf` members — it is the wrapper's keywords conjoined with its members' (the stricter bound wins; a `$ref` member contributes the keywords written beside the reference, while the referenced model keeps its own). A variant model of a discriminated `allOf` + `oneOf` union conjoins the shared keywords — the outer wrapper's, the base members' and any written beside the `oneOf` — with the variant's own the way `allOf` does — an instance must satisfy both, so the larger `min*` and the smaller `max*` win and `dependentRequired` maps are unioned per property. +**Scope.** The keywords declared on the field, parameter or model schema are collected, conjoined with those of its inline `allOf` members: the parser unwraps a single-member `allOf`, so `{ allOf: [{ type: string, maxLength: 3 }] }` is a string field carrying `{ maxLength: 3 }`, and a `$ref` member contributes the keywords written beside the reference while the referenced model keeps its own. Limits nested inside array `items` or map values (e.g. `items: { maxLength: 10 }`) are not carried. The one exception is a nullable wrapper — a `oneOf`/`anyOf` with exactly one non-null, non-`$ref` branch, which the IR collapses to `nullable` — whose branch keywords are conjoined with the wrapper's own (and its `allOf` members'), so `anyOf: [{ type: string, maxLength: 8 }, { type: "null" }]` gives `{ maxLength: 8 }` and `{ allOf: [{ maxLength: 30 }], anyOf: [{ type: string, minLength: 20 }, { type: "null" }] }` gives `{ maxLength: 30, minLength: 20 }`, and an inline model built from that branch carries the same conjoined keywords, so `minProperties` written beside `anyOf: [{ type: object, … }, { type: "null" }]` reaches both the field and its model. Limits on a general multi-variant union are not carried. Keywords written beside a `$ref` (OpenAPI 3.1 allows them) are the field's own and are kept; a bare `$ref` to a model, named union or enum adds none of the target's, because its limits belong to the referenced node, while one to an array component, which has no model, conjoins the component's limits with the field's own (see Array components). A `Model` carries whatever its own schema declares — for an object schema that is `minProperties`, `maxProperties` and `dependentRequired`; for an `allOf` model — a component, or an inline response, request body or field the parser rebuilds from `allOf` members — it is the wrapper's keywords conjoined with its members' (the stricter bound wins; a `$ref` member contributes the keywords written beside the reference, while the referenced model keeps its own). A variant model of a discriminated `allOf` + `oneOf` union conjoins the shared keywords — the outer wrapper's, the base members' and any written beside the `oneOf` — with the variant's own the way `allOf` does — an instance must satisfy both, so the larger `min*` and the smaller `max*` win and `dependentRequired` maps are unioned per property. ### Deprecation @@ -824,7 +824,7 @@ An inline enum (one declared on a field, parameter or header rather than under ` Wherever two inline models are folded into one by structural agreement — list-envelope companions sharing a bare name, header models and array items sharing a registry entry, same-named inline models in the merge passes — `deprecated` is part of the shape, and a deprecated copy never shares a live inline model's name except as the merge passes fold a copy identical but for the flag (below); a model the document declares — a component schema, or a model a component's field minted — reads the flag apart from its shape (see Models), so a deprecated copy a live one admits shares it. An envelope companion keeps its operation-qualified name (`ListLegacyUsersListMetadata`) while the live copies share the majority name — beside a live component that admits it, its nested models agreeing on the flag, a deprecated companion shares the component, with the warning below, and its envelope field keeps the flag — and a header model gets a qualified name of its own. A site that shared a model rather than minting it — a `default` body or header matching a primary body's model, an array body's items matching a component field's model or an earlier operation's — has its references attached to the owner's site, so when the merge renames the owner (a deprecated `Circle` variant two arrays share, once a live `Circle` of other fields appears) every sharing response, header and array is repointed with it. A reference a header, secondary-body, fallback-body or error-body schema wrote by sharing a primary body's or a companion's model (its shape matched the name's owner) belongs to that model's site too, so renaming the model repoints the `default` response or header along with the site's own references. In the merge passes, a live copy owns a shared name: when a deprecated inline model and a live one would resolve to the same name — two inline responses pinned by the same `object` const, an inline request body or a component field's object named like another — the deprecated copy is folded onto the live definition only when it is identical to every live copy of that name in everything but the flag (fields, requiredness, types, limits, defaults), with a warning; a copy that differs in anything else, requiredness included, keeps its own model under a qualified name — its operation's name prefixed (`GetLegacyWidgetWidget`), or, when the name already starts with it, as a request body named from its operation does, a numeric suffix (`CreateNoteRequest2`); a component field's object likewise takes a suffix (`PetOwner2`) — minted against every model and enum name in the document (inline enums the collectors lift later included); identical copies that must leave the name share one such model under the first site's qualified name, the note saying how many sites share it (`PetOwnerAddress2` for the deprecated `address` objects of `Pet.owner` and of a deprecated `PetOwner` component beside a live `PetOwnerAddress` of another shape, `GetLegacyWidgetWidget` for identical deprecated `object: widget` responses of `getLegacyWidget` and `getOldWidget` beside a live `Widget` that does not admit them), and a copy of another shape takes a name of its own beside it; and only the references minted for each copy are repointed — by identity, never by name: an explicit `$ref` to a same-named component stays as written even in the same response map, a reference an `allOf` merge copied from another component's field follows that field's model, and nested models are named before their parents, so a parent whose child was just renamed is no longer "identical but for the flag" to its live twin — and, referencing a renamed model, leaves the shared name by its site rather than by the choice between live twins (see Models: `GetBWidget { owner: GetBWidgetOwner }` beside the `Widget` the other operation keeps), so the renamed child is never left referenced by nothing. A component's name and flag are authoritative: a live inline model named like a deprecated component — an inline response, a request body, a field object, array items, a `oneOf` variant — takes a qualified name the same way, whatever its shape, so a live field never resolves to a deprecated component (`Pet.owner` → `PetOwner2` beside a deprecated `PetOwner`); a deprecated inline model named like a live component shares the component when the component admits its shape, the flag read apart (see Models) — `Pet.owner` written `deprecated: true` resolves to a live `PetOwner` of the same shape and the field keeps its flag, a deprecated `getLegacyWidget` response identical to a live `Widget` resolves to `Widget`, a deprecated `createNote` body identical to a live `CreateNoteRequest` resolves to it — with the warning two inline copies get, and takes a qualified name otherwise. An inline model named like a component of its own flag shares the component when the component admits its shape and takes a qualified name otherwise (see Models); the component's fields, `deprecated`, `description` and `externalDocs` are never replaced. So two things never depend on document order: whether a reference reaches a deprecated model — only where the document itself wrote `deprecated: true`, on the component or on that very operation's or field's own schema, never because of folding — and the name a deprecated copy takes: the shared name when it is identical but for the flag to every live copy of that name, or when a live component admits it, its own qualified name otherwise (when the live copies disagree among themselves, the deprecated copy is always qualified). What follows document order is which live definition a shared name carries when the live copies disagree — the first declared, or the one with more fields — and, for a list-envelope companion several operations share, which of them qualifies it (the operation the envelope pass made its owner: the first declared of the most common shape). -**Source per node.** Each node reads `deprecated` from one place: `Operation` from the Operation Object's own keyword; `Parameter` from any of the Parameter Object (after a `components.parameters` `$ref` is resolved), its inline `schema` and the component schema its `schema.$ref` points at — deprecated when any of the three says `deprecated: true`, so a `false` on one level does not stop a `true` on another; `ResponseHeader` from the Header Object only (after a `components/headers` `$ref` is resolved), never from its schema; `Field` from the property schema itself, a keyword written beside a `$ref` included, while the referenced model keeps its own and an `allOf` member's is not read — or, for a bare `$ref` to an array component, which has no model to keep the flag, from the component as well (see Array components); `Model` and `Enum` from the schema's own top-level keyword as described above. At every site the parser tests `=== true`: a `false`, absent or non-boolean keyword (`"yes"`, `1`) leaves the key absent, so the IR never carries anything but a literal `true`, on any of the six nodes. `EnumValue.deprecated` reads the value's own `const` member — an enum declared as a `oneOf`/`anyOf` of `const` schemas — by the same `=== true` rule, and is absent on every value of a plain `enum:` list, which has no per-value keyword (see Enum below). +**Source per node.** Each node reads `deprecated` from one place: `Operation` from the Operation Object's own keyword; `Parameter` from any of the Parameter Object (after a `components.parameters` `$ref` is resolved), its inline `schema` and the component schema its `schema.$ref` points at — deprecated when any of the three says `deprecated: true`, so a `false` on one level does not stop a `true` on another; `ResponseHeader` from the Header Object only (after a `components/headers` `$ref` is resolved), never from its schema; `Field` from the property schema itself, a keyword written beside a `$ref` included, while the referenced model keeps its own and an `allOf` member's is not read — or, for a bare `$ref` to an array component, which has no model to keep the flag, from the component and every alias the reference passes as well (see Array components); `Model` and `Enum` from the schema's own top-level keyword as described above. At every site the parser tests `=== true`: a `false`, absent or non-boolean keyword (`"yes"`, `1`) leaves the key absent, so the IR never carries anything but a literal `true`, on any of the six nodes. `EnumValue.deprecated` reads the value's own `const` member — an enum declared as a `oneOf`/`anyOf` of `const` schemas — by the same `=== true` rule, and is absent on every value of a plain `enum:` list, which has no per-value keyword (see Enum below). The differ compares the flag on every node that carries it — operation, parameter, response header, model, field, list envelope field, enum, enum value, webhook and webhook parameter — and reads it the way the parser writes it: only `true` is deprecated, so `false` and an absent key are the same thing, and a document that spells out `deprecated: false` diffs as unchanged against one that omits it. A flip is reported on the owning `-modified` change: `deprecatedChanged: true` on `operation-modified`, `model-modified`, `enum-modified` and `webhook-modified`, with `oldDeprecated`/`newDeprecated` beside it (present exactly when the flag is); a `param-deprecated-changed`, `header-deprecated-changed`, `field-deprecated-changed` or `value-deprecated-changed` entry, carrying the same two values and a `details` that reads `live → deprecated` or `deprecated → live`, in the change's `paramChanges`, `responseHeaderChanges`, `fieldChanges`, `envelopeFieldChanges` or `valueChanges` for an operation's or a webhook's parameter, an operation's response header, a field, an operation's list envelope field (`pagination.envelopeFields`, matched by name while both specs paginate) or an enum value. A model, enum, operation or webhook whose only change is the flag is still reported, with an empty nested list. Becoming or ceasing to be deprecated changes no contract — the node is still there with the same shape — so every one of these is additive, in either direction; it still counts as `modified` in the summary, and a breaking change on the same node (a removed field beside the model's flag, a changed response beside the operation's, a retyped payload beside the webhook's) keeps the change breaking. Each flip is also mirrored into `behaviorChanges` as a `deprecated-changed` entry naming the node (`operation`, `parameter`, `response-header`, `model`, `field`, `envelope-field`, `enum`, `enum-value`, `webhook` or `webhook-parameter`) and copying `oldDeprecated`/`newDeprecated` from the change, the way a parameter default change is, so a changelog can say which way it went without reading both specs. Parameters are compared in every location a node carries them — an operation's path, query, header and cookie parameters, a webhook's header, query and cookie parameters — and matched by location and name, since a path `id` and a query `id` may sit on one operation and a webhook's header and query parameters may share a name — a header's name compared case-insensitively, so a header deprecated and respelled `x-pet-token` in one step is still the `X-Pet-Token` it was, and its flip is reported; every `paramChanges` entry carries `paramLocation` to say which one it is about. A response header's flag is compared on every response entry that declares headers — the primary and every other 2xx/3xx entry, every error, the `default` and every range — with headers matched by name case-insensitively, and every `responseHeaderChanges` entry carries its `statusCode` or `fallback` to say which entry it is about (see Response Headers above). diff --git a/skills/check-emitter-parity/SKILL.md b/skills/check-emitter-parity/SKILL.md index 07d12688..42007d25 100644 --- a/skills/check-emitter-parity/SKILL.md +++ b/skills/check-emitter-parity/SKILL.md @@ -158,7 +158,7 @@ A missing kind means an entire category of types won't generate correctly. - `example` — Example value annotation - `examples` — Every example value (the list `example` is drawn from) - `constraints` — Validation limits (min/max, length, pattern, item counts) for doc comments or value checks -- `extensions` — The field schema's own `x-*` keys (a `$ref` sibling's included; a bare `$ref` yields none unless it names an array component, whose keys the field carries as it carries that component's `externalDocs`), as written; for a key the SDK knows +- `extensions` — The field schema's own `x-*` keys (a `$ref` sibling's included; a bare `$ref` yields none unless it names an array component, whose keys the field carries under its own, as it carries that component's `externalDocs`), as written; for a key the SDK knows #### Enum diff --git a/skills/generate-emitter/references/generator-guide.md b/skills/generate-emitter/references/generator-guide.md index c06cf2dc..27df7be3 100644 --- a/skills/generate-emitter/references/generator-guide.md +++ b/skills/generate-emitter/references/generator-guide.md @@ -101,10 +101,10 @@ Use the shared utilities from `@workos/oagen` (`toPascalCase`, `toSnakeCase`, `t - `type` — `TypeRef` - `required` — `boolean` - `description?`, `readOnly?`, `writeOnly?`, `deprecated?` — doc text and modifier flags - - `externalDocs?` — the field schema's own link (a `$ref` sibling's included), for a `@see`-style tag. A bare `$ref` yields none — the referenced model, union or enum carries its own — unless it names an array component, which has no model to carry it: the field then takes the component's, by the one rule `description`, `deprecated`, `externalDocs`, `extensions` and `example` / `examples` share (never `constraints`) + - `externalDocs?` — the field schema's own link (a `$ref` sibling's included), for a `@see`-style tag. A bare `$ref` yields none — the referenced model, union or enum carries its own — unless it names an array component, which has no model to carry it: the field then takes the component's, read through any alias of it, by the one rule `description`, `deprecated`, `externalDocs`, `extensions`, `example` / `examples`, `readOnly`, `writeOnly`, `default` and `constraints` share (the component's limits conjoined with the field's own) - `default?`, `example?`, `examples?` — spec-declared values (`example` is the schema's `example`, else `examples[0]`) - `constraints?` — validation keywords on the field's schema (`Constraints`) - - `extensions?` — the field schema's own `x-*` keys (a `$ref` sibling's included; a bare `$ref` yields none unless it names an array component, whose keys the field then carries, as it carries that component's `externalDocs`); escape a value before writing it into source + - `extensions?` — the field schema's own `x-*` keys (a `$ref` sibling's included; a bare `$ref` yields none unless it names an array component, whose keys the field then carries under its own, as it carries that component's `externalDocs`); escape a value before writing it into source - Output: One file per model, using the exact model pattern from the design doc - Use `type-map.ts` to convert field types diff --git a/src/ir/types.ts b/src/ir/types.ts index d30db3fd..46c449eb 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -988,15 +988,17 @@ export interface TypeParam { /** * Model definition (maps to an SDK model/data class). Every * `components/schemas` entry becomes one, except an enum (`Enum`) and an - * array component — a schema that is an array and declares `items`, or an - * alias of one (a bare `$ref`, or an `allOf` of a `$ref` and limits) — which - * becomes no model at all: a `$ref` to it resolves to the array it declares - * (`{ kind: 'array' }`, under `nullable` when the component admits null), - * and what its items reference is extracted under `Item…`; the - * component's own `description`, `deprecated` and `externalDocs` are read - * by a field that names it with a bare `$ref` (`Field`). The inline schemas - * the parser materializes — request bodies, responses, union variants, - * nested objects, array items, map values — are models too. + * array component — a schema that is an array and declares `items` (a + * nullable `oneOf`/`anyOf` around one included), or an alias of one (a bare + * `$ref`, a nullable `oneOf`/`anyOf` around a `$ref`, or an `allOf` of a + * `$ref` and limits) — which becomes no model at all: a `$ref` to it + * resolves to the array it declares (`{ kind: 'array' }`, under `nullable` + * when the component admits null), and what its items reference is + * extracted under `Item…`; what the component and its aliases + * declare beside the items — text, link, flags, limits, `default`, `x-*` + * keys — is read by a field that names it with a bare `$ref` (`Field`). The + * inline schemas the parser materializes — request bodies, responses, union + * variants, nested objects, array items, map values — are models too. */ export interface Model { name: string; @@ -1091,8 +1093,9 @@ export interface Field { /** * The field schema's own `description`; for a bare `$ref` to an array * component — which names no model to carry the component's text — the - * component's, as `example` falls back to it. A `$ref` to a model, named - * union or enum leaves the text on it. + * nearest the reference passes on its way to the array (an alias's own, + * else the component's), as `example` falls back to it. A `$ref` to a + * model, named union or enum leaves the text on it. */ description?: string; /** @@ -1104,16 +1107,26 @@ export interface Field { * shape: two fields that differ only here are one shape. */ externalDocs?: ExternalDocs; + /** + * `true` when the field schema says `readOnly: true`, or when a bare `$ref` + * names an array component that says so, or an alias of it on the way + * (see `description`). `writeOnly` is read the same way. + */ readOnly?: boolean; writeOnly?: boolean; /** * Present, and `true`, when the field schema says `deprecated: true` — a * keyword written beside a `$ref` included — or when a bare `$ref` names - * an array component that says so, since no model carries that - * component's flag. The flag of a model, named union or enum a `$ref` - * names stays on it. + * an array component that says so, or an alias of it on the way, since no + * model carries that component's flag. The flag of a model, named union or + * enum a `$ref` names stays on it. */ deprecated?: boolean; + /** + * The field schema's own `default`, an explicit `null` included; for a + * bare `$ref` to an array component that declares none, the nearest the + * reference passes (see `description`). + */ default?: unknown; /** * A single illustrative value. The field schema's `example` when present, @@ -1131,9 +1144,12 @@ export interface Field { examples?: unknown[]; /** * Validation keywords declared on the field's schema, including any - * written beside a `$ref` (OpenAPI 3.1 allows siblings). Absent when the - * schema declares none; a bare `$ref` yields none, since the referenced - * model carries its own limits. + * written beside a `$ref` (OpenAPI 3.1 allows siblings). A `$ref` to a + * model, named union or enum adds none, since the referenced node carries + * its own limits; a `$ref` to an array component, which names no model, + * conjoins the component's limits and its aliases' with the field's own + * (`{ $ref: Tags, maxItems: 3 }` beside `Tags`'s `minItems: 1, maxItems: + * 5` is `{ minItems: 1, maxItems: 3 }`). Absent when no level declares any. */ constraints?: Constraints; /** @@ -1141,8 +1157,9 @@ export interface Field { * `x-enum-descriptions` / `x-enumDescriptions`, read like `constraints`: * keys written beside a `$ref` (legal in OAS 3.1) are the field's, a bare * `$ref` yields none — the referenced model, union or enum carries its - * own — unless it names an array component, whose keys the field then - * carries as it carries that component's `description`. A property + * own — unless it names an array component, whose keys and its aliases' + * the field then carries under its own, the field's winning per key, as + * it carries that component's `description`. A property * several `allOf` members always declare is typed by the first typing * declaration, keys included (a typeless one fills in what it leaves * unset); a property only variants declare carries exactly the keys diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index 61e38029..3e93d637 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -1033,7 +1033,13 @@ export function extractSchemas( if (isEnumComponentSchema(schema)) { activeEnumSchemaNames.add(resolved); if (isNullableEnumSchema(schema)) activeNullableEnumSchemaNames.add(resolved); - } else if (isNamedUnionSchema(schema) && isNullableUnionSchema(schema)) { + } else if ( + isNamedUnionSchema(schema) && + isNullableUnionSchema(schema) && + // A nullable wrapper around an array is no union but an array + // component, which a `$ref` names by its key. + !isArrayComponentEntry(schema, schemas) + ) { activeNullableUnionSchemaNames.add(resolved); } } @@ -2454,7 +2460,8 @@ export function variantMemberSchemas(variant: SchemaObject, variantName: string) * Build a single Field from a schema property entry. Shared across all * extraction sites. Pass `componentSchemas` where they are at hand so a * field whose schema is a bare `$ref` can take `example`/`examples` from the - * component it points at, as a parameter does. `siblingProperties` are the + * component it points at, as a parameter does, and what an array component + * declares beside its items through its aliases. `siblingProperties` are the * other properties of the object the field belongs to (`collectProperties`); * array items and map values use them to step aside from a sibling that * would take their singular name (`elementContextName`). @@ -2468,42 +2475,57 @@ export function buildFieldFromSchema( siblingProperties?: ReadonlyMap, ): Field { const refTarget = resolveComponentSchemaRef(fieldSchema, componentSchemas); - const { example, examples } = resolveExamples([ - { example: fieldSchema.example, examples: nonEmptyExamples(fieldSchema.examples) }, - { example: refTarget?.example, examples: nonEmptyExamples(refTarget?.examples) }, - ]); - // A bare `$ref` to an array component names no model that could carry the - // component's `description`, `deprecated` and `externalDocs`, so the field - // takes them when it declares none of its own, as it takes the component's - // `example`. A `$ref` to a model, named union or enum leaves them on it. - const alias = refTarget !== undefined && isArrayComponentRef(fieldSchema) ? refTarget : undefined; - const externalDocs = readExternalDocs(fieldSchema.externalDocs) ?? readExternalDocs(alias?.externalDocs); - const extensions = - extensionsOf(fieldSchema, SCHEMA_CONSUMED_EXTENSIONS) ?? - (alias ? extensionsOf(alias, SCHEMA_CONSUMED_EXTENSIONS) : undefined); + // A bare `$ref` to an array component names no model that could carry what + // the component declares beside its items, so the field reads that from + // every entry the reference passes on its way to the array — the entry it + // names, each alias in turn, the component itself (`arrayComponentEntries`) + // — under its own, the nearer entry's first, as the same array written + // inline carries it (#399). A `$ref` to a model, named union or enum leaves + // all of it on that node but the `example` and `examples` a field takes + // from any component it names. + const aliases = + refTarget && componentSchemas && isArrayComponentRef(fieldSchema) + ? (arrayComponentEntries(refTarget, componentSchemas) ?? [refTarget]) + : []; + const levels = [fieldSchema, ...aliases]; + const { example, examples } = resolveExamples( + [fieldSchema, ...(aliases.length > 0 ? aliases : [refTarget])].map((level) => ({ + example: level?.example, + examples: nonEmptyExamples(level?.examples), + })), + ); + const externalDocs = levels.map((level) => readExternalDocs(level.externalDocs)).find(Boolean); + // Limits conjoin, as JSON Schema applies every level at once; an `x-*` key + // the nearer level declares wins, as a collapsed wrapper's does + // (`withWrapperDocs`). Both start from the component, so its keys come first. + const constraints = levels.reduceRight( + (limits, level) => combineConstraints(limits, extractConstraints(level)), + undefined, + ); + const extensions = levels.reduceRight((keys, level) => { + const own = extensionsOf(level, SCHEMA_CONSUMED_EXTENSIONS); + return own ? { ...keys, ...own } : keys; + }, undefined); return { name: fieldName, type: schemaToTypeRef(fieldSchema, fieldName, contextName, siblingProperties), required: requiredSet.has(fieldName), - description: fieldSchema.description ?? alias?.description, + description: levels.map((level) => level.description).find((text) => text !== undefined && text !== null), // The field schema's own link, read like `description`: a sibling of a // `$ref` is the field's, and a bare `$ref` gives none — the referenced // model carries its own — unless the `$ref` names an array component. ...(externalDocs ? { externalDocs } : {}), - readOnly: fieldSchema.readOnly || undefined, - writeOnly: fieldSchema.writeOnly || undefined, - deprecated: fieldSchema.deprecated === true || alias?.deprecated === true || undefined, - default: fieldSchema.default, + readOnly: levels.some((level) => level.readOnly) || undefined, + writeOnly: levels.some((level) => level.writeOnly) || undefined, + deprecated: levels.some((level) => level.deprecated === true) || undefined, + default: levels.find((level) => level.default !== undefined)?.default, example, // Only materialise the key when there is something to carry so existing // inline snapshots of fields without examples stay byte-identical. ...(examples ? { examples } : {}), // Keywords written beside a `$ref` (legal in OAS 3.1) are the field's own; - // a bare `$ref` declares none, and the referenced model carries its limits. - ...constraintsEntry(fieldSchema), - // The schema's own `x-*` keys, read the same way; a `$ref` to an array - // component that declares none takes the component's, as it takes its - // `description`. + // a bare `$ref` to a model declares none, and the model carries its limits. + ...(constraints ? { constraints } : {}), ...(extensions ? { extensions } : {}), }; } @@ -3288,56 +3310,89 @@ function mintingSchema(variant: SchemaObject): SchemaObject { * True for a `components/schemas` entry the parser reads as an array * component: a schema `isArraySchema` accepts — `type: 'array'`, an OAS 3.1 * type list `['array', 'null']`, OAS 3.0 `nullable: true` beside `type: - * 'array'`, or `items` with no `type` — that declares `items`. An `enum:` - * list or a `oneOf`/`anyOf` beside the array is read first by + * 'array'`, or `items` with no `type` — that declares `items`, read with a + * nullable `oneOf`/`anyOf` wrapper removed (`unwrapNullableComponent`): + * `oneOf: [{ type: 'array', items }, { type: 'null' }]` is the nullable + * array everywhere else, so it is one here (#399). An `enum:` list or a + * `oneOf`/`anyOf` of other variants beside the array is read first by * `classifyInlineSchema`, so such a schema is an enum or a named union here - * as it is everywhere else. A `type: 'array'` with no `items` is not an array - * component: `schemaToTypeRef` types it as unknown, with a warning, so it - * keeps the fieldless model it always became rather than warning at every - * reference. + * as it is everywhere else. A `type: 'array'` with no `items` is not an + * array component: `schemaToTypeRef` types it as unknown, with a warning, so + * it keeps the fieldless model it always became rather than warning at + * every reference. */ export function isArrayComponentSchema(schema: SchemaObject): boolean { - return isArraySchema(schema) && schema.items !== undefined; + const bare = unwrapNullableComponent(schema); + return isArraySchema(bare) && bare.items !== undefined; +} + +/** + * A component schema with a nullable `oneOf`/`anyOf` wrapper removed, as + * `schemaToTypeRef` removes it (`unwrapNullableSchema`) — unless the + * component declares `properties` of its own: those keep the object model a + * component with `properties` beside a `oneOf`/`anyOf` always is + * (`isNamedUnionSchema`), and are only folded in (`foldSiblingProperties`). + */ +function unwrapNullableComponent(schema: SchemaObject): SchemaObject { + if (Object.keys(schema.properties ?? {}).length > 0) return foldSiblingProperties(schema); + return unwrapNullableSchema(schema); } /** * Whether a `components/schemas` entry is read as an array component: an * array schema itself (`isArrayComponentSchema`), or one `schemaToTypeRef` - * types as an array through the readings it makes of a `$ref` and an - * `allOf` — a bare `$ref` to one; an `allOf` read as a plain reference - * (`readInlineAllOf`: `$ref` members with nothing inline beside them — - * `allOf: [{ $ref: Pets }]`, `allOf: [{ $ref: Pets }, { maxItems: 10 }]`), - * read as its first `$ref` member; an `allOf` of one member, read as that - * member (`unwrappedAllOfMember`) — followed through aliases of aliases, so a + * types as an array through the readings it makes of a nullable wrapper, a + * `$ref` and an `allOf` — a bare `$ref` to one; a nullable `oneOf`/`anyOf` + * around one (`unwrapNullableComponent`: `oneOf: [{ $ref: Pets }, { type: + * 'null' }]`); an `allOf` read as a plain reference (`readInlineAllOf`: + * `$ref` members with nothing inline beside them — `allOf: [{ $ref: Pets }]`, + * `allOf: [{ $ref: Pets }, { maxItems: 10 }]`), read as its first `$ref` + * member; an `allOf` of one member, read as that member + * (`unwrappedAllOfMember`) — followed through aliases of aliases, so a * `$ref` to the alias resolves to the array exactly as the same schema - * written on a field does. `properties` written beside the composition are - * folded in first (`foldSiblingProperties`), as `schemaToTypeRef` folds - * them. A chain that returns to an entry it has visited names no array: - * such an entry stays the fieldless model it always became, as an alias of - * an object component does. + * written on a field does (`arrayComponentEntries`). + */ +export function isArrayComponentEntry(schema: SchemaObject, schemas: Record): boolean { + return arrayComponentEntries(schema, schemas) !== undefined; +} + +/** + * The `components/schemas` entries a reference to `schema` reads through to + * the array it is, nearest first: `schema` itself, each alias it names in + * turn, and last the entry that declares the array — `[PetsList, Pets]` for + * `PetsList: { allOf: [{ $ref: Pets }] }` — or `undefined` when `schema` is + * no array component (see `isArrayComponentEntry`). A field that names one + * with a bare `$ref` reads what each entry declares beside the array, since + * none of them is a model that could carry it (`buildFieldFromSchema`). + * `properties` written beside the composition are folded in first + * (`unwrapNullableComponent`), as `schemaToTypeRef` folds them. The lone + * member of an `allOf` is the + * entry's own declaration, not an entry of its own. A chain that returns to + * an entry it has visited names no array: such an entry stays the fieldless + * model it always became, as an alias of an object component does. */ -export function isArrayComponentEntry( +function arrayComponentEntries( schema: SchemaObject, schemas: Record, seen: Set = new Set(), -): boolean { - if (isArrayComponentSchema(schema)) return true; - if (seen.has(schema)) return false; +): SchemaObject[] | undefined { + if (isArrayComponentSchema(schema)) return [schema]; + if (seen.has(schema)) return undefined; seen.add(schema); - const folded = foldSiblingProperties(schema); - const aliasOf = (ref: string | undefined): boolean => { + const bare = unwrapNullableComponent(schema); + const aliasOf = (ref: string | undefined): SchemaObject[] | undefined => { const key = ref === undefined ? undefined : componentKeyOf(ref); const aliased = key === undefined ? undefined : schemas[key]; - return aliased !== undefined && isArrayComponentEntry(aliased, schemas, seen); + const entries = aliased === undefined ? undefined : arrayComponentEntries(aliased, schemas, seen); + return entries && [schema, ...entries]; }; - if (folded.$ref) return aliasOf(folded.$ref); - if (classifyInlineSchema(folded) !== 'allOf' || !folded.allOf) return false; - const reading = readInlineAllOf(folded.allOf, folded, schemas); - if (reading === 'reference') return aliasOf(folded.allOf.find((member) => member.$ref)?.$ref); - if (reading === 'unwrapped') { - return isArrayComponentEntry(unwrappedAllOfMember(folded, folded.allOf[0]), schemas, seen); - } - return false; + if (bare.$ref) return aliasOf(bare.$ref); + if (classifyInlineSchema(bare) !== 'allOf' || !bare.allOf) return undefined; + const reading = readInlineAllOf(bare.allOf, bare, schemas); + if (reading === 'reference') return aliasOf(bare.allOf.find((member) => member.$ref)?.$ref); + if (reading !== 'unwrapped') return undefined; + const entries = arrayComponentEntries(unwrappedAllOfMember(bare, bare.allOf[0]), schemas, seen); + return entries && [schema, ...entries.slice(1)]; } /** The `components/schemas` key a `$ref` names, or `undefined` for a `$ref` into another section of the document. */ diff --git a/test/parser/array-components.test.ts b/test/parser/array-components.test.ts index a9087c59..77174d26 100644 --- a/test/parser/array-components.test.ts +++ b/test/parser/array-components.test.ts @@ -17,7 +17,7 @@ import { collectResponseHeaders, collectWebhookTypeRefs, } from '../../src/ir/types.js'; -import type { ApiSpec, Operation, TypeRef } from '../../src/ir/types.js'; +import type { ApiSpec, Field, Operation, TypeRef } from '../../src/ir/types.js'; /** * Issue #284: a `components/schemas` entry whose schema is an array became a @@ -967,12 +967,16 @@ describe('every reference site', () => { deprecated: true, constraints: { minItems: 2, maxItems: 10 }, }); - // A field keeps its own text over the component's, takes the component's - // flag (there is no model to carry it; see the next describe), and a - // bare `$ref` declares no limits, as before. + // A field keeps its own text over the component's, and takes the + // component's flag and limits (there is no model to carry them; see the + // next describes): a bare `$ref` declared none before #399. const pets = spec.models.find((m) => m.name === 'Owner')?.fields[0]; - expect(pets).toMatchObject({ type: array(string), description: 'Theirs', deprecated: true }); - expect(pets).not.toHaveProperty('constraints'); + expect(pets).toMatchObject({ + type: array(string), + description: 'Theirs', + deprecated: true, + constraints: { minItems: 1, maxItems: 10 }, + }); }); }); @@ -1061,6 +1065,332 @@ describe('the component’s documentation and flag (MichaelDeBoey)', () => { }); }); +describe('what the component declares beside its items (#399)', () => { + // No model carries an array component's limits, flags, default or keys + // either, so a bare `$ref` field takes them as it takes the component's + // text: under its own, through every alias the reference passes. + const fieldOf = (spec: ApiSpec, name: string): Field | undefined => + spec.models.find((m) => m.name === 'Owner')?.fields.find((f) => f.name === name); + /** What a field says about its array beside the type. */ + const carried = (field: Field | undefined) => ({ + constraints: field?.constraints, + default: field?.default, + readOnly: field?.readOnly, + writeOnly: field?.writeOnly, + extensions: field?.extensions, + }); + + it('gives the field the component’s limits, flag and default, as the array written inline (issue repro 1)', async () => { + const tags = { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 5, + uniqueItems: true, + readOnly: true, + default: [], + }; + const spec = await parse( + specDoc( + { + Tags: tags, + Owner: { + type: 'object', + properties: { inline: tags, narrowed: { ...ref('Tags'), maxItems: 3 }, tags: ref('Tags') }, + }, + }, + { + '/owners': getOp('listOwners', ref('Owner'), { + parameters: [{ name: 'tags', in: 'query', schema: ref('Tags') }], + }), + }, + ), + ); + const limits = { minItems: 1, maxItems: 5, uniqueItems: true }; + expect(carried(fieldOf(spec, 'inline'))).toEqual({ constraints: limits, default: [], readOnly: true }); + expect(carried(fieldOf(spec, 'tags'))).toEqual(carried(fieldOf(spec, 'inline'))); + expect(carried(fieldOf(spec, 'narrowed'))).toEqual({ + constraints: { minItems: 1, maxItems: 3, uniqueItems: true }, + default: [], + readOnly: true, + }); + expect(opsOf(spec).listOwners.queryParams[0]).toMatchObject({ constraints: limits, default: [] }); + expect(spec.models.map((m) => m.name)).toEqual(['Owner']); + expect(warnings()).toEqual([]); + }); + + it('conjoins the limits written beside the `$ref`, the stricter winning, and keeps the field’s own default', async () => { + const spec = await parse( + specDoc({ + Tags: { + type: 'array', + items: { type: 'string' }, + minItems: 2, + maxItems: 5, + writeOnly: true, + default: ['a', 'b'], + }, + Owner: { + type: 'object', + properties: { + wider: { ...ref('Tags'), minItems: 1, maxItems: 10, default: [] }, + stricter: { ...ref('Tags'), minItems: 3, uniqueItems: true }, + unset: { ...ref('Tags'), default: null }, + }, + }, + }), + ); + expect(carried(fieldOf(spec, 'wider'))).toEqual({ + constraints: { minItems: 2, maxItems: 5 }, + default: [], + writeOnly: true, + }); + expect(carried(fieldOf(spec, 'stricter'))).toEqual({ + constraints: { minItems: 3, maxItems: 5, uniqueItems: true }, + default: ['a', 'b'], + writeOnly: true, + }); + // An explicit `null` is the field's own default, as `example` keeps one. + expect(fieldOf(spec, 'unset')).toHaveProperty('default', null); + }); + + it('merges the component’s `x-*` keys under the field’s own, as the array written inline carries them (MichaelDeBoey)', async () => { + const tags = { type: 'array', items: { type: 'string' }, maxItems: 5, 'x-since': '1.2' }; + const spec = await parse( + specDoc({ + Tags: tags, + Owner: { + type: 'object', + properties: { + tags: ref('Tags'), + narrowed: { ...ref('Tags'), 'x-internal': true }, + inline: { ...tags, 'x-internal': true }, + own: { ...ref('Tags'), 'x-since': '2.0' }, + }, + }, + }), + ); + const read = (name: string): string => { + const { constraints, extensions } = fieldOf(spec, name)!; + return JSON.stringify({ constraints, extensions }); + }; + expect(read('tags')).toBe('{"constraints":{"maxItems":5},"extensions":{"x-since":"1.2"}}'); + expect(read('inline')).toBe('{"constraints":{"maxItems":5},"extensions":{"x-since":"1.2","x-internal":true}}'); + expect(read('narrowed')).toBe(read('inline')); + expect(read('own')).toBe('{"constraints":{"maxItems":5},"extensions":{"x-since":"2.0"}}'); + }); + + it('reads the component through every alias the reference passes, an alias’s own declaration first (issue repro 2)', async () => { + const wrappedArray = { type: 'array', items: ref('Pet'), maxItems: 3 }; + const spec = await parse( + specDoc( + { + Pet: { type: 'object', properties: { name: { type: 'string' } } }, + Pets: { + type: 'array', + items: ref('Pet'), + description: 'The owner’s pets.', + deprecated: true, + externalDocs: { url: 'https://x/pets' }, + minItems: 1, + readOnly: true, + example: [{ name: 'Rex' }], + 'x-since': '1', + }, + PetsAlias: ref('Pets'), + PetsList: { allOf: [ref('Pets')] }, + Chain: ref('PetsList'), + Described: { + allOf: [ref('Pets'), { maxItems: 9 }], + description: 'Some pets', + 'x-since': '2', + 'x-owner': 'pets', + }, + // The lone member of an `allOf` is the entry's own declaration, not + // an entry: its text is not read, as a component model built from + // one does not read it, and its limits are read as written inline. + Nested: { allOf: [{ allOf: [ref('Pets')], description: 'The member’s text' }] }, + Wrapped: { allOf: [{ ...wrappedArray, description: 'The member’s text' }], minItems: 1 }, + Owner: { + type: 'object', + properties: { + alias: ref('PetsAlias'), + list: ref('PetsList'), + pets: ref('Pets'), + chain: ref('Chain'), + nested: ref('Nested'), + described: ref('Described'), + wrapped: ref('Wrapped'), + inline: { allOf: [{ ...wrappedArray, description: 'The member’s text' }], minItems: 1 }, + }, + }, + }, + { + '/owners/{id}': getOp('getOwner', ref('Owner'), { + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + }), + }, + ), + ); + expect(spec.models.map((m) => m.name).sort()).toEqual(['Owner', 'Pet']); + for (const name of ['alias', 'list', 'pets', 'chain', 'nested']) { + expect(fieldOf(spec, name)).toMatchObject({ + type: array(model('Pet')), + description: 'The owner’s pets.', + deprecated: true, + externalDocs: { url: 'https://x/pets' }, + readOnly: true, + example: [{ name: 'Rex' }], + constraints: { minItems: 1 }, + extensions: { 'x-since': '1' }, + }); + } + expect(fieldOf(spec, 'described')).toMatchObject({ + type: array(model('Pet')), + description: 'Some pets', + deprecated: true, + readOnly: true, + constraints: { minItems: 1, maxItems: 9 }, + extensions: { 'x-since': '2', 'x-owner': 'pets' }, + }); + const { name: _wrapped, ...wrapped } = fieldOf(spec, 'wrapped')!; + const { name: _inline, ...inline } = fieldOf(spec, 'inline')!; + expect(wrapped).toEqual(inline); + expect(wrapped).toMatchObject({ constraints: { minItems: 1, maxItems: 3 } }); + expect(wrapped.description).toBeUndefined(); + expect(warnings()).toEqual([]); + expectSound(spec, ['Owner', 'Pet'], ['Pets', 'PetsAlias', 'PetsList', 'Chain', 'Nested', 'Described', 'Wrapped']); + }); +}); + +describe('a component written as a nullable `oneOf`/`anyOf` (#399)', () => { + const pet = { type: 'object', properties: { name: { type: 'string' } } }; + const doc = (maybePets: unknown, schemas: Record = {}) => + specDoc( + { Pet: pet, MaybePets: maybePets, Owner: { type: 'object', properties: { pets: ref('MaybePets') } }, ...schemas }, + { + '/owners/{id}': getOp('getOwner', ref('Owner'), { + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + }), + '/pets': getOp( + 'listPets', + { + type: 'object', + properties: { + data: ref('MaybePets'), + list_metadata: { type: 'object', properties: { after: { type: 'string' } } }, + }, + }, + { parameters: [{ name: 'after', in: 'query', schema: { type: 'string' } }] }, + ), + }, + ); + + it.each([ + ['a oneOf', { oneOf: [{ type: 'array', items: ref('Pet') }, { type: 'null' }] }], + ['an anyOf, null first', { anyOf: [{ type: 'null' }, { type: 'array', items: ref('Pet') }] }], + ])( + 'is the nullable array it is when written as %s, as with a type list (issue repro 3)', + async (_form, maybePets) => { + const spec = await parse(doc(maybePets)); + expect(fieldType(spec, 'Owner', 'pets')).toEqual(nullable(array(model('Pet')))); + expect(opsOf(spec).listPets.response).toEqual(nullable(array(model('Pet')))); + expect(opsOf(spec).listPets.pagination).toMatchObject({ dataPath: 'data', itemType: model('Pet') }); + expect(spec.models.map((m) => m.name).sort()).toEqual(['ListMetadata', 'Owner', 'Pet']); + expect(warnings()).toEqual([]); + expectSound(spec, ['Owner', 'Pet'], ['MaybePets']); + + const typed = await parse(doc({ type: ['array', 'null'], items: ref('Pet') })); + expect(spec.models).toEqual(typed.models); + expect(opsOf(spec)).toEqual(opsOf(typed)); + }, + ); + + it('names its inline items from it, gives a field its text and limits, and reads a nullable `anyOf` around a `$ref` to one as an alias', async () => { + const spec = await parse( + doc( + { + oneOf: [{ type: 'array', items: pet }, { type: 'null' }], + description: 'Maybe some pets', + maxItems: 3, + }, + { + Pets: { type: 'array', items: ref('Pet'), description: 'Some pets' }, + MaybeAlias: { anyOf: [ref('Pets'), { type: 'null' }] }, + Holder: { type: 'object', properties: { maybe: ref('MaybePets'), alias: ref('MaybeAlias') } }, + }, + ), + ); + expect(fieldType(spec, 'Owner', 'pets')).toEqual(nullable(array(model('MaybePetsItem')))); + expect(spec.models.find((m) => m.name === 'Holder')?.fields).toMatchObject([ + { + name: 'maybe', + type: nullable(array(model('MaybePetsItem'))), + description: 'Maybe some pets', + constraints: { maxItems: 3 }, + }, + { name: 'alias', type: nullable(array(model('Pet'))), description: 'Some pets' }, + ]); + expect(spec.models.map((m) => m.name).sort()).toEqual(['Holder', 'ListMetadata', 'MaybePetsItem', 'Owner', 'Pet']); + expect(warnings()).toEqual([]); + expectSound(spec, ['Holder', 'Owner', 'Pet'], ['MaybePets', 'MaybeAlias', 'Pets']); + }); + + it.each([ + ['Note first', false], + ['Notes first', true], + ])('leaves the object of a key resolving to its name as it was, with %s', async (_order, reversed) => { + const entries: Array<[string, unknown]> = [ + ['Note', { type: 'object', properties: { text: { type: 'string' } } }], + ['Notes', { oneOf: [{ type: 'array', items: ref('Note') }, { type: 'null' }] }], + ]; + const spec = await parse( + specDoc({ + ...Object.fromEntries(reversed ? [...entries].reverse() : entries), + Holder: { type: 'object', properties: { one: ref('Note'), many: ref('Notes') } }, + }), + ); + expect(fieldType(spec, 'Holder', 'one')).toEqual(model('Note')); + expect(fieldType(spec, 'Holder', 'many')).toEqual(nullable(array(model('Note')))); + expect(spec.models.map((m) => m.name).sort()).toEqual(['Holder', 'Note']); + expect(warnings()).toEqual([]); + }); + + it('leaves a union of more than one non-null variant, and a nullable `oneOf` around an object component, the models they were', async () => { + const spec = await parse( + doc( + { oneOf: [{ type: 'array', items: ref('Pet') }, { type: 'integer' }, { type: 'null' }] }, + { + MaybePet: { oneOf: [ref('Pet'), { type: 'null' }] }, + Pets: { type: 'array', items: ref('Pet') }, + // `properties` beside a `oneOf`/`anyOf` keep a component the object model it always was. + WithOwn: { + properties: { own: { type: 'string' } }, + oneOf: [{ type: 'array', items: ref('Pet') }, { type: 'null' }], + }, + WithOwnRef: { properties: { own: { type: 'string' } }, anyOf: [ref('Pets'), { type: 'null' }] }, + Holder: { + type: 'object', + properties: { p: ref('MaybePet'), w: ref('WithOwn'), r: ref('WithOwnRef') }, + }, + }, + ), + ); + expect(fieldType(spec, 'Holder', 'w')).toEqual(model('WithOwn')); + expect(fieldType(spec, 'Holder', 'r')).toEqual(model('WithOwnRef')); + for (const name of ['WithOwn', 'WithOwnRef']) { + expect(spec.models.find((m) => m.name === name)?.fields.map((f) => f.name)).toEqual(['own']); + } + expect(fieldType(spec, 'Owner', 'pets')).toEqual(nullable(model('MaybePets'))); + expect(spec.models.find((m) => m.name === 'MaybePets')?.variants).toEqual([ + array(model('Pet')), + { kind: 'primitive', type: 'integer' }, + ]); + expect(fieldType(spec, 'Holder', 'p')).toEqual(nullable(model('MaybePet'))); + expect(spec.models.find((m) => m.name === 'MaybePet')?.variants).toEqual([model('Pet')]); + }); +}); + describe('two keys that resolve to one name (MichaelDeBoey)', () => { // `cleanSchemaName` singularizes the leading word, so `Note` and `Notes` // are both `Note` in the IR. A plan is keyed by the key a `$ref` names, @@ -1718,12 +2048,22 @@ describe('isArrayComponentSchema', () => { expect(isArrayComponentSchema({ type: 'array', nullable: true, items })).toBe(true); expect(isArrayComponentSchema({ type: 'array', items: {} })).toBe(true); expect(isArrayComponentSchema({ type: 'array', items, allOf: [{ minItems: 1 }] })).toBe(true); + // A `oneOf`/`anyOf` beside `items` is read first, and one with a `null` + // variant is the nullable array its other variant is (#399). + expect(isArrayComponentSchema({ items, oneOf: [{ type: 'array', items }, { type: 'null' }] })).toBe(true); + expect(isArrayComponentSchema({ oneOf: [{ type: 'array', items }, { type: 'null' }] })).toBe(true); + expect(isArrayComponentSchema({ anyOf: [{ type: 'null' }, { items }] })).toBe(true); }); it('rejects an array without items and everything that is not an array', () => { expect(isArrayComponentSchema({ type: 'array' })).toBe(false); expect(isArrayComponentSchema({ type: 'array', items, enum: ['a', 'b'] })).toBe(false); - expect(isArrayComponentSchema({ items, oneOf: [{ type: 'array', items }, { type: 'null' }] })).toBe(false); + expect(isArrayComponentSchema({ items, oneOf: [{ type: 'array', items }, { type: 'integer' }] })).toBe(false); + expect(isArrayComponentSchema({ oneOf: [{ type: 'array', items }, { type: 'integer' }, { type: 'null' }] })).toBe( + false, + ); + expect(isArrayComponentSchema({ oneOf: [{ type: 'array' }, { type: 'null' }] })).toBe(false); + expect(isArrayComponentSchema({ oneOf: [{ $ref: '#/components/schemas/Pets' }, { type: 'null' }] })).toBe(false); expect(isArrayComponentSchema({ items, allOf: [{ $ref: '#/components/schemas/List' }] })).toBe(false); expect(isArrayComponentSchema({ type: ['array', 'string'], items })).toBe(false); expect(isArrayComponentSchema({ type: 'object', properties: { items } })).toBe(false); From a6f993dd0a17769121d5d09d715874d1b53e02ee Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 14:52:36 -0400 Subject: [PATCH 53/59] fix(parser)!: step a union variant around names taken at any depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #319 a variant of an inline oneOf/anyOf that is not an inline object (a nested union, an allOf, a nullable object, an array or map) steps from the field's context to `_variant_`, and on to `…_value`, when a name it would mint there is claimed. Both sides of that check read one level. The claims held the object variants' names and what their own properties mint, but not what those models nest, and a candidate context was checked by the models the variant mints there, not by what their properties mint. In the issue's repro the nested `a` variant's `loc` and the object variant's `variant_2_loc` both became `GetPetResponseShapeVariant2Loc` and `{ lng }` was lost; with `variant: { 2: { … } }` instead, a grandchild, the nested `a` variant kept `GetPetResponseShapeVariant2` beside it and only a warning said so. mintedInlineNameEntries takes a `deep` flag that follows each inline model it mints (an inline object, a union's object variant, a merged allOf's own properties) with everything its properties mint under it, at any depth (mintedChildNameEntries). nonObjectVariantContexts claims everything under the object variants that way and checks each candidate context by every name the variant mints under it, so the names later variants step around cover the whole tree too. What a kept title's model nests is context-free like the title and never makes a variant step. The sibling readers still read the top level only. A deep read walks the whole tree, so the object variants' trees are read only once a variant that mints a name is decided (an object beside `enum: ['']` never needs them), each candidate is read once, and the union reader reuses the names the decision read. A chain of nine nested `anyOf: [object, enum: ['']]` parses in 0.1 s (0.4 s before), where the eager reads took 11 s. The issue's repro and control both give GetPetResponseShapeVariant2Value with GetPetResponseShapeVariant2ValueLoc { lng }, without a warning. The IR, warnings included, of every fixture spec, the github-subset example and four WorkOS specs is unchanged. BREAKING CHANGE: a non-object union variant whose models, at any depth, would take a name already taken at any depth under an object variant of the same union (or under an earlier variant) now steps to its next context. Its models are renamed (`GetPetResponseShapeVariant2` → `GetPetResponseShapeVariant2Value`), and the shape that was dropped is emitted. --- docs/architecture/ir-types.md | 2 +- src/parser/inline-models.ts | 65 +++++-- src/parser/schemas.ts | 69 +++++--- test/parser/nested-union-variants.test.ts | 196 ++++++++++++++++++++++ 4 files changed, 300 insertions(+), 32 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index f8d279c7..c4ca1a2d 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -169,7 +169,7 @@ A map's value schema is typed the same way an array's items are: with the owning Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. -Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context, so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and their children, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it, or an earlier variant of the union minted it; when the chosen context still mints a claimed name it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). +Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context — as is everything their models nest (`ALoc` for an inline `loc` of `A`) — so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and everything nested under them, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it at any depth (a `variant` object holding a `2` object mints `GetPetResponseShapeVariant2`, as a `variant_2` object does), or an earlier variant of the union minted it, at any depth too; when the chosen context still mints a claimed name — a model of the variant's own or one at any depth under it, so the `loc` of a nested variant `GetPetResponseShapeVariant2`, `GetPetResponseShapeVariant2Loc`, counts when the object variant's `variant_2_loc` takes that name — it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). **`properties` beside a composition.** JSON Schema applies every keyword of one schema at once, so an inline schema that writes `properties` beside an `allOf`, `oneOf` or `anyOf` that types it — `{ properties: { id }, allOf: [{ $ref: Pet }] }` is a `Pet` that also has `id`; `{ properties: { id }, oneOf: [pet, note] }` is one of `pet` or `note` with `id` in either case — is read as the composition carrying the properties, wherever it sits: a field, array items, map values, a request body, a response body, a list envelope's items, a parameter, a header, a webhook payload. An `allOf` gains the schema's own `properties` and `required` as its first member, so the augmentation of a `$ref` member (`HolderThing { id, name }`), a model merged from inline members, or a single member unwrapped carries them, the schema's own first. Each inline object variant of a `oneOf`/`anyOf` — `type: 'object'`, no `type`, or a type list naming `object`, so a nullable or multi-typed object variant included — gains them the same way (`HolderThing { id, name }`, `HolderThingWithText { id, text }`), the `required` naming the union of both lists. Where the variant declares the same property, the narrower declaration is kept, as JSON Schema applies both: the variant's when it pins the property's values with a `const` or an `enum` and the schema's own pins none, or pins those values and more — so `kind: { const: 'cat' }` beside an own `kind: { type: 'string' }` or `kind: { enum: [cat, dog] }` stays `'cat'`, the variants are named from their values (`HolderThing`, `DogHolderThing`) and a discriminated union keeps its mapping, while the own declaration's flags, documentation, limits and extensions (`readOnly`, `description`, `x-*`) stay the field's wherever the variant's sets none — and the schema's own otherwise, so an own `kind` enum beside a variant's plain `kind` stays the enum; the union keeps every variant. Every other variant is typed as written: a `$ref` names a component the parser never merges properties into; a primitive, array or enum is no object, so `properties` constrains nothing in it; a nested composition or a nullable union wrapper is left to its own variants. The parser warns once per union, with the field's context, for the variants that may be objects and did not take the properties (a `$ref`, an `allOf`, a nested union, a map, a nullable wrapper around an object): ``[oagen] Warning: `properties` beside `oneOf` are carried into its inline object variants only; variant 1 is typed as written, without them (context: GetThingResponse.mixed)``. An empty `properties: {}` declares nothing and is left as written: beside an `allOf` the schema is that `allOf` (`{ properties: {}, allOf: [{ $ref: Pet }] }` is `Pet` on a field and as a response body, as `{ allOf: [{ $ref: Pet }] }` is), beside a `oneOf` the union — never an empty model that drops the composition. An `allOf` or `oneOf` of bare limits beside `properties` does not type the schema, which stays the object it is with the limits conjoined (see Constraints). Where the composition is merged into one model — an `allOf` as a field, as array items, as a response body or as a component schema — earlier members win on a shared property name, the schema's own first — among the declarations that declare a type; a typeless one, a bare `description` say, never types the property and only fills in what the typing declaration leaves unset, wherever it sits; two typing declarations of inline objects conjoin their properties and `required`, an inline object and a `$ref` to an object component conjoin as the `allOf` of both, two of arrays combine their `items` by this same rule, as JSON Schema applies both — so an own `kind` enum beside a member's plain `kind` stays the enum at every site; a property is matched by its own name (`Object.hasOwn`), so a field named `constructor` or `toString` is kept like any other. Under `components.schemas`, a schema's own `properties` and `required` beside an `allOf` are its first member, read before the members as written: `Dog: { allOf: [{ $ref: Pet }], properties: { breed }, required: [breed] }` is `Dog { breed, name }`, with `breed` required, and a `$ref` member's component is read the same way — its own properties, then its members — so a chain (`Pup → Dog → Pet`) carries every level's own properties, an inline object or enum under any of them is materialized under the model that inherits it, and a `required` naming only inherited properties applies to them. A member's own `properties` and `required` beside its `oneOf`/`anyOf` — an inline member's, or those of the component a `$ref` member names — always apply, the union's variants flattened or dispatched after them. A discriminated `allOf` + `oneOf`/`anyOf` component — the union spelled with either keyword, every variant, a single one included, pinning one property to a distinct const — carries its own properties into the dispatcher model and into every variant model, as it carries a member's, and the variant models are built for the union that dispatches whichever keyword spells it, each named for its const — one of several words alone (`user.created` → `UserCreated`), one word after the component's name (`PetEvent`'s `cat` → `PetEventCat`, and `CatEvent`'s → `CatEventCat`), as is a const that spells the component's own name (`Cat`'s `cat` → `CatCat`), so a variant never takes the dispatcher's name; a union no discriminator dispatches — two of its variants named alike, or one whose const spells no name (`''`, `'-'`) — is flattened into the model and builds no variant model. A property declared twice keeps the first always-applying declaration that declares a type, the component's own first — a typeless declaration (`description` alone) never types it and only fills in what the typing one leaves unset, before or after it; two typing inline objects conjoin their properties and `required` into one declaration the component's model nests as its own (`PageResult { items, total_pages }` from `result` declared by two members), an inline object and a `$ref` to a component that declares properties (its own or its `allOf` chain's), or an `allOf` that reaches one, conjoin as the `allOf` of both, read as an inline `allOf` is (`result: { $ref: PetPage }` beside `result: { properties: { total_pages } }` is a `PageResult` with `PetPage`'s `items` and `total_pages`, whichever member comes first, `items` typed as `PetPage` types it, and the flags and documentation either declaration gives the field stay the field's), while a `$ref` to an array, enum, primitive or union component is no object to conjoin and the first declaration stays; two typing arrays combine their `items` by this same rule (`items: { description }` only annotates `items: { $ref: Pet }`, so `data` stays `Pet[]`) — whatever a `oneOf`/`anyOf` variant declares before or after it and at whatever depth — a declaration a nested member makes under a variant stays branch-only in every model that inherits it — so a component never carries two fields of one name; a property only variants declare takes the first variant's declaration (the union of distinct branch declarations an inline merge builds is not built for a component). That precedence is one rule (`declareProperty`, through `combineDeclarations`), shared with the inline merge, the dispatcher base and the list-envelope collector that reads a wrapper's companions and stop field, and the declaration it picks is the one every pass reads for the field (`componentPropertyLedger`): the field's type, the models nested under it and the sibling set its names are decided against all come from that declaration, never from one another member outranks. Every model a component field's type reaches — an inline object, array items, map values, a union's variants, under a nullable wrapper, at any depth — is built by the field extractor from that declaration under the component's name, an inherited declaration exactly as the component's own (`Puppy.meta`, inherited from `Dog`, is typed `PuppyMeta…` and built so), by the field pass that runs over every component (`extractComponentFieldModels`), so no field references a model nothing built. An `allOf` of bare limits beside a component's `properties` leaves the object its properties describe, with the limits conjoined. The component's model and an inline `allOf` merge that names it therefore carry the component's own and inherited properties in the same order (a union the component itself carries is the component's own to dispatch or flatten; the merge does not read it, see Inline `allOf`). A `oneOf` written beside `properties` under `components.schemas` keeps the object model (see Named unions). diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index c97b57e9..8904a043 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -102,12 +102,17 @@ export interface MintedInlineName { * same schema (`reservedNames`); the sibling-reservation readers leave it * unset and read the names in isolation, while `nonObjectVariantContexts` * passes a variant's own set so the names it records are the ones taken. + * `deep` follows each inline model's name with everything its own + * properties mint under it (`mintedChildNameEntries`), so the names are the + * whole tree the extractor builds: `nonObjectVariantContexts` reads them so, + * and the sibling readers read the top level only. */ export function mintedInlineNameEntries( contextName: string, schema: SchemaObject, parentModelName: string | undefined, reserved?: ReadonlySet, + deep = false, ): MintedInlineName[] { const s = unwrapNullableSchema(schema); // Dispatch on the one classification `schemaToTypeRef` uses, so the names @@ -115,9 +120,10 @@ export function mintedInlineNameEntries( const kind = classifyInlineSchema(s); if (kind === 'ref') return []; const base = toPascalCase(contextName); - const modelName = (): MintedInlineName[] => [ - { name: qualifyInlineModelName(base, parentModelName), contextFree: false }, - ]; + const withChildren = (entry: MintedInlineName, properties: SchemaObject['properties']): MintedInlineName[] => + deep ? [entry, ...mintedChildNameEntries(properties, entry.name, entry.contextFree)] : [entry]; + const modelName = (properties?: SchemaObject['properties']): MintedInlineName[] => + withChildren({ name: qualifyInlineModelName(base, parentModelName), contextFree: false }, properties); const enumName = (): MintedInlineName[] => { const cleanParent = parentModelName ? stripListItemMarkers(parentModelName) : undefined; return [ @@ -127,8 +133,14 @@ export function mintedInlineNameEntries( if (kind === 'allOf' && s.allOf) { // The one reading `schemaToTypeRef` makes of an `allOf` (`readInlineAllOf`). const reading = readInlineAllOf(s.allOf, s); - if (reading === 'unwrapped') return mintedInlineNameEntries(contextName, s.allOf[0]!, parentModelName, reserved); - return reading === 'merged' ? modelName() : []; + if (reading === 'unwrapped') { + return mintedInlineNameEntries(contextName, s.allOf[0]!, parentModelName, reserved, deep); + } + if (reading !== 'merged') return []; + // The merged model's own properties are its inline members' + // (`materializeMergedAllOf`); a `$ref` member's stay its component's. + const own = deep ? mergeAllOfMembers(s, undefined).properties : []; + return modelName(Object.fromEntries(own.map((p) => [p.name, p.schema]))); } const variants = kind === 'union' ? (s.oneOf ?? s.anyOf) : undefined; if (variants) { @@ -141,26 +153,57 @@ export function mintedInlineNameEntries( // context itself or `_variant_` for a nested union, a nullable // object, an `allOf` or an inline enum — stepping around the names the // entry reserves — so a sibling that would take one of those names steps - // around it. + // around it. Read `deep`, they are the names the decision already read + // under that context (`VariantContext.minted`). const objectVariantNames = new Map([...variantEntries].map(([variant, entry]) => [variant, entry.name])); const others = [ ...nonObjectVariantContexts(contextName, nonNull, undefined, parentModelName, objectVariantNames, reserved), ].flatMap(([variant, entry]) => - mintedInlineNameEntries(entry.context, unwrapNullableSchema(variant), parentModelName, entry.reserved), + deep + ? entry.minted + : mintedInlineNameEntries(entry.context, unwrapNullableSchema(variant), parentModelName, entry.reserved), ); - return [...[...variantEntries].map(([variant, entry]) => ({ ...entry, schema: variant })), ...others]; + return [ + ...[...variantEntries].flatMap(([variant, entry]) => + withChildren({ ...entry, schema: variant }, variant.properties), + ), + ...others, + ]; } if (kind === 'const') return []; if (kind === 'enum') return (s.enum ?? []).filter((v) => v !== null).length >= 2 ? enumName() : []; - if (kind === 'array' && s.items) return mintedInlineNameEntries(contextName, s.items, parentModelName, reserved); - if (kind === 'object') return modelName(); + if (kind === 'array' && s.items) { + return mintedInlineNameEntries(contextName, s.items, parentModelName, reserved, deep); + } + if (kind === 'object') return modelName(s.properties); if (kind === 'map') { const value = mapValueSchema(s); - return value ? mintedInlineNameEntries(contextName, value, parentModelName, reserved) : []; + return value ? mintedInlineNameEntries(contextName, value, parentModelName, reserved, deep) : []; } return []; } +/** + * Everything the properties of the inline model `modelName` mint under it, at + * any depth (`mintedInlineNameEntries`, `deep`), each property read in + * isolation: the models its extraction builds below it. The properties of a + * kept title's model (`contextFree`) are named from the title, so what they + * mint is context-free too. + */ +export function mintedChildNameEntries( + properties: SchemaObject['properties'], + modelName: string, + contextFree = false, +): MintedInlineName[] { + return Object.entries(properties ?? {}).flatMap(([property, schema]) => + schema + ? mintedInlineNameEntries(property, schema, modelName, undefined, true).map((child) => + contextFree ? { ...child, contextFree } : child, + ) + : [], + ); +} + /** * The inline names the other properties of an object mint * (`mintedInlineNameEntries`) which a `oneOf`/`anyOf` under `fieldName` must diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index 3e93d637..ec90142f 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -1990,10 +1990,15 @@ export function isArraySchema(schema: SchemaObject | undefined): boolean { * deferred body's registry already gives another shape — the same set the * object variants step around through `reservedNamesFor`), an inline object * variant of the union takes it (`ReqShape` — the reason a nested union's - * variants step), an object variant's own property mints it (`item` under - * `ReqShape` is `ReqShapeItem`), or an earlier variant of this map minted it. - * When the context chosen by kind still mints a claimed name, it steps on - * exactly as a plain array's items do beside such a sibling + * variants step), an object variant's own property mints it at any depth + * (`item` under `ReqShape` is `ReqShapeItem`; a `variant` object holding a + * `2` object under it mints `ReqShapeVariant2`, as a `variant_2` object + * would), or an earlier variant of this map minted it, at any depth too. + * When the context chosen by kind still mints a claimed name — a model of + * the variant's own, or one at any depth under it (the `loc` of a nested + * variant `ReqShapeVariant2` is `ReqShapeVariant2Loc`, which a + * `variant_2_loc` object of `ReqShape` takes) — it steps on exactly as a + * plain array's items do beside such a sibling * (`firstFreeElementContext`: `shapes_array_item`, `shapes_array_item2`, … * for an array; `shapes_variant_2_value`, `shapes_variant_2_member_value`, … * for the rest), so no two variants of one union share a model, and neither @@ -2008,7 +2013,7 @@ export function isArraySchema(schema: SchemaObject | undefined): boolean { * Each entry also carries the names the variant must step around when it is * typed and extracted under its context (`reserved`): everything claimed at * that point — the sibling-minted and caller-reserved names, the object - * variants and their children, every name an earlier variant minted — plus + * variants and all they nest, every name an earlier variant minted — plus * the context-free title an earlier variant kept for a different shape. Both * sides hand that set down as `reservedNames`, the set the enclosing field's * own variants step around through `reservedNamesFor`, so a nested @@ -2026,10 +2031,15 @@ export function isArraySchema(schema: SchemaObject | undefined): boolean { * sees are the names actually taken. * * `objectVariantNames` is the name each inline object variant's model takes - * (`nameInlineVariants`), as the caller decided it; those names, and the - * names the models' own properties mint, are claimed before any context is - * chosen. One level is enough: every name a child's children mint starts - * with the child's. + * (`nameInlineVariants`), as the caller decided it; those names, and every + * name the models' own properties mint at any depth + * (`mintedChildNameEntries`), are claimed before any variant that mints a + * name is decided. A variant is checked by every name it mints under a + * context, at any depth too (`mintedInlineNameEntries`, `deep`): a name on + * one side can equal one any number of levels down on the other, since + * `variant` and `2` join into the name `variant_2` gives. What a kept + * title's model nests is named from the title, so it is context-free like + * the title and never makes a variant step. */ export function nonObjectVariantContexts( contextName: string | undefined, @@ -2046,13 +2056,12 @@ export function nonObjectVariantContexts( if (siblingName === contextName) continue; for (const minted of mintedInlineNames(siblingName, siblingSchema, parentModelName)) claimed.add(minted); } - for (const [variant, variantName] of objectVariantNames) { - claimed.add(variantName); - for (const [property, propertySchema] of Object.entries(unwrapNullableSchema(variant).properties ?? {})) { - if (!propertySchema) continue; - for (const minted of mintedInlineNames(property, propertySchema, variantName)) claimed.add(minted); - } - } + for (const variantName of objectVariantNames.values()) claimed.add(variantName); + // What the object variants' own properties mint, at any depth, is read + // once the first variant that mints a name is decided: a deep read walks + // the whole tree, which a union whose other variants mint nothing (an + // object beside `enum: ['']`) never needs. + let childrenClaimed = false; // The context-free title each earlier variant kept, with the shape it // gave it: reserved for a later variant unless that variant gives the same // title the identical shape, which then shares the model. @@ -2065,14 +2074,31 @@ export function nonObjectVariantContexts( const member = unwrapNullableSchema(variant); const kind = classifyInlineSchema(member); if (kind === 'ref') continue; + const ownEntries = mintedInlineNameEntries(contextName, member, parentModelName); + if (ownEntries.length > 0 && !childrenClaimed) { + childrenClaimed = true; + for (const [objectVariant, variantName] of objectVariantNames) { + for (const child of mintedChildNameEntries(unwrapNullableSchema(objectVariant).properties, variantName)) { + claimed.add(child.name); + } + } + } const ownTitles = new Map(); - for (const entry of mintedInlineNameEntries(contextName, member, parentModelName)) { + for (const entry of ownEntries) { if (entry.contextFree && entry.schema) ownTitles.set(entry.name, schemaShapeKey(entry.schema)); } const reserved = new Set(claimed); for (const [title, shape] of keptTitles) if (ownTitles.get(title) !== shape) reserved.add(title); - const minted = (candidate: string): MintedInlineName[] => - mintedInlineNameEntries(candidate, member, parentModelName, reserved); + // Read once per candidate: the check, the step and the claims below share it. + const mintedUnder = new Map(); + const minted = (candidate: string): MintedInlineName[] => { + let names = mintedUnder.get(candidate); + if (!names) { + names = mintedInlineNameEntries(candidate, member, parentModelName, reserved, true); + mintedUnder.set(candidate, names); + } + return names; + }; const dependentNames = (candidate: string): string[] => minted(candidate) .filter((entry) => !entry.contextFree) @@ -2104,7 +2130,7 @@ export function nonObjectVariantContexts( ? stepped : firstFreeElementContext(stepped, 'value', reserved, dependentNames, under).context; } - contexts.set(variant, { context, reserved }); + contexts.set(variant, { context, reserved, minted: minted(context) }); for (const entry of minted(context)) { if (entry.contextFree && entry.schema) keptTitles.set(entry.name, schemaShapeKey(entry.schema)); else claimed.add(entry.name); @@ -2117,6 +2143,8 @@ export function nonObjectVariantContexts( export interface VariantContext { context: string; reserved: ReadonlySet; + /** Every name the variant mints under `context`, at any depth (`mintedInlineNameEntries`, `deep`). */ + minted: readonly MintedInlineName[]; } /** Which of its keywords types an inline schema; see `classifyInlineSchema`. */ @@ -5049,6 +5077,7 @@ import { agreedExtensions, withAgreedExtensions, type MintedInlineName, + mintedChildNameEntries, mintedInlineNameEntries, mintedInlineNames, modelFingerprint, diff --git a/test/parser/nested-union-variants.test.ts b/test/parser/nested-union-variants.test.ts index d369fd23..728c218a 100644 --- a/test/parser/nested-union-variants.test.ts +++ b/test/parser/nested-union-variants.test.ts @@ -488,6 +488,161 @@ describe('what counts as claimed, and how the context steps', () => { }); }); +describe('a name minted anywhere under an object variant, or anywhere under the variant, counts (#372)', () => { + // A nested `a` variant holding an inline `loc`, which mints `Loc`. + const aWithLoc = obj({ type: { const: 'a' }, loc: obj({ lng: str }) }); + /** The issue's `GET /pets/{id}`: the 200 body's `shape` is `{ m, …property }` beside `oneOf: [aWithLoc, b]`. */ + const getPet = (property: Record) => + specDoc({ + '/pets/{id}': { + get: { + operationId: 'getPet', + parameters: [{ name: 'id', in: 'path', required: true, schema: str }], + responses: { + '200': { + description: 'OK', + ...json(obj({ shape: { oneOf: [obj({ m: str, ...property }), { oneOf: [aWithLoc, b] }] } })), + }, + }, + }, + }, + }); + const shape = (spec: ApiSpec) => render(fieldType(spec, 'GetPetResponse', 'shape')!); + const stepped = '(GetPetResponseShape | (GetPetResponseShapeVariant2Value | BGetPetResponseShapeVariant2Value))'; + + it("the issue's repro: a nested variant's `loc` would take the name `variant_2_loc` mints, so the nested union steps on as it does beside `variant_2`", async () => { + const repro = await parse('repro', getPet({ variant_2_loc: obj({ z: str }) })); + const control = await parse('control', getPet({ variant_2: obj({ z: str }) })); + expect(shape(repro.spec)).toBe(stepped); + expect(shape(control.spec)).toBe(stepped); + expect(fieldsOf(repro.spec, 'GetPetResponseShape')).toEqual(['m', 'variant_2_loc']); + expect(fieldsOf(repro.spec, 'GetPetResponseShapeVariant2Loc')).toEqual(['z']); + for (const { spec, warnings } of [repro, control]) { + expect(fieldsOf(spec, 'GetPetResponseShapeVariant2Value')).toEqual(['type', 'loc']); + expect(render(fieldType(spec, 'GetPetResponseShapeVariant2Value', 'loc')!)).toBe( + 'GetPetResponseShapeVariant2ValueLoc', + ); + expect(fieldsOf(spec, 'GetPetResponseShapeVariant2ValueLoc')).toEqual(['lng']); + expect(fieldsOf(spec, 'BGetPetResponseShapeVariant2Value')).toEqual(['type', 'b']); + expect(warnings).toEqual([]); + expectSound(spec, []); + } + }); + + it("the issue's second case: an object variant's grandchild (`variant` → `2`) takes the nested variant's own name", async () => { + const { spec, warnings } = await parse('grandchild', getPet({ variant: obj({ '2': obj({ z: str }) }) })); + expect(shape(spec)).toBe(stepped); + expect(fieldsOf(spec, 'GetPetResponseShapeVariant')).toEqual(['2']); + expect(fieldsOf(spec, 'GetPetResponseShapeVariant2')).toEqual(['z']); + expect(fieldsOf(spec, 'GetPetResponseShapeVariant2Value')).toEqual(['type', 'loc']); + expect(fieldsOf(spec, 'GetPetResponseShapeVariant2ValueLoc')).toEqual(['lng']); + expect(warnings).toEqual([]); + expectSound(spec, []); + }); + + it('a nested name nothing claims moves nothing: the nested union keeps `_variant_`', async () => { + const { spec, warnings } = await parse('unclaimed', getPet({})); + expect(shape(spec)).toBe('(GetPetResponseShape | (GetPetResponseShapeVariant2 | BGetPetResponseShapeVariant2))'); + expect(fieldsOf(spec, 'GetPetResponseShapeVariant2Loc')).toEqual(['lng']); + expect(warnings).toEqual([]); + expectSound(spec, []); + }); + + const claimingChild = obj({ m: str, variant_2_loc: obj({ z: str }) }); + const cases: Array<{ + label: string; + schema: SchemaObject; + type: (base: string) => string; + models: (base: string) => Record; + }> = [ + { + label: "a nested union whose variant's child an object variant's child claims", + schema: { oneOf: [claimingChild, { oneOf: [aWithLoc, b] }] }, + type: (base) => `(${base} | (${base}Variant2Value | B${base}Variant2Value))`, + models: (base) => ({ + [base]: ['m', 'variant_2_loc'], + [`${base}Variant2Loc`]: ['z'], + [`${base}Variant2Value`]: ['type', 'loc'], + [`${base}Variant2ValueLoc`]: ['lng'], + [`B${base}Variant2Value`]: ['type', 'b'], + }), + }, + { + label: "a nested union whose variant an object variant's grandchild claims", + schema: { oneOf: [obj({ m: str, variant: obj({ '2': obj({ z: str }) }) }), { oneOf: [aWithLoc, b] }] }, + type: (base) => `(${base} | (${base}Variant2Value | B${base}Variant2Value))`, + models: (base) => ({ + [`${base}Variant2`]: ['z'], + [`${base}Variant2Value`]: ['type', 'loc'], + [`${base}Variant2ValueLoc`]: ['lng'], + }), + }, + { + label: "an allOf whose own property's model an object variant's child claims", + schema: { + oneOf: [claimingChild, { allOf: [{ $ref: '#/components/schemas/Base' }, obj({ loc: obj({ lng: str }) })] }], + }, + type: (base) => `(${base} | ${base}Variant2Value)`, + models: (base) => ({ + [`${base}Variant2Loc`]: ['z'], + [`${base}Variant2Value`]: ['id', 'loc'], + [`${base}Variant2ValueLoc`]: ['lng'], + }), + }, + { + label: "a nullable object whose child an object variant's child claims", + schema: { oneOf: [claimingChild, { oneOf: [obj({ loc: obj({ lng: str }) }), { type: 'null' }] }] }, + type: (base) => `(${base} | ${base}Variant2Value?)`, + models: (base) => ({ + [`${base}Variant2Loc`]: ['z'], + [`${base}Variant2Value`]: ['loc'], + [`${base}Variant2ValueLoc`]: ['lng'], + }), + }, + { + label: "an array whose items' child an object variant's child claims", + schema: { + oneOf: [obj({ m: str, item_loc: obj({ z: str }) }), { type: 'array', items: obj({ loc: obj({ lng: str }) }) }], + }, + type: (base) => `(${base} | ${base}ArrayItem[])`, + models: (base) => ({ + [`${base}ItemLoc`]: ['z'], + [`${base}ArrayItem`]: ['loc'], + [`${base}ArrayItemLoc`]: ['lng'], + }), + }, + { + label: "a later nested union whose variant an earlier nested union's child claims", + schema: { + oneOf: [ + str, + { oneOf: [obj({ type: { const: 'a' }, variant_3: obj({ z: str }) }), b] }, + { oneOf: [obj({ c: str }), obj({ d: str })] }, + ], + }, + type: (base) => `(string | (${base} | B${base}) | (${base}Variant3Value | ${base}Variant3ValueWithD))`, + models: (base) => ({ + [base]: ['type', 'variant_3'], + [`${base}Variant3`]: ['z'], + [`${base}Variant3Value`]: ['c'], + [`${base}Variant3ValueWithD`]: ['d'], + }), + }, + ]; + + it.each(cases)('$label, at every site', async ({ label, schema, type, models }) => { + const { spec, warnings } = await parse(label.replace(/\W+/g, '-'), everySite(schema)); + expect(siteTypes(spec)).toEqual( + Object.fromEntries(Object.entries(siteBases).map(([site, base]) => [site, type(base)])), + ); + for (const base of Object.values(siteBases)) { + for (const [name, fields] of Object.entries(models(base))) expect(fieldsOf(spec, name)).toEqual(fields); + } + expect(warnings).toEqual([]); + expectSound(spec, everySiteComponents); + }); +}); + describe('a request body `oneOf`', () => { const post = (schema: SchemaObject) => specDoc( @@ -945,6 +1100,47 @@ describe('nonObjectVariantContexts', () => { expect(entries.get(sameShape)!.reserved.has('B')).toBe(true); }); + it('reads every name under an object variant and under a variant, at any depth (#372)', () => { + const withChild = obj({ m: str, variant_2_loc: obj({ z: str }) }); + const withGrandchild = obj({ m: str, variant: obj({ '2': obj({ z: str }) }) }); + const nestedWithLoc: SchemaObject = { oneOf: [obj({ type: { const: 'a' }, loc: obj({ lng: str }) }), b] }; + const child = nonObjectVariantContexts( + 'shape', + [withChild, nestedWithLoc], + undefined, + 'Thing', + new Map([[withChild, 'ThingShape']]), + ); + // `ThingShapeVariant2Loc` is both `variant_2_loc`'s model and the `a` variant's `loc` under `shape_variant_2`. + expect(contextsOf(child)).toEqual([[nestedWithLoc, 'shape_variant_2_value']]); + expect([...child.get(nestedWithLoc)!.reserved].sort()).toEqual(['ThingShape', 'ThingShapeVariant2Loc']); + const grandchild = nonObjectVariantContexts( + 'shape', + [withGrandchild, nested], + undefined, + 'Thing', + new Map([[withGrandchild, 'ThingShape']]), + ); + expect(contextsOf(grandchild)).toEqual([[nested, 'shape_variant_2_value']]); + expect([...grandchild.get(nested)!.reserved].sort()).toEqual([ + 'ThingShape', + 'ThingShapeVariant', + 'ThingShapeVariant2', + ]); + }); + + it("never steps for what a kept title's own properties mint: those names are context-free like the title (#372)", () => { + const titledWithLoc = obj({ kind: { const: 'a' }, loc: obj({ lng: str }) }, { title: 'A' }); + const first: SchemaObject = { oneOf: [titledWithLoc, titledB] }; + const again: SchemaObject = { oneOf: [titledWithLoc, titledB] }; + const entries = nonObjectVariantContexts('shape', [str, first, again], undefined, 'Thing', new Map()); + expect(contextsOf(entries)).toEqual([ + [first, 'shape'], + [again, 'shape'], + ]); + expect(entries.get(again)!.reserved.has('ALoc')).toBe(true); + }); + it('decides nothing without a context name', () => { expect(nonObjectVariantContexts(undefined, [nested], undefined, 'Thing', new Map()).size).toBe(0); }); From e79da42125e326abe6d5de2ec01ff40fce5a0701 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 15:00:36 -0400 Subject: [PATCH 54/59] fix(parser): merge an allOf variant with its components when reading its names The deep read of a merged allOf read its own properties without following its `$ref` members. A property a member's component declares beside an inline member's (`Base.loc` and `{ loc: { lng } }`) conjoins into the merged model's own, so what the component's declaration nests (`extra`) is minted under the merged model too (`HolderShapeVariant2LocExtra`). Unread, it let the variant keep a context whose names an object variant's child already takes, and `{ x }` was lost. The read now merges with the document's components (documentComponentSchemas, the map componentSchemaOf already falls back to) and keeps the properties the merge marks as the model's own, the ones materializeMergedAllOf re-enters; a property only the component declares stays typed under it (`BaseLoc`) and claims nothing. --- src/parser/inline-models.ts | 9 ++-- src/parser/schemas.ts | 5 ++ test/parser/nested-union-variants.test.ts | 57 +++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index 8904a043..a2443a18 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -7,6 +7,7 @@ import { classifyInlineSchema, componentPropertyLedger, componentSchemaOf, + documentComponentSchemas, declaredComponentProperty, nonObjectVariantContexts, constraintsEntry, @@ -137,9 +138,11 @@ export function mintedInlineNameEntries( return mintedInlineNameEntries(contextName, s.allOf[0]!, parentModelName, reserved, deep); } if (reading !== 'merged') return []; - // The merged model's own properties are its inline members' - // (`materializeMergedAllOf`); a `$ref` member's stay its component's. - const own = deep ? mergeAllOfMembers(s, undefined).properties : []; + // The merged model's own properties, as `materializeMergedAllOf` re-enters + // them: a `$ref` member's stay its component's, unless an inline member + // declares the property too and the two conjoin. + const merged = deep ? mergeAllOfMembers(s, documentComponentSchemas()).properties : []; + const own = merged.filter((p) => p.origin === null); return modelName(Object.fromEntries(own.map((p) => [p.name, p.schema]))); } const variants = kind === 'union' ? (s.oneOf ?? s.anyOf) : undefined; diff --git a/src/parser/schemas.ts b/src/parser/schemas.ts index ec90142f..b7d7648c 100644 --- a/src/parser/schemas.ts +++ b/src/parser/schemas.ts @@ -52,6 +52,11 @@ export function componentSchemaOf( return map && Object.hasOwn(map, key) ? map[key] : undefined; } +/** The document's `components/schemas` while they are read (`activeComponentSchemas`), for a reader that merges an `allOf` as the extractors do. */ +export function documentComponentSchemas(): Record | undefined { + return activeComponentSchemas; +} + /** * Module-level set of resolved schema names whose components/schemas entry is * an enum (`isEnumComponentSchema`: an `enum:` list, or a `oneOf`/`anyOf` of diff --git a/test/parser/nested-union-variants.test.ts b/test/parser/nested-union-variants.test.ts index 728c218a..f7d7ea3c 100644 --- a/test/parser/nested-union-variants.test.ts +++ b/test/parser/nested-union-variants.test.ts @@ -641,6 +641,63 @@ describe('a name minted anywhere under an object variant, or anywhere under the expect(warnings).toEqual([]); expectSound(spec, everySiteComponents); }); + + it("an allOf's property a `$ref` member's component declares too is the merged model's own, and so is what it nests (Greptile round 1)", async () => { + // `Base.loc` and the inline member's `loc` conjoin into the merged + // model's own `{ extra, lng }`, so its `extra` mints + // `HolderShapeVariant2LocExtra`, which `variant_2_loc_extra` takes. + const { spec, warnings } = await parse( + 'allof-overlap', + specDoc( + {}, + { + Base: obj({ loc: obj({ extra: obj({ x: str }) }) }), + Holder: obj({ + shape: { + oneOf: [ + obj({ m: str, variant_2_loc_extra: obj({ z: str }) }), + { allOf: [{ $ref: '#/components/schemas/Base' }, obj({ loc: obj({ lng: str }) })] }, + ], + }, + }), + }, + ), + ); + expect(render(fieldType(spec, 'Holder', 'shape')!)).toBe('(HolderShape | HolderShapeVariant2Value)'); + expect(fieldsOf(spec, 'HolderShapeVariant2LocExtra')).toEqual(['z']); + expect(fieldsOf(spec, 'HolderShapeVariant2ValueLoc')).toEqual(['extra', 'lng']); + expect(fieldsOf(spec, 'HolderShapeVariant2ValueLocExtra')).toEqual(['x']); + expect(fieldsOf(spec, 'BaseLocExtra')).toEqual(['x']); + expect(warnings).toEqual([]); + expectSound(spec, ['Base', 'Holder']); + }); + + it("an allOf's property only its `$ref` member's component declares stays the component's, so it moves nothing (Greptile round 1)", async () => { + // `loc` is inherited and typed `BaseLoc`, so nothing under the merged + // model is named `HolderShapeVariant2Loc` and the variant keeps its context. + const { spec, warnings } = await parse( + 'allof-inherited', + specDoc( + {}, + { + Base: obj({ loc: obj({ x: str }) }), + Holder: obj({ + shape: { + oneOf: [ + obj({ m: str, variant_2_loc: obj({ z: str }) }), + { allOf: [{ $ref: '#/components/schemas/Base' }, obj({ id: str })] }, + ], + }, + }), + }, + ), + ); + expect(render(fieldType(spec, 'Holder', 'shape')!)).toBe('(HolderShape | HolderShapeVariant2)'); + expect(render(fieldType(spec, 'HolderShapeVariant2', 'loc')!)).toBe('BaseLoc'); + expect(fieldsOf(spec, 'HolderShapeVariant2Loc')).toEqual(['z']); + expect(warnings).toEqual([]); + expectSound(spec, ['Base', 'Holder']); + }); }); describe('a request body `oneOf`', () => { From b7125e10c55edcb79b4e3b147675662650d6bcea Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 15:13:57 -0400 Subject: [PATCH 55/59] fix(parser)!: contest a title only among unions the parser types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #320 a title two shapes take under one root is contested, and every variant carrying it takes its context-qualified name. The walk that finds the clashes followed every keyword schemaShapeKey compares, including the ones the parser never types: `not`, `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, `propertyNames`, `additionalItems` and `unevaluated*`. A union found only there never becomes a model, yet it contested the title, so adding an unread constraint renamed the typed union's variants (`Circle` became `CircleGetDrawingResponseShape`). markContestedTitlesUnderRoot now walks only the keywords the parser types a schema through: `properties`, `patternProperties`, `additionalProperties`, `items` and `allOf`/`oneOf`/`anyOf`, each with the claim group it had. schemaShapeKey still compares every keyword, so two shapes that differ only under `not` stay two shapes. The IR, warnings included, of every fixture spec, the github-subset example and four WorkOS specs is unchanged. BREAKING CHANGE: a titled union variant whose title only a union under a keyword the parser never types contested keeps its bare title again (`CircleGetDrawingResponseShape` → `Circle`), and the discriminator mapping follows. --- docs/architecture/ir-types.md | 2 +- src/parser/inline-models.ts | 34 ++++++++------ test/parser/inline-models.test.ts | 73 +++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index c4ca1a2d..8eb44d86 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -167,7 +167,7 @@ The optional `keyType` allows non-string map keys (e.g., enum-keyed maps). When A map's value schema is typed the same way an array's items are: with the owning field's name and parent model. When the value is an inline object (or an array or `oneOf` of inline objects), the parser extracts it as a model named by the parent plus the singularized field name, e.g. `layers` on `CreateDrawingRequest` → `CreateDrawingRequestLayer`, matching `shapes` → `CreateDrawingRequestShape` for array items. `additionalProperties: {}` or `true` keeps the value type `unknown`. The singular form is taken only when no sibling property of the same object would itself mint that name — an inline object, an inline enum, an inline union (the names of its object variants and whatever its other variants mint, see below), or an array or map of such (a primitive `email` beside `emails` claims nothing); otherwise array items are `Item` (`DrawingLayersItem` beside a `layer` object with different fields) and map values `Value`; if a sibling claims that name too (a property `layers_item`), the fallback continues to `ArrayItem`, then `ArrayItem2`, and so on (`MemberValue`, `…MemberValue2` for maps), taking the first name no sibling mints. One deliberate exception: when the claiming sibling is an inline object structurally identical to the items or values (same properties, required, types, nested inline schemas and enum values; `description`, `title` and `example` ignored), both fields share one model under the singular name, since one type is right for both and a duplicate would only add noise; the split happens only once the shapes diverge. The choice reads sibling names and schema content only, so document order plays no part. -Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. +Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first; only the unions the parser types count, those reached through `properties`, `patternProperties`, `additionalProperties`, `items` and `allOf`/`oneOf`/`anyOf`, so a union under `not`, `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, `propertyNames`, `unevaluatedProperties` or any other keyword never becomes a model and contests no title; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context — as is everything their models nest (`ALoc` for an inline `loc` of `A`) — so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and everything nested under them, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it at any depth (a `variant` object holding a `2` object mints `GetPetResponseShapeVariant2`, as a `variant_2` object does), or an earlier variant of the union minted it, at any depth too; when the chosen context still mints a claimed name — a model of the variant's own or one at any depth under it, so the `loc` of a nested variant `GetPetResponseShapeVariant2`, `GetPetResponseShapeVariant2Loc`, counts when the object variant's `variant_2_loc` takes that name — it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index a2443a18..ab20d5e2 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -766,12 +766,15 @@ export function isContestedTitle(variant: SchemaObject): boolean { * the roots' models decide it (the first owner keeps the name, and a later * shape is renamed or dropped with a warning). * - * Within a root, the walk follows the schema keywords only — `properties` - * and the other name-to-schema maps, `items`, `additionalProperties`, - * `allOf`/`oneOf`/`anyOf` and the rest of `schemaShapeKey`'s structural - * keywords — so a property named `schema` or `title` is a property, and a - * `$ref` is a reference to a component read under the components root, not - * descended into. The roots are found by following the OpenAPI object model + * Within a root, the walk follows the keywords the parser types a schema + * through only — `properties`, `patternProperties`, `additionalProperties`, + * `items` and `allOf`/`oneOf`/`anyOf` — so a property named `schema` or + * `title` is a property, a `$ref` is a reference to a component read under + * the components root, not descended into, and a union under `not`, + * `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, + * `prefixItems`, `propertyNames` or another keyword the parser never reads + * never becomes a model, so it contests no title (`schemaShapeKey` still + * compares them). The roots are found by following the OpenAPI object model * (`schemaRootsOf`), the way the operation, response and webhook readers * do, so a name is never mistaken for a specification extension: a header * named `x-meta`, a component named `x-thing`, a media type `x-custom/json` @@ -981,18 +984,21 @@ function markContestedTitlesUnderRoot(roots: readonly SchemaObject[]): void { entry.variants.push(foldedVariantOrigin(variant) ?? variant); byTitle.set(title, entry); } - // The group continues through what `mintedInlineNameEntries` reads for a - // union: its variants, an array's items, a map's values (whether under - // `additionalProperties` or `patternProperties`), and the one member of a - // lone `allOf`. `properties` and every other keyword begin a new group. + // Only the keywords the parser types a schema through are walked: a union + // under `not`, `if`, `$defs` or any other never becomes a model, so it + // contests nothing. The group continues through what + // `mintedInlineNameEntries` reads for a union: its variants, an array's + // items, a map's values (whether under `additionalProperties` or + // `patternProperties`), and the one member of a lone `allOf`. + // `properties` and a longer `allOf` begin a new group. ancestors.add(schema); for (const [keyword, value] of Object.entries(schema)) { - if (NAMED_SCHEMA_MAPS.has(keyword) && isRecord(value)) { + if ((keyword === 'properties' || keyword === 'patternProperties') && isRecord(value)) { const inherited = keyword === 'patternProperties' ? claims : undefined; for (const sub of Object.values(value)) if (isSchemaRecord(sub)) walk(sub, inherited); - } else if (SCHEMA_KEYWORDS.has(keyword) && isSchemaRecord(value)) { - walk(value, keyword === 'items' || keyword === 'additionalProperties' ? claims : undefined); - } else if (SCHEMA_LIST_KEYWORDS.has(keyword) && Array.isArray(value)) { + } else if ((keyword === 'items' || keyword === 'additionalProperties') && isSchemaRecord(value)) { + walk(value, claims); + } else if ((keyword === 'allOf' || keyword === 'oneOf' || keyword === 'anyOf') && Array.isArray(value)) { const inherited = keyword === 'oneOf' || keyword === 'anyOf' || (keyword === 'allOf' && value.length === 1) ? claims diff --git a/test/parser/inline-models.test.ts b/test/parser/inline-models.test.ts index a1363a61..2294f5d5 100644 --- a/test/parser/inline-models.test.ts +++ b/test/parser/inline-models.test.ts @@ -4055,6 +4055,52 @@ describe('a title two unions give different shapes at different depths of one ro expectConsistent(ir, ['Drawing']); }); + /** Every keyword holding a schema that the parser never types, each wrapping `schema` the way it takes one (#375). */ + const untypedKeywords: { keyword: string; under: (schema: SchemaObject) => SchemaObject }[] = [ + ...[ + 'not', + 'if', + 'then', + 'else', + 'contains', + 'propertyNames', + 'additionalItems', + 'unevaluatedProperties', + 'unevaluatedItems', + ].map((keyword) => ({ keyword, under: (schema: SchemaObject) => ({ [keyword]: schema }) })), + { keyword: 'prefixItems', under: (schema) => ({ prefixItems: [schema] }) }, + ...['$defs', 'definitions', 'dependentSchemas'].map((keyword) => ({ + keyword, + under: (schema: SchemaObject) => ({ [keyword]: { other: schema } }), + })), + ]; + + it.each(untypedKeywords)( + 'a union under $keyword, which the parser never types, contests no title (#375)', + async ({ under }) => { + // The issue's response: `shape` beside a union of other shapes that + // only the keyword holds. That union never becomes a model, so `shape` + // keeps `Circle` and `Square` as it does without it, in a response root + // and in the components root alike. + const drawing = obj({ id: { type: 'string' }, shape: byRadius() }, under(obj({ other: byDiameter() }))); + for (const [spec, root] of [ + [ + doc(op('getDrawing', { responses: { '200': { description: 'ok', ...json(drawing) } } })), + 'GetDrawingResponse', + ], + [doc({ paths: {}, components: { schemas: { Drawing: drawing } } }), 'Drawing'], + ] as const) { + const ir = await parseWithoutWarnings(spec); + const types = fieldTypesByModel(ir.models); + expect(types[root]).toEqual({ id: { kind: 'primitive', type: 'string' }, shape: unionOf('Circle', 'Square') }); + expect(types['Circle']).toEqual({ ...kind('circle'), radius: num }); + expect(types['Square']).toEqual({ ...kind('square'), side: num }); + expect(modelNames(ir)).toEqual(['Circle', root, 'Square']); + expectConsistent(ir, [root]); + } + }, + ); + it('reads every component schema as one root, since their inline models are collected together', async () => { const ir = await parseWithoutWarnings( doc({ @@ -4197,6 +4243,33 @@ describe('a title two unions give different shapes at different depths of one ro expect(variantsOf(inExtension).map(isContestedTitle)).toEqual([false, false]); }); + /** Every keyword the parser types a schema through, each wrapping `schema` the way it takes one. */ + const typedKeywords: typeof untypedKeywords = [ + { keyword: 'properties', under: (schema) => obj({ marker: schema }) }, + { keyword: 'patternProperties', under: (schema) => ({ patternProperties: { '^m': schema } }) }, + { keyword: 'additionalProperties', under: (schema) => ({ additionalProperties: schema }) }, + { keyword: 'items', under: (schema) => ({ type: 'array', items: schema }) }, + { keyword: 'allOf', under: (schema) => ({ allOf: [schema] }) }, + { keyword: 'oneOf', under: (schema) => ({ oneOf: [schema, n] }) }, + { keyword: 'anyOf', under: (schema) => ({ anyOf: [schema, n] }) }, + ]; + + it.each([ + ...typedKeywords.map((entry) => ({ ...entry, contested: true })), + ...untypedKeywords.map((entry) => ({ ...entry, contested: false })), + ])( + 'reads a union under $keyword only if the parser types a schema there: contested $contested (#375)', + ({ under, contested }) => { + // A union of other shapes beside `shape`, under the keyword: one the + // parser never types never becomes a model, so it contests nothing. + const top = byRadius(); + const inner = byDiameter(); + markContestedTitles({ components: { schemas: { Drawing: obj({ shape: top, holder: under(inner) }) } } }); + expect(variantsOf(top).map(isContestedTitle)).toEqual([contested, contested]); + expect(variantsOf(inner).map(isContestedTitle)).toEqual([contested, contested]); + }, + ); + it('does not descend into a `$ref`, and ignores a union that is not title-mapped', () => { const component = byRadius(); const untitled: SchemaObject = { From 5e8230e9f6c35b81c3d4bd4d8020a69ad811c4c1 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 15:30:15 -0400 Subject: [PATCH 56/59] fix(parser)!: qualify a later variant that repeats a contested title Since #320 a title two shapes take under one root is contested, and every variant carrying it takes its context-qualified name. The walk that decides this, markContestedTitlesUnderRoot, read only the first variant per title in a claim group (a union and the nested unions, array items, map values and lone allOf member it reaches), assuming a later one is stepped off the title by its own union anyway. That holds only while the first keeps the title. Once the first is contested it takes a qualified name and the later one keeps the bare title, so two such unions in one root put their later variants of two shapes on one model, with a "minted twice" warning. The walk now records a later variant under its title too, without comparing its shape: only the group's first variant per title is a claim, so two variants of one union that give a title two shapes still contest nothing on their own. When the title is contested, the later variant is marked with the rest, and nameInlineVariantEntries names it through nameUndiscriminatedVariant, which steps around the name the first took (`DrawingShapeWithDiameter` beside `CircleDrawingShape`). The issue's repeat.json now gives four models (CircleDrawingShape, DrawingShapeWithDiameter, CircleDrawingMarkerShape and DrawingMarkerShapeWithZ) without a warning, and its request-body and array-items cases do the same. The IR, warnings included, of every fixture spec, the github-subset example and four WorkOS specs is unchanged. BREAKING CHANGE: a later union variant that repeats a title contested under its root no longer keeps the bare title. It takes its context-qualified name (`Circle` becomes `DrawingShapeWithDiameter`, or `CircleDrawingShapeItem` among an array variant's items), and the discriminator mapping follows. --- docs/architecture/ir-types.md | 2 +- src/parser/inline-models.ts | 61 ++++++---- test/parser/inline-models.test.ts | 192 +++++++++++++++++++++++++++++- 3 files changed, 225 insertions(+), 30 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 8eb44d86..98fc2df5 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -167,7 +167,7 @@ The optional `keyType` allows non-string map keys (e.g., enum-keyed maps). When A map's value schema is typed the same way an array's items are: with the owning field's name and parent model. When the value is an inline object (or an array or `oneOf` of inline objects), the parser extracts it as a model named by the parent plus the singularized field name, e.g. `layers` on `CreateDrawingRequest` → `CreateDrawingRequestLayer`, matching `shapes` → `CreateDrawingRequestShape` for array items. `additionalProperties: {}` or `true` keeps the value type `unknown`. The singular form is taken only when no sibling property of the same object would itself mint that name — an inline object, an inline enum, an inline union (the names of its object variants and whatever its other variants mint, see below), or an array or map of such (a primitive `email` beside `emails` claims nothing); otherwise array items are `Item` (`DrawingLayersItem` beside a `layer` object with different fields) and map values `Value`; if a sibling claims that name too (a property `layers_item`), the fallback continues to `ArrayItem`, then `ArrayItem2`, and so on (`MemberValue`, `…MemberValue2` for maps), taking the first name no sibling mints. One deliberate exception: when the claiming sibling is an inline object structurally identical to the items or values (same properties, required, types, nested inline schemas and enum values; `description`, `title` and `example` ignored), both fields share one model under the singular name, since one type is right for both and a duplicate would only add noise; the split happens only once the shapes diverge. The choice reads sibling names and schema content only, so document order plays no part. -Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first; only the unions the parser types count, those reached through `properties`, `patternProperties`, `additionalProperties`, `items` and `allOf`/`oneOf`/`anyOf`, so a union under `not`, `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, `propertyNames`, `unevaluatedProperties` or any other keyword never becomes a model and contests no title; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. +Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first, a later variant that repeats the title in its union included (`DrawingShapeWithDiameter` beside `CircleDrawingShape`, whose title prefix the first took); only the unions the parser types count, those reached through `properties`, `patternProperties`, `additionalProperties`, `items` and `allOf`/`oneOf`/`anyOf`, so a union under `not`, `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, `propertyNames`, `unevaluatedProperties` or any other keyword never becomes a model and contests no title; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context — as is everything their models nest (`ALoc` for an inline `loc` of `A`) — so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and everything nested under them, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it at any depth (a `variant` object holding a `2` object mints `GetPetResponseShapeVariant2`, as a `variant_2` object does), or an earlier variant of the union minted it, at any depth too; when the chosen context still mints a claimed name — a model of the variant's own or one at any depth under it, so the `loc` of a nested variant `GetPetResponseShapeVariant2`, `GetPetResponseShapeVariant2Loc`, counts when the object variant's `variant_2_loc` takes that name — it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index ab20d5e2..6f4b64bd 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -744,16 +744,21 @@ export function isContestedTitle(variant: SchemaObject): boolean { * take their context-qualified names instead (`nameInlineVariantEntries`). * Identical shapes under one title share one model, as between siblings * (`reservedInlineNames`), and a title used once is kept. Within one union - * only the first variant to carry a title is read: a later one with the - * same title never keeps it, whatever the document holds (`emitted` in - * `nameInlineVariantEntries`), so it is no claim on the title. The same - * holds across everything one union reaches without entering an object — - * its nested unions, its array variants' items, its map variants' values, - * a lone `allOf` member — the names `mintedInlineNameEntries` reads for the - * union, which `nonObjectVariantContexts` decides in variant order: an - * earlier variant's kept title is reserved for a later variant of another - * shape and shared for the identical one. So one claim set spans that - * group, and only its first variant per title is read here. + * only the first variant to carry a title is a claim on it: a later one + * with the same title never keeps it while the first does, whatever the + * document holds (`emitted` in `nameInlineVariantEntries`), so its shape + * is not compared. The same holds across everything one union reaches + * without entering an object — its nested unions, its array variants' + * items, its map variants' values, a lone `allOf` member — the names + * `mintedInlineNameEntries` reads for the union, which + * `nonObjectVariantContexts` decides in variant order: an earlier variant's + * kept title is reserved for a later variant of another shape and shared + * for the identical one. So one claim set spans that group, and only its + * first variant per title is compared. A later one still carries the title, + * so it is marked with the rest when the title is contested: the first then + * takes its context-qualified name, which leaves nothing to step the later + * one off the bare title, and two groups' later variants of two shapes + * would meet on it. * * A root is a schema whose inline models are collected together: every * `components.schemas` entry is read as one root, because one pass collects @@ -939,29 +944,32 @@ function schemaRootsOf( /** * Mark every title-mapped variant under `roots` (read as one root) whose - * title another variant under the same roots gives a different shape; see + * title two claims under the same roots give different shapes; see * `markContestedTitles` for the walk. The visited sets are this root's own: * a schema object another root also holds is read here as well, against * this root's variants. Within the root a schema is read once per claim * group it is reached from — a union object one group already read (its - * title skipped as claimed) is read again under a property that starts a - * group of its own, where the same title is a claim — and a schema that - * refers back to itself is stopped by the ancestors on the path to it, which - * ends the walk. + * title no claim there, an earlier variant having claimed it) is read again + * under a property that starts a group of its own, where the same title is + * a claim — and a schema that refers back to itself is stopped by the + * ancestors on the path to it, which ends the walk. */ function markContestedTitlesUnderRoot(roots: readonly SchemaObject[]): void { const byTitle = new Map; variants: SchemaObject[] }>(); const visitedByGroup = new WeakMap, WeakSet>(); const ancestors = new Set(); // `claims` is the group's claim set (see `markContestedTitles`): the titles - // already read for the union this schema is reached from without entering - // an object. Only the first variant of a group to carry a title can keep - // it — a later one with the same title is stepped off it by its own union - // (`emitted` in `nameInlineVariantEntries` within one union; the kept - // titles `nonObjectVariantContexts` reserves across a union's non-object - // variants) whatever else is in the document — so a later one competes - // for nothing and is not read here. A schema reached any other way starts - // a group of its own, under which a title the first group skipped is a + // already claimed for the union this schema is reached from without + // entering an object. Only the first variant of a group to carry a title + // can keep it — a later one with the same title is stepped off it by its + // own union while the first keeps it (`emitted` in + // `nameInlineVariantEntries` within one union; the kept titles + // `nonObjectVariantContexts` reserves across a union's non-object + // variants) whatever else is in the document — so a later one's shape + // competes for nothing. It is still recorded, to be marked when the title + // is contested: the first is then qualified, and nothing steps the later + // one off the bare title. A schema reached any other way starts a group of + // its own, under which a title that was no claim in the first group is a // claim of its own, so the schema is read again there (a union object a // `transformSpec` reused under another property: its title keeps the bare // name there, and contests the first group's). @@ -977,10 +985,11 @@ function markContestedTitlesUnderRoot(roots: readonly SchemaObject[]): void { // (`foldSiblingProperties`). The marks go on the document's variants, // which every later fold's copies are read through (`isContestedTitle`). for (const [variant, title] of titleMappedVariantNames(foldSiblingProperties(schema))) { - if (claims.has(title)) continue; - claims.add(title); const entry = byTitle.get(title) ?? { shapes: new Set(), variants: [] }; - entry.shapes.add(schemaShapeKey(variant)); + if (!claims.has(title)) { + claims.add(title); + entry.shapes.add(schemaShapeKey(variant)); + } entry.variants.push(foldedVariantOrigin(variant) ?? variant); byTitle.set(title, entry); } diff --git a/test/parser/inline-models.test.ts b/test/parser/inline-models.test.ts index 2294f5d5..5781414c 100644 --- a/test/parser/inline-models.test.ts +++ b/test/parser/inline-models.test.ts @@ -4101,6 +4101,140 @@ describe('a title two unions give different shapes at different depths of one ro }, ); + /** A title-mapped union's TypeRef, its variants in `mapping` order (#373). */ + const mappedUnion = (mapping: Record) => ({ + kind: 'union', + compositionKind: 'oneOf', + variants: Object.values(mapping).map(model), + discriminator: { property: 'kind', mapping }, + }); + /** #373's union: two variants titled `Circle`, told apart by `kind` (`c1`, `c2`). */ + const circleTwice = (first: string, later: string): SchemaObject => ({ + oneOf: [ + obj({ kind: { const: 'c1' }, [first]: n }, { title: 'Circle' }), + obj({ kind: { const: 'c2' }, [later]: n }, { title: 'Circle' }), + ], + }); + const triangle = (fields: Record): SchemaObject => + obj({ kind: { const: 'triangle' }, ...fields }, { title: 'Triangle' }); + + it.each<{ label: string; spec: (drawing: SchemaObject) => Record; root: string }>([ + { + label: 'a component schema', + spec: (drawing) => doc({ paths: {}, components: { schemas: { Drawing: drawing } } }), + root: 'Drawing', + }, + { + label: 'a request body', + spec: (drawing) => + doc({ + paths: { + '/drawings': { + post: { + operationId: 'createDrawing', + requestBody: json(drawing), + responses: { '204': { description: 'ok' } }, + }, + }, + }, + }), + root: 'CreateDrawingRequest', + }, + ])('qualifies a later variant that repeats a contested title too, under $label (#373)', async ({ spec, root }) => { + // The issue's `repeat.json`: `shape` and `marker.shape` each title two + // variants `Circle`. Their first ones, `{ radius }` and `{ x }`, contest + // the title, and the later ones carry it too, so all four take their + // context-qualified names — the later ones used to keep the bare + // `Circle`, one model for `{ diameter }` and `{ z }`. + const ir = await parseWithoutWarnings( + spec( + obj({ + id: { type: 'string' }, + shape: circleTwice('radius', 'diameter'), + marker: obj({ shape: circleTwice('x', 'z') }), + }), + ), + ); + const types = fieldTypesByModel(ir.models); + expect(types[root]).toEqual({ + id: { kind: 'primitive', type: 'string' }, + shape: mappedUnion({ c1: `Circle${root}Shape`, c2: `${root}ShapeWithDiameter` }), + marker: model(`${root}Marker`), + }); + expect(types[`${root}Marker`]).toEqual({ + shape: mappedUnion({ c1: `Circle${root}MarkerShape`, c2: `${root}MarkerShapeWithZ` }), + }); + expect(types[`Circle${root}Shape`]).toEqual({ ...kind('c1'), radius: num }); + expect(types[`${root}ShapeWithDiameter`]).toEqual({ ...kind('c2'), diameter: num }); + expect(types[`Circle${root}MarkerShape`]).toEqual({ ...kind('c1'), x: num }); + expect(types[`${root}MarkerShapeWithZ`]).toEqual({ ...kind('c2'), z: num }); + expect(modelNames(ir)).toEqual( + [ + root, + `${root}Marker`, + `Circle${root}Shape`, + `${root}ShapeWithDiameter`, + `Circle${root}MarkerShape`, + `${root}MarkerShapeWithZ`, + ].sort(), + ); + expectConsistent(ir, [root]); + }); + + it("qualifies a later variant among an array variant's items too, at both depths (#373)", async () => { + // The issue's array case: at each depth `shape` is a `Circle | Square` + // union beside an array of `Circle | Triangle`. The first `Circle`s, + // `{ radius }` and `{ x }`, contest the title, so the items' `Circle`s are + // qualified with them instead of both keeping the bare `Circle`, one model + // for `{ diameter }` and `{ z }`. `Square` and `Triangle` are the same at + // both depths, so they are kept and shared. + const shapes = (first: string, later: string): SchemaObject => ({ + oneOf: [ + { oneOf: [circle({ [first]: n }), square({ side: n })] }, + { type: 'array', items: { oneOf: [circle({ [later]: n }), triangle({ base: n })] } }, + ], + }); + const ir = await parseWithoutWarnings( + doc({ + paths: {}, + components: { + schemas: { Drawing: obj({ shape: shapes('radius', 'diameter'), marker: obj({ shape: shapes('x', 'z') }) }) }, + }, + }), + ); + const shapeUnion = (head: string, items: string) => ({ + kind: 'union', + compositionKind: 'oneOf', + variants: [ + mappedUnion({ circle: head, square: 'Square' }), + { kind: 'array', items: mappedUnion({ circle: items, triangle: 'Triangle' }) }, + ], + }); + const types = fieldTypesByModel(ir.models); + expect(types['Drawing']).toEqual({ + shape: shapeUnion('CircleDrawingShape', 'CircleDrawingShapeItem'), + marker: model('DrawingMarker'), + }); + expect(types['DrawingMarker']).toEqual({ + shape: shapeUnion('CircleDrawingMarkerShape', 'CircleDrawingMarkerShapeItem'), + }); + expect(types['CircleDrawingShape']).toEqual({ ...kind('circle'), radius: num }); + expect(types['CircleDrawingShapeItem']).toEqual({ ...kind('circle'), diameter: num }); + expect(types['CircleDrawingMarkerShape']).toEqual({ ...kind('circle'), x: num }); + expect(types['CircleDrawingMarkerShapeItem']).toEqual({ ...kind('circle'), z: num }); + expect(modelNames(ir)).toEqual([ + 'CircleDrawingMarkerShape', + 'CircleDrawingMarkerShapeItem', + 'CircleDrawingShape', + 'CircleDrawingShapeItem', + 'Drawing', + 'DrawingMarker', + 'Square', + 'Triangle', + ]); + expectConsistent(ir, ['Drawing']); + }); + it('reads every component schema as one root, since their inline models are collected together', async () => { const ir = await parseWithoutWarnings( doc({ @@ -4397,12 +4531,13 @@ describe('a title two unions give different shapes at different depths of one ro markContestedTitles({ components: { schemas: { Drawing: obj({ shape: twice }) } } }); expect(variantsOf(twice).map(isContestedTitle)).toEqual([false, false]); // A second union whose `Circle` has the later variant's shape contests - // the first variant's, not the later one's. + // the first variant's. The later one carries the contested title, so it + // is marked with the first and does not keep `Circle` either (#373). const other = byDiameter(); markContestedTitles({ components: { schemas: { Drawing: obj({ shape: twice, marker: obj({ shape: other }) }) } }, }); - expect(variantsOf(twice).map(isContestedTitle)).toEqual([true, false]); + expect(variantsOf(twice).map(isContestedTitle)).toEqual([true, true]); expect(variantsOf(other).map(isContestedTitle)).toEqual([true, false]); }); @@ -4441,8 +4576,10 @@ describe('a title two unions give different shapes at different depths of one ro }); expect(variantsOf(first).map(isContestedTitle)).toEqual([true, true]); expect(variantsOf(underProperty).map(isContestedTitle)).toEqual([true, true]); + // The group's later variants claim nothing, but they carry the contested + // titles, so they are marked with the first one (#373). for (const union of [later, inItems, inValues, inAllOf]) { - expect(variantsOf(union).map(isContestedTitle)).toEqual([false, false]); + expect(variantsOf(union).map(isContestedTitle)).toEqual([true, true]); } // Without the property's union, nothing contests the group's claim. const alone = byRadius(); @@ -4454,6 +4591,55 @@ describe('a title two unions give different shapes at different depths of one ro expect(variantsOf(alongside).map(isContestedTitle)).toEqual([false, false]); }); + it('marks a later variant that repeats a contested title in its union too (#373)', () => { + // The issue's `repeat.json`: each union's first `Circle`, `{ radius }` + // and `{ x }`, claims the title and contests it. The later ones claim + // nothing but carry the title, so they are marked with the first ones; + // unmarked, both would keep the bare `Circle` once the first ones are + // qualified. + const top = circleTwice('radius', 'diameter'); + const inner = circleTwice('x', 'z'); + markContestedTitles({ components: { schemas: { Drawing: nested(top, inner) } } }); + expect(variantsOf(top).map(isContestedTitle)).toEqual([true, true]); + expect(variantsOf(inner).map(isContestedTitle)).toEqual([true, true]); + }); + + it.each<{ label: string; holding: (union: SchemaObject) => SchemaObject }>([ + { label: 'a nested union', holding: (union) => union }, + { label: "an array variant's items", holding: (union) => ({ type: 'array', items: union }) }, + { label: "a map variant's values", holding: (union) => ({ type: 'object', additionalProperties: union }) }, + { + label: "a map variant's pattern values", + holding: (union) => ({ type: 'object', patternProperties: { '^s': union } }), + }, + { label: 'a lone `allOf` member', holding: (union) => ({ allOf: [union] }) }, + ])( + 'marks a later `Circle` in $label of a contested group too, and reads no claim from it (#373)', + ({ holding }) => { + // A `Circle | Square` union beside the holder of a later + // `Circle | Triangle`, all in one claim group. + const group = (first: string, later: string) => { + const head: SchemaObject = { oneOf: [circle({ [first]: n }), square({ side: n })] }; + const tail: SchemaObject = { oneOf: [circle({ [later]: n }), triangle({ base: n })] }; + return { head, tail, union: { oneOf: [head, holding(tail)] } }; + }; + // Alone, the later `Circle` is no claim, so its other shape contests nothing. + const alone = group('radius', 'diameter'); + markContestedTitles({ components: { schemas: { Drawing: obj({ shape: alone.union }) } } }); + expect(variantsOf(alone.head).map(isContestedTitle)).toEqual([false, false]); + expect(variantsOf(alone.tail).map(isContestedTitle)).toEqual([false, false]); + // At two depths whose first `Circle`s differ, the title is contested, + // and each group's later `Circle` is marked with its first. + const top = group('radius', 'diameter'); + const deeper = group('x', 'z'); + markContestedTitles({ components: { schemas: { Drawing: nested(top.union, deeper.union) } } }); + for (const { head, tail } of [top, deeper]) { + expect(variantsOf(head).map(isContestedTitle)).toEqual([true, false]); + expect(variantsOf(tail).map(isContestedTitle)).toEqual([true, false]); + } + }, + ); + it('ends on a schema that refers back to itself, within a root and across the document', () => { // A root whose property is the root itself, with a conflict further // down; a component that contains itself; a path item whose From cb8efa68a2a42b2267cf17fd405c80b8573b83ce Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 16:00:27 -0400 Subject: [PATCH 57/59] fix(parser)!: contest a title between an inline allOf's $ref and inline members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #320 a title two shapes take under one root is contested, and every variant carrying it takes its context-qualified name. The walk that finds the clashes, markContestedTitlesUnderRoot, stopped at every `$ref`. An inline `allOf: [{ $ref: Base }, { … }]` is merged into one model that carries Base's properties beside the inline member's, yet Base's unions were read only under the components root, so a title that `Base.shape` and the inline `marker.shape` give different shapes was contested nowhere. As a 2xx response both unions minted one `Circle`, and `marker.shape` resolved to Base's `{ radius }` and `{ side }` with a "minted twice" warning. As a default, error or secondary body, a header or a parameter, the inherited `shape` was silently pointed at the inline member's `{ diameter }` and `{ len }`. Where an `allOf` is read as one merged model (readInlineAllOf), the walk now also reads every property the merge inherits through a `$ref` member (mergeAllOfMembers, a component's own allOf chain included) under the same root. Each such property is a claim group of its own, as any property is, and is read once per root, so a chain of components that each merge the next stays linear. A plain reference, a `$ref` under a variant and a union component's own variants are not read, since the merge does not read them either. The issue's allof.json now types GetDrawingResponseMarker.shape as (CircleGetDrawingResponseMarkerShape | SquareGetDrawingResponseMarkerShape) over `{ diameter }` and `{ len }`, and Base.shape as (CircleBaseShape | SquareBaseShape) over `{ radius }` and `{ side }`, without a warning. The IR, warnings included, of every fixture spec, the github-subset example and four WorkOS specs is unchanged. BREAKING CHANGE: when an inline allOf merges a component whose union gives a title another shape than a union of the same root does, no variant carrying that title keeps it. The component's variants take their context-qualified names wherever they appear (`Circle` becomes `CircleBaseShape`), and so do the root's (a request body's `CreateDrawingCircle` becomes `CircleCreateDrawingRequestMarkerShape`). The discriminator mappings follow. --- docs/architecture/ir-types.md | 2 +- src/parser/inline-models.ts | 73 ++++++-- test/parser/inline-models.test.ts | 265 ++++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+), 12 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 98fc2df5..e3870dfd 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -167,7 +167,7 @@ The optional `keyType` allows non-string map keys (e.g., enum-keyed maps). When A map's value schema is typed the same way an array's items are: with the owning field's name and parent model. When the value is an inline object (or an array or `oneOf` of inline objects), the parser extracts it as a model named by the parent plus the singularized field name, e.g. `layers` on `CreateDrawingRequest` → `CreateDrawingRequestLayer`, matching `shapes` → `CreateDrawingRequestShape` for array items. `additionalProperties: {}` or `true` keeps the value type `unknown`. The singular form is taken only when no sibling property of the same object would itself mint that name — an inline object, an inline enum, an inline union (the names of its object variants and whatever its other variants mint, see below), or an array or map of such (a primitive `email` beside `emails` claims nothing); otherwise array items are `Item` (`DrawingLayersItem` beside a `layer` object with different fields) and map values `Value`; if a sibling claims that name too (a property `layers_item`), the fallback continues to `ArrayItem`, then `ArrayItem2`, and so on (`MemberValue`, `…MemberValue2` for maps), taking the first name no sibling mints. One deliberate exception: when the claiming sibling is an inline object structurally identical to the items or values (same properties, required, types, nested inline schemas and enum values; `description`, `title` and `example` ignored), both fields share one model under the singular name, since one type is right for both and a duplicate would only add noise; the split happens only once the shapes diverge. The choice reads sibling names and schema content only, so document order plays no part. -Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first, a later variant that repeats the title in its union included (`DrawingShapeWithDiameter` beside `CircleDrawingShape`, whose title prefix the first took); only the unions the parser types count, those reached through `properties`, `patternProperties`, `additionalProperties`, `items` and `allOf`/`oneOf`/`anyOf`, so a union under `not`, `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, `propertyNames`, `unevaluatedProperties` or any other keyword never becomes a model and contests no title; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. +Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first, a later variant that repeats the title in its union included (`DrawingShapeWithDiameter` beside `CircleDrawingShape`, whose title prefix the first took); only the unions the parser types count, those reached through `properties`, `patternProperties`, `additionalProperties`, `items` and `allOf`/`oneOf`/`anyOf`, so a union under `not`, `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, `propertyNames`, `unevaluatedProperties` or any other keyword never becomes a model and contests no title; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). A component an inline `allOf` merges into one model (`allOf: [{ $ref: Base }, { … }]`, wherever a root holds it) is read with that root as far as the merge reads it, since the model carries the component's properties beside the inline members': every property the merge inherits through the `$ref`, `Base`'s own and its `allOf` chain's, is compared with the root's unions, so a title `Base.shape` and the inline member's `marker.shape` give different shapes is contested, and with the mark on `Base`'s own variants they are qualified wherever they appear (`CircleBaseShape`, beside `CircleGetDrawingResponseMarkerShape`); a plain reference, a `$ref` under a variant and a union component's own variants are left to the components root. Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context — as is everything their models nest (`ALoc` for an inline `loc` of `A`) — so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and everything nested under them, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it at any depth (a `variant` object holding a `2` object mints `GetPetResponseShapeVariant2`, as a `variant_2` object does), or an earlier variant of the union minted it, at any depth too; when the chosen context still mints a claimed name — a model of the variant's own or one at any depth under it, so the `loc` of a nested variant `GetPetResponseShapeVariant2`, `GetPetResponseShapeVariant2Loc`, counts when the object variant's `variant_2_loc` takes that name — it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index 6f4b64bd..3f47bb08 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -769,17 +769,25 @@ export function isContestedTitle(variant: SchemaObject): boolean { * `webhooks` or `components` alike — is a root of its own. The same title * given two shapes by two roots is not contested here: the passes that merge * the roots' models decide it (the first owner keeps the name, and a later - * shape is renamed or dropped with a warning). + * shape is renamed or dropped with a warning). A component an inline `allOf` + * merges into one model (`readInlineAllOf`: `'merged'`) is read with that + * root too, as far as the merge reads it: the model carries the component's + * properties beside its inline members' (`mergeAllOfMembers`), so every + * property it inherits through a `$ref` member is walked under the root, + * each once (workos/oagen#374: as a response, `allOf: [{ $ref: Base }, + * { … }]` kept a title `Base.shape` and the inline `marker.shape` give + * different shapes, and one union resolved to the other's models). * * Within a root, the walk follows the keywords the parser types a schema * through only — `properties`, `patternProperties`, `additionalProperties`, * `items` and `allOf`/`oneOf`/`anyOf` — so a property named `schema` or * `title` is a property, a `$ref` is a reference to a component read under - * the components root, not descended into, and a union under `not`, - * `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, - * `prefixItems`, `propertyNames` or another keyword the parser never reads - * never becomes a model, so it contests no title (`schemaShapeKey` still - * compares them). The roots are found by following the OpenAPI object model + * the components root, not descended into (a merged `allOf`'s `$ref` member + * aside, see above), and a union under `not`, `if`/`then`/`else`, + * `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, + * `propertyNames` or another keyword the parser never reads never becomes a + * model, so it contests no title (`schemaShapeKey` still compares them). + * The roots are found by following the OpenAPI object model * (`schemaRootsOf`), the way the operation, response and webhook readers * do, so a name is never mistaken for a specification extension: a header * named `x-meta`, a component named `x-thing`, a media type `x-custom/json` @@ -799,6 +807,8 @@ export function isContestedTitle(variant: SchemaObject): boolean { * external schema inlined twice as one object — is read under each root and * compared with each root's other variants, and a mark from either root * stands: the shared variant's title is then qualified wherever it appears. + * So does a component's union a root reads through a merged `allOf`: a mark + * there qualifies the component's own variants too (`CircleBaseShape`). * Within a root the same holds per claim group: a union object reached * under two groups — nested in `shape`, where an earlier union of the group * already claimed its title, and again as `wrapper.marker`, where it keeps @@ -813,8 +823,13 @@ export function markContestedTitles(document: unknown): void { if (!isRecord(document)) return; const components = isRecord(document['components']) ? document['components'] : undefined; const schemas = components?.['schemas']; - if (isRecord(schemas)) markContestedTitlesUnderRoot(Object.values(schemas).filter(isSchemaRecord)); - for (const root of schemaRootsOf(document, components)) markContestedTitlesUnderRoot([root]); + // What a merged `allOf`'s `$ref` members resolve against: this document's + // components only, none when it declares none. + const componentSchemas = isRecord(schemas) ? (schemas as Record) : {}; + if (isRecord(schemas)) { + markContestedTitlesUnderRoot(Object.values(schemas).filter(isSchemaRecord), componentSchemas); + } + for (const root of schemaRootsOf(document, components)) markContestedTitlesUnderRoot([root], componentSchemas); } /** A plain object: the only value that can be a schema or a container of them. */ @@ -952,12 +967,21 @@ function schemaRootsOf( * title no claim there, an earlier variant having claimed it) is read again * under a property that starts a group of its own, where the same title is * a claim — and a schema that refers back to itself is stopped by the - * ancestors on the path to it, which ends the walk. + * ancestors on the path to it, which ends the walk. `componentSchemas` + * resolves the `$ref` members of a merged `allOf`, whose inherited + * properties are read here too (`inheritedAllOfProperties`). */ -function markContestedTitlesUnderRoot(roots: readonly SchemaObject[]): void { +function markContestedTitlesUnderRoot( + roots: readonly SchemaObject[], + componentSchemas: Record, +): void { const byTitle = new Map; variants: SchemaObject[] }>(); const visitedByGroup = new WeakMap, WeakSet>(); const ancestors = new Set(); + // The properties a merged `allOf` inherited, each read once: a property + // starts a group of its own, so a second read finds the same claims, and a + // chain of components each merging the next would be read once per path. + const inheritedRead = new WeakSet(); // `claims` is the group's claim set (see `markContestedTitles`): the titles // already claimed for the union this schema is reached from without // entering an object. Only the first variant of a group to carry a title @@ -984,7 +1008,8 @@ function markContestedTitlesUnderRoot(roots: readonly SchemaObject[]): void { // `properties` written beside the union folded into them // (`foldSiblingProperties`). The marks go on the document's variants, // which every later fold's copies are read through (`isContestedTitle`). - for (const [variant, title] of titleMappedVariantNames(foldSiblingProperties(schema))) { + const folded = foldSiblingProperties(schema); + for (const [variant, title] of titleMappedVariantNames(folded)) { const entry = byTitle.get(title) ?? { shapes: new Set(), variants: [] }; if (!claims.has(title)) { claims.add(title); @@ -1015,6 +1040,15 @@ function markContestedTitlesUnderRoot(roots: readonly SchemaObject[]): void { for (const sub of value) if (isSchemaRecord(sub)) walk(sub, inherited); } } + // A `$ref` member of an `allOf` merged into one model is no reference: + // the model carries what the merge inherits through it beside its inline + // members' properties, so those properties are read with this root, each + // a group of its own like any property. + for (const property of inheritedAllOfProperties(folded, componentSchemas)) { + if (inheritedRead.has(property)) continue; + inheritedRead.add(property); + walk(property); + } ancestors.delete(schema); }; for (const root of roots) walk(root); @@ -1023,6 +1057,23 @@ function markContestedTitlesUnderRoot(roots: readonly SchemaObject[]): void { } } +/** + * The properties an inline `allOf` read as one merged model + * (`readInlineAllOf`: `'merged'`) inherits from the components its `$ref` + * members name — every property `mergeAllOfMembers` reads through a + * reference, a component's `allOf` chain included — and none for any other + * schema. A `$ref` the merge does not follow (one under a variant), and a + * union a component carries, which the merge leaves to that component, add + * nothing. + */ +function inheritedAllOfProperties( + schema: SchemaObject, + componentSchemas: Record, +): SchemaObject[] { + if (!schema.allOf || readInlineAllOf(schema.allOf, schema, componentSchemas) !== 'merged') return []; + return mergeAllOfMembers(schema, componentSchemas).properties.flatMap((p) => (p.origin ? [p.schema] : [])); +} + /** * The context name an array field's items (`kind: 'item'`) or a map field's * values (`kind: 'value'`) are typed and extracted under. By default it is diff --git a/test/parser/inline-models.test.ts b/test/parser/inline-models.test.ts index 5781414c..e4147542 100644 --- a/test/parser/inline-models.test.ts +++ b/test/parser/inline-models.test.ts @@ -4311,6 +4311,165 @@ describe('a title two unions give different shapes at different depths of one ro expectConsistent(ir, ['CreateDrawingRequest', 'CreateDrawingDefaultResponse']); }); + /** The issue's `allof.json` schema (#374): `Base` merged with an inline member whose `marker.shape` gives `Circle` and `Square` other shapes. */ + const mergedWithBase = (): SchemaObject => ({ + allOf: [{ $ref: '#/components/schemas/Base' }, obj({ marker: obj({ shape: byDiameter() }) })], + }); + /** `body` beside the issue's `Base`, whose `shape` is the `{ radius }` and `{ side }` union. */ + const withBase = (body: Record) => + doc({ ...body, components: { schemas: { Base: obj({ id: { type: 'string' }, shape: byRadius() }) } } }); + const str = { kind: 'primitive', type: 'string' }; + + it("contests a title an inline response's `$ref` and inline `allOf` members give different shapes (#374)", async () => { + // The issue's `allof.json`: the response merges `Base` with an inline + // member into one model whose inline models come from both, so `Circle` + // and `Square` are contested under the response root. Neither root saw + // the clash before: both members kept the bare titles, and `marker.shape` + // resolved to `Base`'s `{ radius }` and `{ side }`, with a warning. + const ir = await parseWithoutWarnings( + withBase(op('getDrawing', { responses: { '200': { description: 'ok', ...json(mergedWithBase()) } } })), + ); + const types = fieldTypesByModel(ir.models); + // The mark is on `Base`'s own variants, so they are qualified wherever + // they appear, `Base` included. + expect(types['Base']).toEqual({ id: str, shape: unionOf('CircleBaseShape', 'SquareBaseShape') }); + expect(types['GetDrawingResponse']).toEqual({ + id: str, + shape: unionOf('CircleGetDrawingResponseShape', 'SquareGetDrawingResponseShape'), + marker: model('GetDrawingResponseMarker'), + }); + expect(types['GetDrawingResponseMarker']).toEqual({ + shape: unionOf('CircleGetDrawingResponseMarkerShape', 'SquareGetDrawingResponseMarkerShape'), + }); + for (const owner of ['Base', 'GetDrawingResponse']) { + expect(types[`Circle${owner}Shape`]).toEqual({ ...kind('circle'), radius: num }); + expect(types[`Square${owner}Shape`]).toEqual({ ...kind('square'), side: num }); + } + expect(types['CircleGetDrawingResponseMarkerShape']).toEqual({ ...kind('circle'), diameter: num }); + expect(types['SquareGetDrawingResponseMarkerShape']).toEqual({ ...kind('square'), len: num }); + expect(modelNames(ir)).toEqual([ + 'Base', + 'CircleBaseShape', + 'CircleGetDrawingResponseMarkerShape', + 'CircleGetDrawingResponseShape', + 'GetDrawingResponse', + 'GetDrawingResponseMarker', + 'SquareBaseShape', + 'SquareGetDrawingResponseMarkerShape', + 'SquareGetDrawingResponseShape', + ]); + expectConsistent(ir, ['Base']); + }); + + /** The fields of each model the union `field` of the model `owner` names, in order. */ + const variantFields = (ir: ApiSpec, owner: string, field: string): string[][] => { + const fieldsOf = (name: string) => ir.models.find((m) => m.name === name)?.fields ?? []; + const type = fieldsOf(owner).find((f) => f.name === field)?.type; + if (type?.kind !== 'union') return []; + return type.variants.map((variant) => fieldsOf(variant.kind === 'model' ? variant.name : '').map((f) => f.name)); + }; + + it.each<{ label: string; body: Record; merged: string }>([ + { + label: 'a 2xx response', + body: op('getDrawing', { responses: { '200': { description: 'ok', ...json(mergedWithBase()) } } }), + merged: 'GetDrawingResponse', + }, + { + label: 'a default response', + body: op('getDrawing', { + responses: { '204': { description: 'ok' }, default: { description: 'err', ...json(mergedWithBase()) } }, + }), + merged: 'GetDrawingDefaultResponse', + }, + { + label: 'an error response', + body: op('getDrawing', { + responses: { '204': { description: 'ok' }, '404': { description: 'gone', ...json(mergedWithBase()) } }, + }), + merged: 'GetDrawingError404', + }, + { + label: 'a field of a default response', + body: op('getDrawing', { + responses: { + '204': { description: 'ok' }, + default: { description: 'err', ...json(obj({ drawing: mergedWithBase() })) }, + }, + }), + merged: 'GetDrawingDefaultResponseDrawing', + }, + { + label: 'a response header', + body: op('getDrawing', { + responses: { '204': { description: 'ok', headers: { 'X-Meta': { schema: mergedWithBase() } } } }, + }), + merged: 'DrawingsXMeta', + }, + { + label: 'a query parameter', + body: op('getDrawing', { + parameters: [{ name: 'filter', in: 'query', content: { 'application/json': { schema: mergedWithBase() } } }], + responses: { '204': { description: 'ok' } }, + }), + merged: 'DrawingsFilter', + }, + { + label: 'a request body', + body: { + paths: { + '/drawings': { + post: { + operationId: 'createDrawing', + requestBody: json(mergedWithBase()), + responses: { '204': { description: 'ok' } }, + }, + }, + }, + }, + merged: 'CreateDrawingRequest', + }, + { + label: 'a webhook payload', + body: { + paths: {}, + webhooks: { + drawingCreated: { + post: { + operationId: 'drawingCreated', + requestBody: json(mergedWithBase()), + responses: { '204': { description: 'ok' } }, + }, + }, + }, + }, + merged: 'DrawingCreatedPayload', + }, + ])("keeps both members' shapes of a title they contest under $label (#374)", async ({ body, merged }) => { + // Wherever the issue's `allOf` is merged, `Base.shape` and the inline + // `marker.shape` are one model's, so the title is contested there and + // each union keeps its own shapes. Before, a response, a header or a + // parameter pointed one of them at the other's models (a 2xx response + // with a warning, the rest silently), and a request body or webhook + // payload renamed the inline member's after the fact. + const ir = await parseWithoutWarnings(withBase(body)); + const marker = fieldTypesByModel(ir.models)[merged]?.['marker']; + expect(marker).toMatchObject({ kind: 'model' }); + expect(variantFields(ir, 'Base', 'shape')).toEqual([ + ['kind', 'radius'], + ['kind', 'side'], + ]); + expect(variantFields(ir, merged, 'shape')).toEqual([ + ['kind', 'radius'], + ['kind', 'side'], + ]); + expect(variantFields(ir, (marker as { name: string }).name, 'shape')).toEqual([ + ['kind', 'diameter'], + ['kind', 'len'], + ]); + expectConsistent(ir, ['Base']); + }); + describe('markContestedTitles', () => { const variantsOf = (union: SchemaObject): SchemaObject[] => union.oneOf!; @@ -4431,6 +4590,112 @@ describe('a title two unions give different shapes at different depths of one ro expect(variantsOf(untitled).map(isContestedTitle)).toEqual([false, false]); }); + const ref = (name: string): SchemaObject => ({ $ref: `#/components/schemas/${name}` }); + /** A response of `body`, `top` the `{ radius }` union a component holds and `inner` the body's `{ diameter }` one (#374). */ + type MergeCase = { + label: string; + body: (inner: SchemaObject) => SchemaObject; + schemas: (top: SchemaObject) => Record; + }; + const markResponse = ({ body, schemas }: MergeCase) => { + const top = byRadius(); + const inner = byDiameter(); + markContestedTitles({ + paths: { '/d': { get: { responses: { '200': json(body(inner)) } } } }, + components: { schemas: schemas(top) }, + }); + return [variantsOf(top).map(isContestedTitle), variantsOf(inner).map(isContestedTitle)]; + }; + const marker = (inner: SchemaObject) => obj({ marker: obj({ shape: inner }) }); + const base = (top: SchemaObject) => ({ Base: obj({ id: { type: 'string' }, shape: top }) }); + + it.each([ + { label: 'a `$ref` member', body: (inner) => ({ allOf: [ref('Base'), marker(inner)] }), schemas: base }, + { + label: "a `$ref` in the component's own `allOf` chain", + body: (inner) => ({ allOf: [ref('Base'), marker(inner)] }), + schemas: (top) => ({ + Base: { allOf: [ref('Root'), obj({ id: { type: 'string' } })] }, + Root: obj({ shape: top }), + }), + }, + { + label: "a `$ref` under an inline member's own `allOf`", + body: (inner) => ({ allOf: [{ allOf: [ref('Base')] }, marker(inner)] }), + schemas: base, + }, + { + label: 'a nullable `$ref` member', + body: (inner) => ({ allOf: [{ oneOf: [ref('Base'), { type: 'null' }] }, marker(inner)] }), + schemas: base, + }, + { + label: '`properties` written beside a lone `$ref` member', + body: (inner) => ({ allOf: [ref('Base')], properties: { marker: obj({ shape: inner }) } }), + schemas: base, + }, + ])('reads the properties a merged `allOf` inherits through $label with the root (#374)', (merge) => { + // The issue's `allof.json`: the response merges `Base` with an inline + // member into one model, which carries `Base.shape` beside the inline + // `marker.shape`, so the two are compared under the response root. + expect(markResponse(merge)).toEqual([ + [true, true], + [true, true], + ]); + }); + + it.each([ + { + label: 'an `allOf` read as a plain reference', + body: (inner) => obj({ base: { allOf: [ref('Base')] }, marker: obj({ shape: inner }) }), + schemas: base, + }, + { + label: "a `$ref` under an inline member's variant", + body: (inner) => ({ allOf: [marker(inner), { oneOf: [ref('Base'), obj({ other: n })] }] }), + schemas: base, + }, + { + label: "a union component's variants", + body: (inner) => ({ allOf: [ref('Base'), marker(inner)] }), + schemas: (top) => ({ Base: top }), + }, + ])('reads nothing the merge does not inherit: $label (#374)', (merge) => { + // Each of these is left to its component's model, which the components + // root reads (`mergeAllOfMembers`), so no title is contested here. + expect(markResponse(merge)).toEqual([ + [false, false], + [false, false], + ]); + }); + + it('reads an inherited property once per root, so a chain of merges ends and stays linear (#374)', () => { + // Each link merges the next one under three properties, so reading an + // inherited property again wherever it is reached would take 3^29 + // walks; the last link's union is still compared with the response's. + // A component that merges itself, and two that merge each other, end. + const deepest = byRadius(); + const inner = byDiameter(); + const schemas: Record = { Link30: obj({ shape: deepest }) }; + for (let i = 29; i >= 1; i--) { + const fields = ['l1', 'l2', 'l3'].map((f) => [f, { allOf: [ref(`Link${i + 1}`), obj({ [f]: n })] }]); + schemas[`Link${i}`] = obj(Object.fromEntries(fields)); + } + schemas['Node'] = obj({ child: { allOf: [ref('Node'), obj({ extra: n })] } }); + schemas['A'] = obj({ toB: { allOf: [ref('B'), obj({ x: n })] } }); + schemas['B'] = obj({ toA: { allOf: [ref('A'), obj({ y: n })] } }); + markContestedTitles({ + paths: { + '/d': { + get: { responses: { '200': json({ allOf: [ref('Link1'), ref('Node'), ref('A'), marker(inner)] }) } }, + }, + }, + components: { schemas }, + }); + expect(variantsOf(deepest).map(isContestedTitle)).toEqual([true, true]); + expect(variantsOf(inner).map(isContestedTitle)).toEqual([true, true]); + }); + it("replaces the previous document's marks", () => { const top = byRadius(); markContestedTitles({ components: { schemas: { Drawing: nested(top, byDiameter()) } } }); From cd63b1579a4079f426d944e2e58cdb3a00a82be5 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 16:12:00 -0400 Subject: [PATCH 58/59] fix(parser)!: contest a title under a property both allOf members declare Greptile review of #498. The walk read only the properties a merged `allOf` inherits through a `$ref` member (a non-null origin). When an inline member declares a property the component declares too, such as `meta` beside `Base.meta`, mergeAllOfMembers conjoins the two into one property the merged model owns (origin null), and every site mints it under the root. That property was skipped, so `Base.meta.shape` was never compared with the inline `meta.marker.shape`, and both unions minted one `Circle` with a "minted twice" warning, as a 2xx response, a request body and a default response alike. mergedAllOfProperties (was inheritedAllOfProperties) now returns every always-applying property of the merged model: the inherited ones, the conjoined ones and an inline member's own, which the walk also reads where it is written, so a second read finds the same claims. A property only variants declare is still left where it is written. Each property is still read once per root. The IR, warnings included, of every fixture spec, the github-subset example and four WorkOS specs is unchanged. BREAKING CHANGE: a titled variant under a property both members of a merged inline allOf declare is contested with the root's other unions too: `Base.meta.shape`'s `Circle` becomes `CircleBaseMetaShape`, and the inline `meta.marker.shape`'s takes its own qualified name instead of resolving to it. --- docs/architecture/ir-types.md | 2 +- src/parser/inline-models.ts | 58 ++++++++++++----------- test/parser/inline-models.test.ts | 76 ++++++++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 29 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index e3870dfd..361a41dd 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -167,7 +167,7 @@ The optional `keyType` allows non-string map keys (e.g., enum-keyed maps). When A map's value schema is typed the same way an array's items are: with the owning field's name and parent model. When the value is an inline object (or an array or `oneOf` of inline objects), the parser extracts it as a model named by the parent plus the singularized field name, e.g. `layers` on `CreateDrawingRequest` → `CreateDrawingRequestLayer`, matching `shapes` → `CreateDrawingRequestShape` for array items. `additionalProperties: {}` or `true` keeps the value type `unknown`. The singular form is taken only when no sibling property of the same object would itself mint that name — an inline object, an inline enum, an inline union (the names of its object variants and whatever its other variants mint, see below), or an array or map of such (a primitive `email` beside `emails` claims nothing); otherwise array items are `Item` (`DrawingLayersItem` beside a `layer` object with different fields) and map values `Value`; if a sibling claims that name too (a property `layers_item`), the fallback continues to `ArrayItem`, then `ArrayItem2`, and so on (`MemberValue`, `…MemberValue2` for maps), taking the first name no sibling mints. One deliberate exception: when the claiming sibling is an inline object structurally identical to the items or values (same properties, required, types, nested inline schemas and enum values; `description`, `title` and `example` ignored), both fields share one model under the singular name, since one type is right for both and a duplicate would only add noise; the split happens only once the shapes diverge. The choice reads sibling names and schema content only, so document order plays no part. -Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first, a later variant that repeats the title in its union included (`DrawingShapeWithDiameter` beside `CircleDrawingShape`, whose title prefix the first took); only the unions the parser types count, those reached through `properties`, `patternProperties`, `additionalProperties`, `items` and `allOf`/`oneOf`/`anyOf`, so a union under `not`, `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, `propertyNames`, `unevaluatedProperties` or any other keyword never becomes a model and contests no title; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). A component an inline `allOf` merges into one model (`allOf: [{ $ref: Base }, { … }]`, wherever a root holds it) is read with that root as far as the merge reads it, since the model carries the component's properties beside the inline members': every property the merge inherits through the `$ref`, `Base`'s own and its `allOf` chain's, is compared with the root's unions, so a title `Base.shape` and the inline member's `marker.shape` give different shapes is contested, and with the mark on `Base`'s own variants they are qualified wherever they appear (`CircleBaseShape`, beside `CircleGetDrawingResponseMarkerShape`); a plain reference, a `$ref` under a variant and a union component's own variants are left to the components root. Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. +Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first, a later variant that repeats the title in its union included (`DrawingShapeWithDiameter` beside `CircleDrawingShape`, whose title prefix the first took); only the unions the parser types count, those reached through `properties`, `patternProperties`, `additionalProperties`, `items` and `allOf`/`oneOf`/`anyOf`, so a union under `not`, `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, `propertyNames`, `unevaluatedProperties` or any other keyword never becomes a model and contests no title; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). A component an inline `allOf` merges into one model (`allOf: [{ $ref: Base }, { … }]`, wherever a root holds it) is read with that root as far as the merge reads it, since the model carries the component's properties beside the inline members': every property the merge inherits through the `$ref`, `Base`'s own and its `allOf` chain's, and one an inline member declares too, conjoined with `Base`'s (a `meta` both declare), is compared with the root's unions, so a title `Base.shape` and the inline member's `marker.shape` give different shapes is contested, and with the mark on `Base`'s own variants they are qualified wherever they appear (`CircleBaseShape`, beside `CircleGetDrawingResponseMarkerShape`); a plain reference, a `$ref` under a variant and a union component's own variants are left to the components root. Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context — as is everything their models nest (`ALoc` for an inline `loc` of `A`) — so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and everything nested under them, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it at any depth (a `variant` object holding a `2` object mints `GetPetResponseShapeVariant2`, as a `variant_2` object does), or an earlier variant of the union minted it, at any depth too; when the chosen context still mints a claimed name — a model of the variant's own or one at any depth under it, so the `loc` of a nested variant `GetPetResponseShapeVariant2`, `GetPetResponseShapeVariant2Loc`, counts when the object variant's `variant_2_loc` takes that name — it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index 3f47bb08..77f4a27e 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -772,11 +772,13 @@ export function isContestedTitle(variant: SchemaObject): boolean { * shape is renamed or dropped with a warning). A component an inline `allOf` * merges into one model (`readInlineAllOf`: `'merged'`) is read with that * root too, as far as the merge reads it: the model carries the component's - * properties beside its inline members' (`mergeAllOfMembers`), so every - * property it inherits through a `$ref` member is walked under the root, - * each once (workos/oagen#374: as a response, `allOf: [{ $ref: Base }, - * { … }]` kept a title `Base.shape` and the inline `marker.shape` give - * different shapes, and one union resolved to the other's models). + * properties beside its inline members' (`mergeAllOfMembers`), so its + * properties — every one it inherits through a `$ref` member, and one an + * inline member declares too, conjoined with the component's — are walked + * under the root, each once (workos/oagen#374: as a response, `allOf: + * [{ $ref: Base }, { … }]` kept a title `Base.shape` and the inline + * `marker.shape` give different shapes, and one union resolved to the + * other's models). * * Within a root, the walk follows the keywords the parser types a schema * through only — `properties`, `patternProperties`, `additionalProperties`, @@ -968,8 +970,8 @@ function schemaRootsOf( * under a property that starts a group of its own, where the same title is * a claim — and a schema that refers back to itself is stopped by the * ancestors on the path to it, which ends the walk. `componentSchemas` - * resolves the `$ref` members of a merged `allOf`, whose inherited - * properties are read here too (`inheritedAllOfProperties`). + * resolves the `$ref` members of a merged `allOf`, whose model's properties + * are read here too (`mergedAllOfProperties`). */ function markContestedTitlesUnderRoot( roots: readonly SchemaObject[], @@ -978,10 +980,10 @@ function markContestedTitlesUnderRoot( const byTitle = new Map; variants: SchemaObject[] }>(); const visitedByGroup = new WeakMap, WeakSet>(); const ancestors = new Set(); - // The properties a merged `allOf` inherited, each read once: a property + // The properties of a merged `allOf`'s model, each read once: a property // starts a group of its own, so a second read finds the same claims, and a // chain of components each merging the next would be read once per path. - const inheritedRead = new WeakSet(); + const mergedRead = new WeakSet(); // `claims` is the group's claim set (see `markContestedTitles`): the titles // already claimed for the union this schema is reached from without // entering an object. Only the first variant of a group to carry a title @@ -1041,12 +1043,13 @@ function markContestedTitlesUnderRoot( } } // A `$ref` member of an `allOf` merged into one model is no reference: - // the model carries what the merge inherits through it beside its inline - // members' properties, so those properties are read with this root, each - // a group of its own like any property. - for (const property of inheritedAllOfProperties(folded, componentSchemas)) { - if (inheritedRead.has(property)) continue; - inheritedRead.add(property); + // the model carries what the merge reads through it beside its inline + // members' properties, conjoined with theirs where both declare one, so + // the model's properties are read with this root, each a group of its + // own like any property. + for (const property of mergedAllOfProperties(folded, componentSchemas)) { + if (mergedRead.has(property)) continue; + mergedRead.add(property); walk(property); } ancestors.delete(schema); @@ -1058,20 +1061,21 @@ function markContestedTitlesUnderRoot( } /** - * The properties an inline `allOf` read as one merged model - * (`readInlineAllOf`: `'merged'`) inherits from the components its `$ref` - * members name — every property `mergeAllOfMembers` reads through a - * reference, a component's `allOf` chain included — and none for any other - * schema. A `$ref` the merge does not follow (one under a variant), and a - * union a component carries, which the merge leaves to that component, add - * nothing. + * Every always-applying property of the one model an inline `allOf` read as + * merged (`readInlineAllOf`: `'merged'`) builds (`mergeAllOfMembers`): those + * it inherits through a `$ref` member, a component's own `allOf` chain + * included; those two members declare, conjoined into one the model owns (a + * `meta` both `Base` and an inline member declare carries `Base`'s + * `meta.shape` beside the inline `meta.marker`); and an inline member's own, + * which the walk also reads where it is written. None for any other schema, + * nor a property only variants declare, which only an inline member's + * variants can and the walk reads there. A `$ref` the merge does not follow + * (one under a variant), and a union a component carries, which the merge + * leaves to that component, add nothing. */ -function inheritedAllOfProperties( - schema: SchemaObject, - componentSchemas: Record, -): SchemaObject[] { +function mergedAllOfProperties(schema: SchemaObject, componentSchemas: Record): SchemaObject[] { if (!schema.allOf || readInlineAllOf(schema.allOf, schema, componentSchemas) !== 'merged') return []; - return mergeAllOfMembers(schema, componentSchemas).properties.flatMap((p) => (p.origin ? [p.schema] : [])); + return mergeAllOfMembers(schema, componentSchemas).properties.flatMap((p) => (p.conditional ? [] : [p.schema])); } /** diff --git a/test/parser/inline-models.test.ts b/test/parser/inline-models.test.ts index e4147542..8df96b1a 100644 --- a/test/parser/inline-models.test.ts +++ b/test/parser/inline-models.test.ts @@ -4470,6 +4470,75 @@ describe('a title two unions give different shapes at different depths of one ro expectConsistent(ir, ['Base']); }); + it.each<{ label: string; body: (schema: SchemaObject) => Record; root: string }>([ + { + label: 'a 2xx response', + body: (schema) => op('getDrawing', { responses: { '200': { description: 'ok', ...json(schema) } } }), + root: 'GetDrawingResponse', + }, + { + label: 'a default response', + body: (schema) => + op('getDrawing', { + responses: { '204': { description: 'ok' }, default: { description: 'err', ...json(schema) } }, + }), + root: 'GetDrawingDefaultResponse', + }, + { + label: 'a request body', + body: (schema) => ({ + paths: { + '/drawings': { + post: { + operationId: 'createDrawing', + requestBody: json(schema), + responses: { '204': { description: 'ok' } }, + }, + }, + }, + }), + root: 'CreateDrawingRequest', + }, + ])( + 'contests a title under a property both members declare, under $label (#374, Greptile)', + async ({ body, root }) => { + // `Base.meta` and the inline member's `meta` conjoin into one property of + // the merged model, its own: `meta.shape` comes from `Base` and + // `meta.marker.shape` from the inline member, and both are minted under + // the root, so their titles are compared there. + const ir = await parseWithoutWarnings( + doc({ + ...body({ + allOf: [ + { $ref: '#/components/schemas/Base' }, + obj({ meta: obj({ marker: obj({ shape: byDiameter() }) }) }), + ], + }), + components: { schemas: { Base: obj({ id: { type: 'string' }, meta: obj({ shape: byRadius() }) }) } }, + }), + ); + /** The model the field `field` of the model `owner` references. */ + const modelOf = (owner: string, field: string): string => { + const type = fieldTypesByModel(ir.models)[owner]?.[field]; + expect(type).toMatchObject({ kind: 'model' }); + return (type as { name: string }).name; + }; + const meta = modelOf(root, 'meta'); + const marker = modelOf(meta, 'marker'); + for (const owner of ['BaseMeta', meta]) { + expect(variantFields(ir, owner, 'shape')).toEqual([ + ['kind', 'radius'], + ['kind', 'side'], + ]); + } + expect(variantFields(ir, marker, 'shape')).toEqual([ + ['kind', 'diameter'], + ['kind', 'len'], + ]); + expectConsistent(ir, ['Base']); + }, + ); + describe('markContestedTitles', () => { const variantsOf = (union: SchemaObject): SchemaObject[] => union.oneOf!; @@ -4634,7 +4703,12 @@ describe('a title two unions give different shapes at different depths of one ro body: (inner) => ({ allOf: [ref('Base')], properties: { marker: obj({ shape: inner }) } }), schemas: base, }, - ])('reads the properties a merged `allOf` inherits through $label with the root (#374)', (merge) => { + { + label: 'a property both members declare, conjoined (Greptile)', + body: (inner) => ({ allOf: [ref('Base'), obj({ meta: marker(inner) })] }), + schemas: (top) => ({ Base: obj({ meta: obj({ shape: top }) }) }), + }, + ])('reads what a merged `allOf` inherits with the root, through $label (#374)', (merge) => { // The issue's `allof.json`: the response merges `Base` with an inline // member into one model, which carries `Base.shape` beside the inline // `marker.shape`, so the two are compared under the response root. From 436616d49beefcf9cf8f324574149fec1089c714 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 29 Oct 2026 16:23:45 -0400 Subject: [PATCH 59/59] fix(parser)!: read a merged allOf as the model it builds when contesting a title Review of #498. The walk read a merged `allOf`'s members as written and then the merged model's properties beside them. A declaration the merge drops was read too: with `allOf: [{ $ref: Base }, { properties: { shape } }]`, Base's `shape` types the field and the inline one is never minted, yet the two were compared, so `Base.shape` lost its bare `Circle` for `CircleBaseShape` though one shape is emitted. A union whose variants the merge flattens into the model's fields contested its titles the same way. Outside the components root, a schema read as one merged `allOf` (readInlineAllOf) is now walked as that model and nothing else: each property mergeAllOfMembers settles on, once per root and as a group of its own. That covers what a `$ref` member's component contributes, a property two members declare as the declaration the merge keeps or conjoins, and a property only variants declare as the union of their declarations. The components root reads its schemas as written, as before: every component is one of them, and a component's own `allOf` is built by extractAllOfModel, not by the merge. The IR, warnings included, of every fixture spec, the github-subset example and four WorkOS specs is unchanged. Those documents hold 32 merged `allOf`s outside their component schemas, 4 of them through a `$ref`. BREAKING CHANGE: outside the component schemas, a union a merged inline `allOf` drops or flattens into its fields no longer contests its titles, so a union beside it keeps its bare title (`Circle` rather than `CircleBaseShape`, relative to the earlier commits of this change). --- docs/architecture/ir-types.md | 2 +- src/parser/inline-models.ts | 91 +++++++++++++++++-------------- test/parser/inline-models.test.ts | 47 ++++++++++++++++ 3 files changed, 98 insertions(+), 42 deletions(-) diff --git a/docs/architecture/ir-types.md b/docs/architecture/ir-types.md index 361a41dd..f9e50785 100644 --- a/docs/architecture/ir-types.md +++ b/docs/architecture/ir-types.md @@ -167,7 +167,7 @@ The optional `keyType` allows non-string map keys (e.g., enum-keyed maps). When A map's value schema is typed the same way an array's items are: with the owning field's name and parent model. When the value is an inline object (or an array or `oneOf` of inline objects), the parser extracts it as a model named by the parent plus the singularized field name, e.g. `layers` on `CreateDrawingRequest` → `CreateDrawingRequestLayer`, matching `shapes` → `CreateDrawingRequestShape` for array items. `additionalProperties: {}` or `true` keeps the value type `unknown`. The singular form is taken only when no sibling property of the same object would itself mint that name — an inline object, an inline enum, an inline union (the names of its object variants and whatever its other variants mint, see below), or an array or map of such (a primitive `email` beside `emails` claims nothing); otherwise array items are `Item` (`DrawingLayersItem` beside a `layer` object with different fields) and map values `Value`; if a sibling claims that name too (a property `layers_item`), the fallback continues to `ArrayItem`, then `ArrayItem2`, and so on (`MemberValue`, `…MemberValue2` for maps), taking the first name no sibling mints. One deliberate exception: when the claiming sibling is an inline object structurally identical to the items or values (same properties, required, types, nested inline schemas and enum values; `description`, `title` and `example` ignored), both fields share one model under the singular name, since one type is right for both and a duplicate would only add noise; the split happens only once the shapes diverge. The choice reads sibling names and schema content only, so document order plays no part. -Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first, a later variant that repeats the title in its union included (`DrawingShapeWithDiameter` beside `CircleDrawingShape`, whose title prefix the first took); only the unions the parser types count, those reached through `properties`, `patternProperties`, `additionalProperties`, `items` and `allOf`/`oneOf`/`anyOf`, so a union under `not`, `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, `propertyNames`, `unevaluatedProperties` or any other keyword never becomes a model and contests no title; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). A component an inline `allOf` merges into one model (`allOf: [{ $ref: Base }, { … }]`, wherever a root holds it) is read with that root as far as the merge reads it, since the model carries the component's properties beside the inline members': every property the merge inherits through the `$ref`, `Base`'s own and its `allOf` chain's, and one an inline member declares too, conjoined with `Base`'s (a `meta` both declare), is compared with the root's unions, so a title `Base.shape` and the inline member's `marker.shape` give different shapes is contested, and with the mark on `Base`'s own variants they are qualified wherever they appear (`CircleBaseShape`, beside `CircleGetDrawingResponseMarkerShape`); a plain reference, a `$ref` under a variant and a union component's own variants are left to the components root. Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. +Inline object variants of a `oneOf`/`anyOf` are extracted as their own models. The first keeps the field's inline name (`DrawingShape`); later ones take a const-discriminator prefix when every variant pins one (`SquareDrawingShape`). A variant with neither is named from its own content, so an SDK user can tell the variants apart and reordering them changes nothing but which one is first: its `title`, trimmed and PascalCased, as the same prefix form (`CircleDrawingShape`; a title the base name already starts with is not doubled, and a title an earlier variant already took is not reused); else the first property, in the variant's own order, that no other inline object variant of the union declares (`DrawingShapeWithSide`, `DrawingShapeWithRadius`); else, only when its property set adds nothing to another variant's, the position counter (`DrawingShape2`). Every candidate also steps around the inline names the parent's other properties mint, so a variant never overwrites another field's model: beside a `shape_with_side` object (`DrawingShapeWithSide`), the `{ side }` variant of `shape` takes the next candidate (`DrawingShape2`), and beside a `shape2` object the counter keeps counting (`DrawingShape3`); each sibling's names are read in isolation, so the choice depends on sibling names and content only. The same reservation is handed down to a union among a field's array items or map values. A `oneOf` without an explicit `discriminator:` whose every variant carries a `title` and pins the same const property is the one shape named differently: its discriminator mapping has always named each variant by its title (`Circle`, `Square`), so the extracted models carry those titles too, and the mapping is read from the same names — such a title is context-free and names one shape wherever it appears, as a component name would, so a second union beside it (at field level, or among a sibling array's items or map's values) that gives the same title a variant of the identical shape (same properties, required, types and nested schemas; `description`, `title` and `example` ignored — the rule array items already use to share a singular sibling's model) shares the model instead of stepping around it. A sibling that gives the same title a different shape reserves it, and because each side reads its siblings in isolation the decision is symmetric: when `Circle` is `{ radius }` on `shape` and `{ diameter }` on `marker`, both fall back to their context-qualified names (`CircleDrawingShape`, `CircleDrawingMarker`: the title as a prefix on the base, else a distinguishing property, else the counter) and neither keeps the bare `Circle`, with the mappings following; when the shapes are identical, both keep `Circle` and one model is emitted. The same holds at any depth of one root, not only between siblings: a title that two unions anywhere under the same root give different shapes — `Circle` as `{ radius }` on `shape` and as `{ diameter }` on `marker.shape` — is contested, decided once per document before any union is named, and every variant carrying it takes its context-qualified name (`CircleDrawingShape`, `CircleDrawingMarkerShape`), wherever in the root it sits and whichever is declared first, a later variant that repeats the title in its union included (`DrawingShapeWithDiameter` beside `CircleDrawingShape`, whose title prefix the first took); only the unions the parser types count, those reached through `properties`, `patternProperties`, `additionalProperties`, `items` and `allOf`/`oneOf`/`anyOf`, so a union under `not`, `if`/`then`/`else`, `$defs`/`definitions`, `dependentSchemas`, `contains`, `prefixItems`, `propertyNames`, `unevaluatedProperties` or any other keyword never becomes a model and contests no title; a union object one root holds in two places — nested under `shape` beside an earlier union that already claimed its title for that group, and again as `wrapper.marker`, where the title is a claim of its own — is read in each place, so its shape is compared with the earlier union's and the title contested when they differ; a title given one shape, or identical shapes, under a root is kept and shared. The component schemas are read as one root, since one pass collects their inline field models into one list and a named union's variants are already compared across every component; a request body, a response body, a response header, a parameter and a webhook payload are each a root of their own, and a title two such roots give different shapes is left to the passes that merge their models (the first owner keeps the name; a later shape is renamed or dropped with a warning). Outside the component schemas, an inline `allOf` merged into one model (`allOf: [{ $ref: Base }, { … }]`) is read as the model every extractor builds from it: its properties as the merge settles them, `Base`'s included (its own and its `allOf` chain's) and a property two members declare as the one declaration the merge keeps or conjoins from both (a `meta` both declare), so a title `Base.shape` and the inline member's `marker.shape` give different shapes is contested, and with the mark on `Base`'s own variants they are qualified wherever they appear (`CircleBaseShape`, beside `CircleGetDrawingResponseMarkerShape`); a declaration the merge drops (an inline `shape` beside `Base`'s, which types the field) and a union whose variants it flattens into the model's fields are no models and contest nothing, and a plain reference, a `$ref` under a variant and a union component's own variants are left to the components root. Should a union variant still find its name taken by a model of another shape in the list it is extracted into, the extractor keeps the first model and warns that the variant's reference resolves to it, rather than dropping the variant silently; the two are compared with the nested models each builds (`deepModelFingerprint`), so an ordinary `circle` and a variant titled `Drawing circle` that both hold a `detail` object — `{ radius }` under one, `{ diameter }` under the other, both named `DrawingCircleDetail` — are two shapes, and the warning names the nested models that differ when the parents' own fields agree. A title that coincides with another field's context-dependent model name falls back the same way. When the union has no const discriminator and every variant carries a distinct title, the first variant takes its title too (`CircleDrawingShape` beside `SquareDrawingShape`) instead of being the only one left bare. The decision reads the variants' content only, and the union TypeRef and every extractor (component fields, request bodies, inline responses, array items, map values) make it through one function, so a reference always has a model behind it. A request body that is itself a `oneOf` of inline objects follows the same rule for its const-less variants (`CreateThingRequest`, `CreateThingRequestWithB`); a variant with a const string property is still named after it, and either kind steps around a name an earlier variant of the body already took (a variant titled `Foo` beside one pinning `kind: foo` gives `FooCreateThingRequest` and `FooCreateThingRequest2`). A response that is itself a `oneOf` goes through the field extractor under the response name, so every inline object variant — const-prefixed, titled, property-named, or nullable (`type: ['object', 'null']`) — is materialized under exactly the name the union references (`GetThingResponse`, `GetThingResponseWithSide`, `SquareGetThingResponse`). Everything nested under a variant is named from the variant, never from the union's field: an inline object under the second variant is `DrawingShapeWithSideCenter` (or `SquareDrawingShapeCenter` under a titled one); a multi-type variant such as `type: ['object', 'array']` keeps its other types beside the model (`union[model, array]`), and the inline items of that array member are `Item` (`DrawingShapeWithSideItem`, with nested objects `DrawingShapeWithSideItemChild`); any other non-object member is named `Value` (an inline enum, for instance). If a property of the variant already claims that model name (`item`, or `items`, which singularizes to the same `Item`), the member falls back to `ArrayItem` / `MemberValue`, then `ArrayItem2`, `ArrayItem3`, and so on. A variant that admits null (`['object', 'null']` or `nullable: true`) keeps its `nullable` wrapper around the model. Every other variant of an inline `oneOf`/`anyOf` — one that is neither a `$ref`, an inline object, nor its `null` variant (see UnionType) — is typed and extracted under a context decided by one function, which the union TypeRef, every extractor and the sibling-name reservation all read, so a reference always has a model behind it. An array or map variant is typed under `_item` / `_value`, so its inline items or values are `Item` / `Value` (`CreateThingRequestFItem` for the items of an array variant of `f`), never the name an object variant of the same union keeps. A variant that would mint a model or enum name under the field's own context — a union nested under the variant (its inline object variants are named from the field's name as the outer ones are, so its first one would otherwise take the outer variant 0's name), a nullable wrapper around an inline object (`oneOf: [{ … }, { type: 'null' }]`), an `allOf`, an inline enum or a `oneOf` of string consts — keeps the field's context when nothing it mints there is claimed, and is otherwise typed under `_variant_`, n its 1-based position among the non-null variants, as a named union's non-object variants are: beside an object variant `GetNoteError400`, the variants of a nested union are `GetNoteError400Variant2` and `BGetNoteError400Variant2`, a nullable object or an `allOf` is `GetNoteError400Variant2`, an inline enum is `GetNoteError400Variant2`; beside only a primitive, a nested union still names its variants `GetNoteError400` and `BGetNoteError400`, and an inline enum keeps `GetNoteError400`. Only context-dependent names take part: a nested `oneOf` whose every variant carries a `title` and pins the same const property names its variants by those titles (`A`, `B`), which are the same under every context — as is everything their models nest (`ALoc` for an inline `loc` of `A`) — so it keeps the field's context and is materialized under the titles — while one of its variants whose title is already taken falls back to a context-qualified name (`AGetNoteError400`) and steps with it. A variant typed under its context steps around every name claimed at that point — the sibling-minted and caller-reserved names the field's own variants step around, the union's inline object variants and everything nested under them, every name an earlier variant minted, and a context-free title an earlier variant kept for a different shape (one kept for the identical shape is shared, as between sibling fields) — so a nested titled variant whose title is already a sibling field's model (`HolderA` beside `Holder.a`), an earlier variant's model (`HolderShapeVariant2`) or an earlier nested variant's title of another shape takes its context-qualified name (`HolderAHolderShape`, `AHolderShape`) exactly as a field-level titled variant does beside the same sibling, and the union never exposes the sibling's fields. A name is claimed when a sibling property mints it, an inline object variant of the union takes it, an object variant's own property mints it at any depth (a `variant` object holding a `2` object mints `GetPetResponseShapeVariant2`, as a `variant_2` object does), or an earlier variant of the union minted it, at any depth too; when the chosen context still mints a claimed name — a model of the variant's own or one at any depth under it, so the `loc` of a nested variant `GetPetResponseShapeVariant2`, `GetPetResponseShapeVariant2Loc`, counts when the object variant's `variant_2_loc` takes that name — it steps on as an array's items do beside a claiming sibling (`_array_item`, `_array_item2`, … for an array or map variant; `_variant__value`, `_variant__member_value`, … for the rest). The rule applies wherever an inline union is typed — a component field, a request body (a `oneOf` body's inline object variants keep their body names, see below; its other variants follow this rule, and a body typed by `schemaToTypeRef` alone, such as an `anyOf` or an array, has every inline model it references materialized the same way), an inline 2xx response, an error body, a discriminated `allOf` + `oneOf` variant's field, array items and map values — and to a nested union's own variants in turn (`GetNoteError400Variant2Variant2` for a union nested two levels down). diff --git a/src/parser/inline-models.ts b/src/parser/inline-models.ts index 77f4a27e..81b67556 100644 --- a/src/parser/inline-models.ts +++ b/src/parser/inline-models.ts @@ -769,16 +769,19 @@ export function isContestedTitle(variant: SchemaObject): boolean { * `webhooks` or `components` alike — is a root of its own. The same title * given two shapes by two roots is not contested here: the passes that merge * the roots' models decide it (the first owner keeps the name, and a later - * shape is renamed or dropped with a warning). A component an inline `allOf` - * merges into one model (`readInlineAllOf`: `'merged'`) is read with that - * root too, as far as the merge reads it: the model carries the component's - * properties beside its inline members' (`mergeAllOfMembers`), so its - * properties — every one it inherits through a `$ref` member, and one an - * inline member declares too, conjoined with the component's — are walked - * under the root, each once (workos/oagen#374: as a response, `allOf: - * [{ $ref: Base }, { … }]` kept a title `Base.shape` and the inline - * `marker.shape` give different shapes, and one union resolved to the - * other's models). + * shape is renamed or dropped with a warning). Outside the components root, + * an inline `allOf` merged into one model (`readInlineAllOf`: `'merged'`) is + * read as that model, the one every extractor builds from it: its + * properties as `mergeAllOfMembers` settles them, each once per root, a + * `$ref` member's component's included — so the component is read with + * the root as far as the merge reads it — and nothing else of it. A + * declaration the merge drops, or a union whose variants it flattens into + * the model's fields, is no model and contests nothing (workos/oagen#374: + * as a response, `allOf: [{ $ref: Base }, { … }]` kept a title that + * `Base.shape` and the inline `marker.shape` give different shapes, and one + * union resolved to the other's models). The components root reads its + * schemas as written: every component is one of them, and a component's own + * `allOf` is built by its own reader (`extractAllOfModel`). * * Within a root, the walk follows the keywords the parser types a schema * through only — `properties`, `patternProperties`, `additionalProperties`, @@ -825,12 +828,10 @@ export function markContestedTitles(document: unknown): void { if (!isRecord(document)) return; const components = isRecord(document['components']) ? document['components'] : undefined; const schemas = components?.['schemas']; + if (isRecord(schemas)) markContestedTitlesUnderRoot(Object.values(schemas).filter(isSchemaRecord)); // What a merged `allOf`'s `$ref` members resolve against: this document's // components only, none when it declares none. const componentSchemas = isRecord(schemas) ? (schemas as Record) : {}; - if (isRecord(schemas)) { - markContestedTitlesUnderRoot(Object.values(schemas).filter(isSchemaRecord), componentSchemas); - } for (const root of schemaRootsOf(document, components)) markContestedTitlesUnderRoot([root], componentSchemas); } @@ -969,13 +970,14 @@ function schemaRootsOf( * title no claim there, an earlier variant having claimed it) is read again * under a property that starts a group of its own, where the same title is * a claim — and a schema that refers back to itself is stopped by the - * ancestors on the path to it, which ends the walk. `componentSchemas` - * resolves the `$ref` members of a merged `allOf`, whose model's properties - * are read here too (`mergedAllOfProperties`). + * ancestors on the path to it, which ends the walk. `componentSchemas`, given + * for every root but the components', resolves the `$ref` members of an + * `allOf` merged into one model, which is read as that model + * (`mergedAllOfProperties`); without it every schema is read as written. */ function markContestedTitlesUnderRoot( roots: readonly SchemaObject[], - componentSchemas: Record, + componentSchemas?: Record, ): void { const byTitle = new Map; variants: SchemaObject[] }>(); const visitedByGroup = new WeakMap, WeakSet>(); @@ -1011,6 +1013,20 @@ function markContestedTitlesUnderRoot( // (`foldSiblingProperties`). The marks go on the document's variants, // which every later fold's copies are read through (`isContestedTitle`). const folded = foldSiblingProperties(schema); + // An `allOf` merged into one model is read as that model, whatever its + // members hold: each property the merge settles on, a group of its own + // like any property, and nothing else. + const merged = componentSchemas && mergedAllOfProperties(folded, componentSchemas); + if (merged) { + ancestors.add(schema); + for (const property of merged) { + if (mergedRead.has(property)) continue; + mergedRead.add(property); + walk(property); + } + ancestors.delete(schema); + return; + } for (const [variant, title] of titleMappedVariantNames(folded)) { const entry = byTitle.get(title) ?? { shapes: new Set(), variants: [] }; if (!claims.has(title)) { @@ -1042,16 +1058,6 @@ function markContestedTitlesUnderRoot( for (const sub of value) if (isSchemaRecord(sub)) walk(sub, inherited); } } - // A `$ref` member of an `allOf` merged into one model is no reference: - // the model carries what the merge reads through it beside its inline - // members' properties, conjoined with theirs where both declare one, so - // the model's properties are read with this root, each a group of its - // own like any property. - for (const property of mergedAllOfProperties(folded, componentSchemas)) { - if (mergedRead.has(property)) continue; - mergedRead.add(property); - walk(property); - } ancestors.delete(schema); }; for (const root of roots) walk(root); @@ -1061,21 +1067,24 @@ function markContestedTitlesUnderRoot( } /** - * Every always-applying property of the one model an inline `allOf` read as - * merged (`readInlineAllOf`: `'merged'`) builds (`mergeAllOfMembers`): those - * it inherits through a `$ref` member, a component's own `allOf` chain - * included; those two members declare, conjoined into one the model owns (a + * The properties of the one model an inline `allOf` read as merged + * (`readInlineAllOf`: `'merged'`) builds, as `mergeAllOfMembers` settles + * them and every extractor builds the model from them: those a `$ref` + * member's component contributes, its own `allOf` chain included; a property + * two members declare as the one declaration the merge keeps or conjoins (a * `meta` both `Base` and an inline member declare carries `Base`'s - * `meta.shape` beside the inline `meta.marker`); and an inline member's own, - * which the walk also reads where it is written. None for any other schema, - * nor a property only variants declare, which only an inline member's - * variants can and the walk reads there. A `$ref` the merge does not follow - * (one under a variant), and a union a component carries, which the merge - * leaves to that component, add nothing. - */ -function mergedAllOfProperties(schema: SchemaObject, componentSchemas: Record): SchemaObject[] { - if (!schema.allOf || readInlineAllOf(schema.allOf, schema, componentSchemas) !== 'merged') return []; - return mergeAllOfMembers(schema, componentSchemas).properties.flatMap((p) => (p.conditional ? [] : [p.schema])); + * `meta.shape` beside the inline `meta.marker`); a property only variants + * declare as the union of their declarations. A declaration the merge drops, + * a union whose variants it flattens into the model, a `$ref` it does not + * follow (one under a variant) and a union a component carries are none of + * them. `undefined` for any other schema. + */ +function mergedAllOfProperties( + schema: SchemaObject, + componentSchemas: Record, +): SchemaObject[] | undefined { + if (!schema.allOf || readInlineAllOf(schema.allOf, schema, componentSchemas) !== 'merged') return undefined; + return mergeAllOfMembers(schema, componentSchemas).properties.map((p) => p.schema); } /** diff --git a/test/parser/inline-models.test.ts b/test/parser/inline-models.test.ts index 8df96b1a..3c4976e1 100644 --- a/test/parser/inline-models.test.ts +++ b/test/parser/inline-models.test.ts @@ -4539,6 +4539,43 @@ describe('a title two unions give different shapes at different depths of one ro }, ); + it.each<{ label: string; member: SchemaObject }>([ + { label: "a `shape` the merge drops for `Base`'s", member: obj({ shape: byDiameter() }) }, + { label: 'a union whose variants the merge flattens into the model', member: byDiameter() }, + ])('keeps the bare titles for $label, which is no model (#374 review)', async ({ member }) => { + // The merged model holds `Base.shape` alone: the inline member's union is + // dropped (the first declaration types the property) or flattened into + // the model's optional fields, so it is minted nowhere and contests + // nothing, in a response as in a request body. + const schema: SchemaObject = { allOf: [{ $ref: '#/components/schemas/Base' }, member] }; + for (const [body, root] of [ + [op('getDrawing', { responses: { '200': { description: 'ok', ...json(schema) } } }), 'GetDrawingResponse'], + [ + { + paths: { + '/drawings': { + post: { + operationId: 'createDrawing', + requestBody: json(schema), + responses: { '204': { description: 'ok' } }, + }, + }, + }, + }, + 'CreateDrawingRequest', + ], + ] as const) { + const ir = await parseWithoutWarnings(withBase(body)); + const types = fieldTypesByModel(ir.models); + expect(types['Base']).toEqual({ id: str, shape: unionOf('Circle', 'Square') }); + expect(types[root]).toMatchObject({ id: str, shape: unionOf('Circle', 'Square') }); + expect(types['Circle']).toEqual({ ...kind('circle'), radius: num }); + expect(types['Square']).toEqual({ ...kind('square'), side: num }); + expect(modelNames(ir)).toEqual(['Base', 'Circle', root, 'Square'].sort()); + expectConsistent(ir, ['Base']); + } + }); + describe('markContestedTitles', () => { const variantsOf = (union: SchemaObject): SchemaObject[] => union.oneOf!; @@ -4734,6 +4771,16 @@ describe('a title two unions give different shapes at different depths of one ro body: (inner) => ({ allOf: [ref('Base'), marker(inner)] }), schemas: (top) => ({ Base: top }), }, + { + label: 'a declaration the merge drops for the first one (review)', + body: (inner) => ({ allOf: [ref('Base'), obj({ shape: inner })] }), + schemas: base, + }, + { + label: "a union whose variants the merge flattens into the model's fields (review)", + body: (inner) => ({ allOf: [ref('Base'), inner] }), + schemas: base, + }, ])('reads nothing the merge does not inherit: $label (#374)', (merge) => { // Each of these is left to its component's model, which the components // root reads (`mergeAllOfMembers`), so no title is contested here.