diff --git a/.chronus/changes/fix-import-anyof-ref-and-object-2026-3-16.md b/.chronus/changes/fix-import-anyof-ref-and-object-2026-3-16.md new file mode 100644 index 00000000000..43ea7d569bf --- /dev/null +++ b/.chronus/changes/fix-import-anyof-ref-and-object-2026-3-16.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +[importer] Fix `anyOf` with `$ref` and inline object being incorrectly imported as a model instead of a union. diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts index 81f04d3c184..e9821bbd638 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts @@ -316,8 +316,13 @@ function unwrapSingleAnyOfOneOf( return (member as any).type !== "null"; }); - // If there's exactly one meaningful inline member AND it's an object type, unwrap it - if (meaningfulInlineMembers.length === 1) { + // Check if there are any $ref members in the union alongside the inline members + const hasRefMembers = unionMembers.some((member) => "$ref" in member); + + // If there's exactly one meaningful inline member AND it's an object type + // AND there are no $ref members alongside it, unwrap it. + // If $ref members are present, the schema is truly a union (e.g. $ref + object). + if (meaningfulInlineMembers.length === 1 && !hasRefMembers) { const member = meaningfulInlineMembers[0]; // Only unwrap if the member is an object schema if (!("$ref" in member) && (member.type === "object" || member.properties)) { @@ -386,8 +391,13 @@ function getTypeSpecKind(schema: Refable): TypeSpecDataT return (member as any).type !== "null"; }); - // If there's exactly one meaningful inline member AND it's an object type, treat it as a model - if (meaningfulInlineMembers.length === 1) { + // Check if there are any $ref members in the union alongside the inline members + const hasRefMembers = unionMembers.some((member) => "$ref" in member); + + // If there's exactly one meaningful inline member AND it's an object type + // AND there are no $ref members alongside it, treat it as a model. + // If $ref members are present, the schema is truly a union (e.g. $ref + object). + if (meaningfulInlineMembers.length === 1 && !hasRefMembers) { const member = meaningfulInlineMembers[0]; // Only unwrap if the member is an object schema if (!("$ref" in member) && (member.type === "object" || member.properties)) { diff --git a/packages/openapi3/test/tsp-openapi3/anyof-ref-and-object.test.ts b/packages/openapi3/test/tsp-openapi3/anyof-ref-and-object.test.ts new file mode 100644 index 00000000000..a144bd0a064 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/anyof-ref-and-object.test.ts @@ -0,0 +1,94 @@ +import { dereference } from "@scalar/openapi-parser"; +import { OpenAPI } from "@scalar/openapi-types"; +import { beforeAll, describe, expect, it } from "vitest"; +import { generateDataType } from "../../src/cli/actions/convert/generators/generate-model.js"; +import { TypeSpecDataTypes, TypeSpecUnion } from "../../src/cli/actions/convert/interfaces.js"; +import { transformComponentSchemas } from "../../src/cli/actions/convert/transforms/transform-component-schemas.js"; +import { createContext } from "../../src/cli/actions/convert/utils/context.js"; +import { OpenAPI3Document } from "../../src/types.js"; + +describe("tsp-openapi: anyOf with $ref and inline object should produce union", () => { + let doc: OpenAPI.Document<{}>; + + beforeAll(async () => { + const { specification } = await dereference({ + openapi: "3.1.0", + info: { title: "Test", version: "1.0.0" }, + paths: {}, + components: { + schemas: { + VoiceIdsOrCustomVoice: { + title: "Voice", + description: "A built-in voice name or a custom voice reference.", + anyOf: [ + { $ref: "#/components/schemas/VoiceIdsShared" }, + { + type: "object", + description: "Custom voice reference.", + additionalProperties: false, + required: ["id"], + properties: { + id: { type: "string" }, + }, + }, + ], + }, + VoiceIdsShared: { + anyOf: [ + { type: "string" }, + { + type: "string", + enum: ["alloy", "ash", "ballad", "coral", "echo", "sage"], + }, + ], + }, + }, + }, + }); + if (!specification) { + throw new Error("Failed to dereference OpenAPI document"); + } + doc = specification; + }); + + it("should generate a union (not a model) for anyOf with $ref and inline object", () => { + const context = createContext(doc as OpenAPI3Document); + const types: TypeSpecDataTypes[] = []; + transformComponentSchemas(context, types); + + const type = types.find((t) => t.name === "VoiceIdsOrCustomVoice"); + expect(type).toBeDefined(); + expect(type!.kind).toBe("union"); + }); + + it("should generate TypeSpec union code containing both the ref and the inline object", () => { + const context = createContext(doc as OpenAPI3Document); + const types: TypeSpecDataTypes[] = []; + transformComponentSchemas(context, types); + + const union = types.find( + (t) => t.name === "VoiceIdsOrCustomVoice" && t.kind === "union", + ) as TypeSpecUnion; + expect(union).toBeDefined(); + + const generatedCode = generateDataType(union, context); + + // Should be a union, not a model + expect(generatedCode).toContain("union VoiceIdsOrCustomVoice"); + expect(generatedCode).not.toContain("model VoiceIdsOrCustomVoice"); + // Should reference the VoiceIdsShared type + expect(generatedCode).toContain("VoiceIdsShared"); + }); + + it("should preserve description from parent schema on the union", () => { + const context = createContext(doc as OpenAPI3Document); + const types: TypeSpecDataTypes[] = []; + transformComponentSchemas(context, types); + + const union = types.find( + (t) => t.name === "VoiceIdsOrCustomVoice" && t.kind === "union", + ) as TypeSpecUnion; + expect(union).toBeDefined(); + expect(union.doc).toContain("A built-in voice name or a custom voice reference."); + }); +});