From c3a86d425ea20494e845cf547e6942d4e848cd9f Mon Sep 17 00:00:00 2001 From: Jirka D Date: Mon, 7 Sep 2026 20:02:20 +0200 Subject: [PATCH] fix: keep the referenced type when a $ref has complex siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A schema carrying a $ref next to `not`, `allOf`, `oneOf` or `anyOf` is routed to the complex parser, which parses the complex keyword and drops the reference. With `not` — the one complex keyword that cannot be expressed in TypeScript — the property collapses to `any`: constrainedRef: $ref: '#/components/schemas/Status' not: { enum: [STATUS_UNSPECIFIED] } // before constrainedRef?: any; // after constrainedRef?: Status; Sibling keywords beside a $ref are legal in OpenAPI 3.1, and are how protoc-gen-connect-openapi renders a protobuf enum field that carries a protovalidate `not_in` rule, so a document generated from protobuf loses the type on every such field: 156 properties in a 678-operation document. The reference is now parsed alongside the complex content, with `any` members filtered out of the intersection the same way AllOfSchemaParser already does. A $ref with only annotation siblings (title, description) was already handled and is unchanged. --- .../base-schema-parsers/complex.ts | 36 +++++++++++------- .../__snapshots__/basic.test.ts.snap | 36 ++++++++++++++++++ .../ref-with-sibling-keywords/basic.test.ts | 33 +++++++++++++++++ .../ref-with-sibling-keywords/schema.json | 37 +++++++++++++++++++ 4 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 tests/spec/ref-with-sibling-keywords/__snapshots__/basic.test.ts.snap create mode 100644 tests/spec/ref-with-sibling-keywords/basic.test.ts create mode 100644 tests/spec/ref-with-sibling-keywords/schema.json diff --git a/src/schema-parser/base-schema-parsers/complex.ts b/src/schema-parser/base-schema-parsers/complex.ts index 8767d084..f066c24e 100644 --- a/src/schema-parser/base-schema-parsers/complex.ts +++ b/src/schema-parser/base-schema-parsers/complex.ts @@ -18,6 +18,14 @@ export class ComplexSchemaParser extends MonoSchemaParser { complexType ](this.schema); + // A $ref alongside `not`, `allOf` and friends is a sibling in OpenAPI 3.1: the + // keywords apply on top of the referenced schema. Parsing the reference too keeps + // the type, which would otherwise be lost with the complex keyword that cannot be + // expressed in TypeScript. + const shouldParseSimpleSchema = + this.schemaUtils.getInternalSchemaType(simpleSchema) === + SCHEMA_TYPES.OBJECT || this.schemaUtils.isRefSchema(simpleSchema); + return { ...(typeof this.schema === "object" ? this.schema : {}), $schemaPath: this.schemaPath.slice(), @@ -33,19 +41,21 @@ export class ComplexSchemaParser extends MonoSchemaParser { ), content: this.config.Ts.IntersectionType( - compact([ - this.config.Ts.ExpressionGroup(complexSchemaContent), - this.schemaUtils.getInternalSchemaType(simpleSchema) === - SCHEMA_TYPES.OBJECT && - this.config.Ts.ExpressionGroup( - this.schemaParserFabric - .createSchemaParser({ - schema: simpleSchema, - schemaPath: this.schemaPath, - }) - .getInlineParseContent(), - ), - ]), + this.schemaUtils + .filterSchemaContents( + compact([ + complexSchemaContent, + shouldParseSimpleSchema && + this.schemaParserFabric + .createSchemaParser({ + schema: simpleSchema, + schemaPath: this.schemaPath, + }) + .getInlineParseContent(), + ]), + (content) => content !== this.config.Ts.Keyword.Any, + ) + .map((content) => this.config.Ts.ExpressionGroup(content)), ) || this.config.Ts.Keyword.Any, }; } diff --git a/tests/spec/ref-with-sibling-keywords/__snapshots__/basic.test.ts.snap b/tests/spec/ref-with-sibling-keywords/__snapshots__/basic.test.ts.snap new file mode 100644 index 00000000..5c061d2e --- /dev/null +++ b/tests/spec/ref-with-sibling-keywords/__snapshots__/basic.test.ts.snap @@ -0,0 +1,36 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`basic > ref-with-sibling-keywords 1`] = ` +"/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +export enum Status { + STATUS_UNSPECIFIED = "STATUS_UNSPECIFIED", + ACTIVE = "ACTIVE", + PAUSED = "PAUSED", +} + +export interface Account { + plainRef?: Status; + /** + * annotatedRef + * a reference carrying annotations + */ + annotatedRef?: Status; + /** + * constrainedRef + * a reference the value must match, minus one member + */ + constrainedRef?: Status; +} +" +`; diff --git a/tests/spec/ref-with-sibling-keywords/basic.test.ts b/tests/spec/ref-with-sibling-keywords/basic.test.ts new file mode 100644 index 00000000..7c261437 --- /dev/null +++ b/tests/spec/ref-with-sibling-keywords/basic.test.ts @@ -0,0 +1,33 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { generateApi } from "../../../src/index.js"; + +describe("basic", async () => { + let tmpdir = ""; + + beforeAll(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "swagger-typescript-api")); + }); + + afterAll(async () => { + await fs.rm(tmpdir, { recursive: true }); + }); + + test("ref-with-sibling-keywords", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: false, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/ref-with-sibling-keywords/schema.json b/tests/spec/ref-with-sibling-keywords/schema.json new file mode 100644 index 00000000..dd0439cf --- /dev/null +++ b/tests/spec/ref-with-sibling-keywords/schema.json @@ -0,0 +1,37 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Ref with sibling keywords", + "version": "1.0.0" + }, + "components": { + "schemas": { + "Status": { + "type": "string", + "enum": ["STATUS_UNSPECIFIED", "ACTIVE", "PAUSED"] + }, + "Account": { + "type": "object", + "properties": { + "plainRef": { + "$ref": "#/components/schemas/Status" + }, + "annotatedRef": { + "$ref": "#/components/schemas/Status", + "title": "annotatedRef", + "description": "a reference carrying annotations" + }, + "constrainedRef": { + "$ref": "#/components/schemas/Status", + "title": "constrainedRef", + "description": "a reference the value must match, minus one member", + "not": { + "enum": ["STATUS_UNSPECIFIED"] + } + } + } + } + } + }, + "paths": {} +}