Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/fix-import-anyof-ref-and-object-2026-3-16.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -386,8 +391,13 @@ function getTypeSpecKind(schema: Refable<SupportedOpenAPISchema>): 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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.");
});
});
Loading