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/copilot-fix-8513-2025-8-23-9-38-32.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/openapi3"
---

[converter] anyOf/oneOf type + type:null gets imported properly and maintains decorators, documentation,...
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { printIdentifier } from "@typespec/compiler";
import { OpenAPI3Schema, Refable } from "../../../../types.js";
import { OpenAPI3Schema, OpenAPISchema3_1, Refable } from "../../../../types.js";
import {
TypeSpecDataTypes,
TypeSpecDecorator,
TypeSpecEnum,
TypeSpecModel,
TypeSpecModelProperty,
TypeSpecUnion,
} from "../interfaces.js";
Expand All @@ -18,7 +18,7 @@ import { getScopeAndName } from "../utils/get-scope-and-name.js";
* @param schemas
* @returns
*/
export function transformComponentSchemas(context: Context, models: TypeSpecModel[]): void {
export function transformComponentSchemas(context: Context, models: TypeSpecDataTypes[]): void {
const schemas = context.openApi3Doc.components?.schemas;
if (!schemas) return;

Expand Down Expand Up @@ -115,17 +115,64 @@ export function transformComponentSchemas(context: Context, models: TypeSpecMode
}

function populateUnion(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void {
// Extract description and decorators from meaningful union members
const unionMetadata = extractUnionMetadata(schema);

const union: TypeSpecUnion = {
kind: "union",
...getScopeAndName(name),
decorators: getDecoratorsForSchema(schema),
doc: schema.description,
decorators: [...getDecoratorsForSchema(schema), ...unionMetadata.decorators],
doc: schema.description ?? unionMetadata.description,
schema,
};

types.push(union);
}

/**
* Extracts meaningful description and decorators from union members.
* Handles anyOf/oneOf with null, and type arrays like ["string", "null"].
*/
function extractUnionMetadata(schema: OpenAPI3Schema | OpenAPISchema3_1): {
description?: string;
decorators: TypeSpecDecorator[];
} {
// Handle anyOf/oneOf scenarios
const unionMembers = schema.anyOf || schema.oneOf;
if (unionMembers) {
const meaningfulMembers = unionMembers.filter((member) => {
if ("$ref" in member) return true; // Redundant, but help the type system understand whether it's a ref or not, and give us access to the type property
return member.type !== "null"; // Non-null types are meaningful
});

// If we have exactly one meaningful member and at least one null, extract from the meaningful one
if (meaningfulMembers.length === 1 && unionMembers.length > meaningfulMembers.length) {
const meaningfulMember = meaningfulMembers[0];
if (!("$ref" in meaningfulMember)) {
return {
description: meaningfulMember.description,
decorators: getDecoratorsForSchema(meaningfulMember),
};
}
}
}

// Handle type array scenarios like type: ["string", "null"]
if (Array.isArray(schema.type)) {
const nonNullTypes = schema.type.filter((t) => t !== "null");
// If we have exactly one non-null type, this is essentially a nullable version of that type
// The schema itself should contain the relevant constraints/description for the non-null type
if (nonNullTypes.length === 1) {
// Create a schema without the null type to extract decorators for the non-null part
const nonNullSchema = { ...schema, type: nonNullTypes[0] };
return { decorators: getDecoratorsForSchema(nonNullSchema) };
// The description should already be on the main schema, so we don't override it here
}
}

return { decorators: [] };
}

function populateScalar(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void {
types.push({
kind: "scalar",
Expand Down Expand Up @@ -225,7 +272,13 @@ function getTypeSpecKind(schema: OpenAPI3Schema): TypeSpecDataTypes["kind"] {

if (schema.enum && schema.type === "string" && !schema.nullable) {
return "enum";
} else if (schema.anyOf || schema.oneOf || schema.enum || schema.nullable) {
} else if (
schema.anyOf ||
schema.oneOf ||
schema.enum ||
schema.nullable ||
(Array.isArray(schema.type) && schema.type.includes("null"))
) {
return "union";
} else if (
schema.type === "object" ||
Expand Down
20 changes: 14 additions & 6 deletions packages/openapi3/src/cli/actions/convert/utils/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { printIdentifier } from "@typespec/compiler";
import { ExtensionKey } from "@typespec/openapi";
import { Extensions, OpenAPI3Parameter, OpenAPI3Schema, Refable } from "../../../../types.js";
import {
Extensions,
OpenAPI3Parameter,
OpenAPI3Schema,
OpenAPISchema3_1,
Refable,
} from "../../../../types.js";
import { TSValue, TypeSpecDecorator } from "../interfaces.js";

const validLocations = ["header", "query", "path"];
Expand Down Expand Up @@ -142,7 +148,9 @@ function getHeaderArgs({ explode }: OpenAPI3Parameter): TSValue | undefined {
return undefined;
}

export function getDecoratorsForSchema(schema: Refable<OpenAPI3Schema>): TypeSpecDecorator[] {
export function getDecoratorsForSchema(
schema: Refable<OpenAPI3Schema | OpenAPISchema3_1>,
): TypeSpecDecorator[] {
const decorators: TypeSpecDecorator[] = [];

if ("$ref" in schema) {
Expand Down Expand Up @@ -196,11 +204,11 @@ function createTSValue(value: string): TSValue {
return { __kind: "value", value };
}

function getOneOfSchemaDecorators(schema: OpenAPI3Schema): TypeSpecDecorator[] {
function getOneOfSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1): TypeSpecDecorator[] {
return [{ name: "oneOf", args: [] }];
}

function getArraySchemaDecorators(schema: OpenAPI3Schema) {
function getArraySchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) {
const decorators: TypeSpecDecorator[] = [];

if (typeof schema.minItems === "number") {
Expand All @@ -214,7 +222,7 @@ function getArraySchemaDecorators(schema: OpenAPI3Schema) {
return decorators;
}

function getNumberSchemaDecorators(schema: OpenAPI3Schema) {
function getNumberSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) {
const decorators: TypeSpecDecorator[] = [];

if (typeof schema.minimum === "number") {
Expand Down Expand Up @@ -246,7 +254,7 @@ const knownStringFormats = new Set([
"uri",
]);

function getStringSchemaDecorators(schema: OpenAPI3Schema) {
function getStringSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) {
const decorators: TypeSpecDecorator[] = [];

if (typeof schema.minLength === "number") {
Expand Down
181 changes: 181 additions & 0 deletions packages/openapi3/test/tsp-openapi3/union-anyof-with-null.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import OpenAPIParser from "@apidevtools/swagger-parser";
import { OpenAPI } from "openapi-types";
import { beforeAll, describe, expect, it } from "vitest";
import { generateDataType } from "../../src/cli/actions/convert/generators/generate-model.js";
import { 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: union anyOf with null", () => {
let parser: OpenAPIParser;
let doc: OpenAPI.Document<{}>;

beforeAll(async () => {
parser = new OpenAPIParser();
doc = await parser.bundle({
openapi: "3.1.0",
info: { title: "Test", version: "1.0.0" },
paths: {},
components: {
schemas: {
ReasoningEffort: {
anyOf: [
{
type: "string",
enum: ["minimal", "low", "medium", "high"],
default: "medium",
description: `Constrains effort on reasoning for
[reasoning models](https://platform.openai.com/docs/guides/reasoning).
Currently supported values are \`minimal\`, \`low\`, \`medium\`, and \`high\`. Reducing
reasoning effort can result in faster responses and fewer tokens used
on reasoning in a response.`,
},
{
type: "null",
},
],
},
NullableNumberWithConstraints: {
oneOf: [
{
type: "number",
minimum: 0,
maximum: 100,
description: "A percentage value between 0 and 100",
},
{
type: "null",
},
],
},
ModelOrNull: {
anyOf: [
{
$ref: "#/components/schemas/SomeModel",
},
{
type: "null",
},
],
},
SomeModel: {
type: "object",
properties: {
id: { type: "string" },
},
},
},
},
});
});

it("generates proper TypeSpec code with description and null", () => {
const context = createContext(parser, doc as OpenAPI3Document);
const types: TypeSpecUnion[] = [];
transformComponentSchemas(context, types);

const union = types.find(
(t) => t.name === "ReasoningEffort" && t.kind === "union",
) as TypeSpecUnion;
expect(union).toBeDefined();
expect(union.doc).toBeTruthy();

// Generate the actual TypeSpec code
const generatedCode = generateDataType(union, context);

// Verify the generated code contains all expected elements
expect(generatedCode).toContain("/**");
expect(generatedCode).toContain("Constrains effort on reasoning");
expect(generatedCode).toContain("*/");
expect(generatedCode).toContain("union ReasoningEffort {");
expect(generatedCode).toContain('"minimal"');
expect(generatedCode).toContain('"low"');
expect(generatedCode).toContain('"medium"');
expect(generatedCode).toContain('"high"');
expect(generatedCode).toContain("null");
expect(generatedCode).toContain("}");
});

it("preserves description from oneOf members with constraints when one is null", () => {
const context = createContext(parser, doc as OpenAPI3Document);
const types: TypeSpecUnion[] = [];
transformComponentSchemas(context, types);

const union = types.find(
(t) => t.name === "NullableNumberWithConstraints" && t.kind === "union",
) as TypeSpecUnion;
expect(union).toBeDefined();
expect(union.doc).toBe("A percentage value between 0 and 100");

// Check that decorators from the number schema are preserved
expect(union.decorators).toBeDefined();
const hasMinConstraint = union.decorators.some((d) => d.name === "minValue");
const hasMaxConstraint = union.decorators.some((d) => d.name === "maxValue");
expect(hasMinConstraint).toBe(true);
expect(hasMaxConstraint).toBe(true);
});

it("handles reference + null anyOf correctly", () => {
const context = createContext(parser, doc as OpenAPI3Document);
const types: TypeSpecUnion[] = [];
transformComponentSchemas(context, types);

const union = types.find(
(t) => t.name === "ModelOrNull" && t.kind === "union",
) as TypeSpecUnion;
expect(union).toBeDefined();
// For reference + null, there's no description on the ref itself, so doc should be undefined
expect(union.doc).toBeUndefined();

const generatedCode = generateDataType(union, context);
expect(generatedCode).toContain("SomeModel");
expect(generatedCode).toContain("null");
});

it("handles oneOf with null type array properly", async () => {
const docWithTypeArray = await parser.bundle({
openapi: "3.1.0",
info: { title: "Test", version: "1.0.0" },
paths: {},
components: {
schemas: {
NullableString: {
type: ["string", "null"],
enum: ["value1", "value2", null],
default: "value1",
description: "A nullable string with enum values",
},
NullableInteger: {
type: ["integer", "null"],
minimum: 1,
maximum: 10,
description: "A nullable integer between 1 and 10",
},
},
},
});

const context = createContext(parser, docWithTypeArray as OpenAPI3Document);
const types: TypeSpecUnion[] = [];
transformComponentSchemas(context, types);

const stringUnion = types.find(
(t) => t.name === "NullableString" && t.kind === "union",
) as TypeSpecUnion;
expect(stringUnion).toBeDefined();
expect(stringUnion.doc).toBe("A nullable string with enum values");

const integerUnion = types.find(
(t) => t.name === "NullableInteger" && t.kind === "union",
) as TypeSpecUnion;
expect(integerUnion).toBeDefined();
expect(integerUnion.doc).toBe("A nullable integer between 1 and 10");

// Check that constraints are preserved for the integer union
const hasMinConstraint = integerUnion.decorators.some((d) => d.name === "minValue");
const hasMaxConstraint = integerUnion.decorators.some((d) => d.name === "maxValue");
expect(hasMinConstraint).toBe(true);
expect(hasMaxConstraint).toBe(true);
});
});
Loading