Skip to content

Commit 4d6a5fe

Browse files
Copilotbaywettimotheeguerin
authored
Fix OpenAPI import: preserve description and decorators for anyOf/oneOf with null (#8514)
The OpenAPI 3.1 import tool was losing description and decorator information when converting schemas with `anyOf`/`oneOf` containing a definition + `null` type. This is a common pattern in OpenAPI 3.1 as an equivalent to `nullable: true` from OpenAPI 3.0. **Before this fix:** ```yaml # OpenAPI 3.1 schema ReasoningEffort: anyOf: - type: string enum: [minimal, low, medium, high] default: medium description: "Constrains effort on reasoning for reasoning models..." - type: 'null' ``` Generated incomplete TypeSpec: ```typescript union ReasoningEffort { "minimal" | "low" | "medium" | "high", null, } ``` **After this fix:** ```typescript /** * Constrains effort on reasoning for reasoning models... */ union ReasoningEffort { "minimal" | "low" | "medium" | "high" = "medium", null, } ``` ## Changes Made 1. **Enhanced union metadata extraction**: Added logic to identify and extract descriptions and decorators from meaningful (non-null) members in `anyOf`/`oneOf` scenarios. 2. **Support for type arrays**: Fixed handling of OpenAPI 3.1 type arrays like `["string", "null"]` to properly preserve constraints and descriptions. 3. **Improved type identification**: Updated `getTypeSpecKind` to correctly identify type arrays containing "null" as unions rather than scalars. ## Test Coverage Added comprehensive test suite covering: - `anyOf` with definition + null - `oneOf` with constraints + null - Type arrays like `["integer", "null"]` - Reference types with null - Edge cases and backward compatibility All existing tests continue to pass (1609 tests). Fixes #8513. > [!WARNING] > > <details> > <summary>Firewall rules blocked me from connecting to one or more addresses (expand for details)</summary> > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `telemetry.astro.build` > - Triggering command: `node /home/REDACTED/work/typespec/typespec/website/node_modules/.bin/../astro/astro.js build` (dns block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/microsoft/typespec/settings/copilot/coding_agent) (admins only) > > </details> <!-- START COPILOT CODING AGENT TIPS --> --- 💬 Share your feedback on Copilot coding agent for the chance to win a $200 gift card! Click [here](https://survey3.medallia.com/?EAHeSx-AP01bZqG0Ld9QLQ) to start the survey. --------- Signed-off-by: Vincent Biret <vibiret@microsoft.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> Co-authored-by: Vincent Biret <vibiret@microsoft.com> Co-authored-by: Timothee Guerin <tiguerin@microsoft.com>
1 parent f233915 commit 4d6a5fe

File tree

4 files changed

+261
-12
lines changed

4 files changed

+261
-12
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/openapi3"
5+
---
6+
7+
[converter] anyOf/oneOf type + type:null gets imported properly and maintains decorators, documentation,...

packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { printIdentifier } from "@typespec/compiler";
2-
import { OpenAPI3Schema, Refable } from "../../../../types.js";
2+
import { OpenAPI3Schema, OpenAPISchema3_1, Refable } from "../../../../types.js";
33
import {
44
TypeSpecDataTypes,
5+
TypeSpecDecorator,
56
TypeSpecEnum,
6-
TypeSpecModel,
77
TypeSpecModelProperty,
88
TypeSpecUnion,
99
} from "../interfaces.js";
@@ -18,7 +18,7 @@ import { getScopeAndName } from "../utils/get-scope-and-name.js";
1818
* @param schemas
1919
* @returns
2020
*/
21-
export function transformComponentSchemas(context: Context, models: TypeSpecModel[]): void {
21+
export function transformComponentSchemas(context: Context, models: TypeSpecDataTypes[]): void {
2222
const schemas = context.openApi3Doc.components?.schemas;
2323
if (!schemas) return;
2424

@@ -115,17 +115,64 @@ export function transformComponentSchemas(context: Context, models: TypeSpecMode
115115
}
116116

117117
function populateUnion(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void {
118+
// Extract description and decorators from meaningful union members
119+
const unionMetadata = extractUnionMetadata(schema);
120+
118121
const union: TypeSpecUnion = {
119122
kind: "union",
120123
...getScopeAndName(name),
121-
decorators: getDecoratorsForSchema(schema),
122-
doc: schema.description,
124+
decorators: [...getDecoratorsForSchema(schema), ...unionMetadata.decorators],
125+
doc: schema.description ?? unionMetadata.description,
123126
schema,
124127
};
125128

126129
types.push(union);
127130
}
128131

132+
/**
133+
* Extracts meaningful description and decorators from union members.
134+
* Handles anyOf/oneOf with null, and type arrays like ["string", "null"].
135+
*/
136+
function extractUnionMetadata(schema: OpenAPI3Schema | OpenAPISchema3_1): {
137+
description?: string;
138+
decorators: TypeSpecDecorator[];
139+
} {
140+
// Handle anyOf/oneOf scenarios
141+
const unionMembers = schema.anyOf || schema.oneOf;
142+
if (unionMembers) {
143+
const meaningfulMembers = unionMembers.filter((member) => {
144+
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
145+
return member.type !== "null"; // Non-null types are meaningful
146+
});
147+
148+
// If we have exactly one meaningful member and at least one null, extract from the meaningful one
149+
if (meaningfulMembers.length === 1 && unionMembers.length > meaningfulMembers.length) {
150+
const meaningfulMember = meaningfulMembers[0];
151+
if (!("$ref" in meaningfulMember)) {
152+
return {
153+
description: meaningfulMember.description,
154+
decorators: getDecoratorsForSchema(meaningfulMember),
155+
};
156+
}
157+
}
158+
}
159+
160+
// Handle type array scenarios like type: ["string", "null"]
161+
if (Array.isArray(schema.type)) {
162+
const nonNullTypes = schema.type.filter((t) => t !== "null");
163+
// If we have exactly one non-null type, this is essentially a nullable version of that type
164+
// The schema itself should contain the relevant constraints/description for the non-null type
165+
if (nonNullTypes.length === 1) {
166+
// Create a schema without the null type to extract decorators for the non-null part
167+
const nonNullSchema = { ...schema, type: nonNullTypes[0] };
168+
return { decorators: getDecoratorsForSchema(nonNullSchema) };
169+
// The description should already be on the main schema, so we don't override it here
170+
}
171+
}
172+
173+
return { decorators: [] };
174+
}
175+
129176
function populateScalar(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void {
130177
types.push({
131178
kind: "scalar",
@@ -225,7 +272,13 @@ function getTypeSpecKind(schema: OpenAPI3Schema): TypeSpecDataTypes["kind"] {
225272

226273
if (schema.enum && schema.type === "string" && !schema.nullable) {
227274
return "enum";
228-
} else if (schema.anyOf || schema.oneOf || schema.enum || schema.nullable) {
275+
} else if (
276+
schema.anyOf ||
277+
schema.oneOf ||
278+
schema.enum ||
279+
schema.nullable ||
280+
(Array.isArray(schema.type) && schema.type.includes("null"))
281+
) {
229282
return "union";
230283
} else if (
231284
schema.type === "object" ||

packages/openapi3/src/cli/actions/convert/utils/decorators.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { printIdentifier } from "@typespec/compiler";
22
import { ExtensionKey } from "@typespec/openapi";
3-
import { Extensions, OpenAPI3Parameter, OpenAPI3Schema, Refable } from "../../../../types.js";
3+
import {
4+
Extensions,
5+
OpenAPI3Parameter,
6+
OpenAPI3Schema,
7+
OpenAPISchema3_1,
8+
Refable,
9+
} from "../../../../types.js";
410
import { TSValue, TypeSpecDecorator } from "../interfaces.js";
511

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

145-
export function getDecoratorsForSchema(schema: Refable<OpenAPI3Schema>): TypeSpecDecorator[] {
151+
export function getDecoratorsForSchema(
152+
schema: Refable<OpenAPI3Schema | OpenAPISchema3_1>,
153+
): TypeSpecDecorator[] {
146154
const decorators: TypeSpecDecorator[] = [];
147155

148156
if ("$ref" in schema) {
@@ -196,11 +204,11 @@ function createTSValue(value: string): TSValue {
196204
return { __kind: "value", value };
197205
}
198206

199-
function getOneOfSchemaDecorators(schema: OpenAPI3Schema): TypeSpecDecorator[] {
207+
function getOneOfSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1): TypeSpecDecorator[] {
200208
return [{ name: "oneOf", args: [] }];
201209
}
202210

203-
function getArraySchemaDecorators(schema: OpenAPI3Schema) {
211+
function getArraySchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) {
204212
const decorators: TypeSpecDecorator[] = [];
205213

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

217-
function getNumberSchemaDecorators(schema: OpenAPI3Schema) {
225+
function getNumberSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) {
218226
const decorators: TypeSpecDecorator[] = [];
219227

220228
if (typeof schema.minimum === "number") {
@@ -246,7 +254,7 @@ const knownStringFormats = new Set([
246254
"uri",
247255
]);
248256

249-
function getStringSchemaDecorators(schema: OpenAPI3Schema) {
257+
function getStringSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) {
250258
const decorators: TypeSpecDecorator[] = [];
251259

252260
if (typeof schema.minLength === "number") {
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import OpenAPIParser from "@apidevtools/swagger-parser";
2+
import { OpenAPI } from "openapi-types";
3+
import { beforeAll, describe, expect, it } from "vitest";
4+
import { generateDataType } from "../../src/cli/actions/convert/generators/generate-model.js";
5+
import { TypeSpecUnion } from "../../src/cli/actions/convert/interfaces.js";
6+
import { transformComponentSchemas } from "../../src/cli/actions/convert/transforms/transform-component-schemas.js";
7+
import { createContext } from "../../src/cli/actions/convert/utils/context.js";
8+
import { OpenAPI3Document } from "../../src/types.js";
9+
10+
describe("tsp-openapi: union anyOf with null", () => {
11+
let parser: OpenAPIParser;
12+
let doc: OpenAPI.Document<{}>;
13+
14+
beforeAll(async () => {
15+
parser = new OpenAPIParser();
16+
doc = await parser.bundle({
17+
openapi: "3.1.0",
18+
info: { title: "Test", version: "1.0.0" },
19+
paths: {},
20+
components: {
21+
schemas: {
22+
ReasoningEffort: {
23+
anyOf: [
24+
{
25+
type: "string",
26+
enum: ["minimal", "low", "medium", "high"],
27+
default: "medium",
28+
description: `Constrains effort on reasoning for
29+
[reasoning models](https://platform.openai.com/docs/guides/reasoning).
30+
Currently supported values are \`minimal\`, \`low\`, \`medium\`, and \`high\`. Reducing
31+
reasoning effort can result in faster responses and fewer tokens used
32+
on reasoning in a response.`,
33+
},
34+
{
35+
type: "null",
36+
},
37+
],
38+
},
39+
NullableNumberWithConstraints: {
40+
oneOf: [
41+
{
42+
type: "number",
43+
minimum: 0,
44+
maximum: 100,
45+
description: "A percentage value between 0 and 100",
46+
},
47+
{
48+
type: "null",
49+
},
50+
],
51+
},
52+
ModelOrNull: {
53+
anyOf: [
54+
{
55+
$ref: "#/components/schemas/SomeModel",
56+
},
57+
{
58+
type: "null",
59+
},
60+
],
61+
},
62+
SomeModel: {
63+
type: "object",
64+
properties: {
65+
id: { type: "string" },
66+
},
67+
},
68+
},
69+
},
70+
});
71+
});
72+
73+
it("generates proper TypeSpec code with description and null", () => {
74+
const context = createContext(parser, doc as OpenAPI3Document);
75+
const types: TypeSpecUnion[] = [];
76+
transformComponentSchemas(context, types);
77+
78+
const union = types.find(
79+
(t) => t.name === "ReasoningEffort" && t.kind === "union",
80+
) as TypeSpecUnion;
81+
expect(union).toBeDefined();
82+
expect(union.doc).toBeTruthy();
83+
84+
// Generate the actual TypeSpec code
85+
const generatedCode = generateDataType(union, context);
86+
87+
// Verify the generated code contains all expected elements
88+
expect(generatedCode).toContain("/**");
89+
expect(generatedCode).toContain("Constrains effort on reasoning");
90+
expect(generatedCode).toContain("*/");
91+
expect(generatedCode).toContain("union ReasoningEffort {");
92+
expect(generatedCode).toContain('"minimal"');
93+
expect(generatedCode).toContain('"low"');
94+
expect(generatedCode).toContain('"medium"');
95+
expect(generatedCode).toContain('"high"');
96+
expect(generatedCode).toContain("null");
97+
expect(generatedCode).toContain("}");
98+
});
99+
100+
it("preserves description from oneOf members with constraints when one is null", () => {
101+
const context = createContext(parser, doc as OpenAPI3Document);
102+
const types: TypeSpecUnion[] = [];
103+
transformComponentSchemas(context, types);
104+
105+
const union = types.find(
106+
(t) => t.name === "NullableNumberWithConstraints" && t.kind === "union",
107+
) as TypeSpecUnion;
108+
expect(union).toBeDefined();
109+
expect(union.doc).toBe("A percentage value between 0 and 100");
110+
111+
// Check that decorators from the number schema are preserved
112+
expect(union.decorators).toBeDefined();
113+
const hasMinConstraint = union.decorators.some((d) => d.name === "minValue");
114+
const hasMaxConstraint = union.decorators.some((d) => d.name === "maxValue");
115+
expect(hasMinConstraint).toBe(true);
116+
expect(hasMaxConstraint).toBe(true);
117+
});
118+
119+
it("handles reference + null anyOf correctly", () => {
120+
const context = createContext(parser, doc as OpenAPI3Document);
121+
const types: TypeSpecUnion[] = [];
122+
transformComponentSchemas(context, types);
123+
124+
const union = types.find(
125+
(t) => t.name === "ModelOrNull" && t.kind === "union",
126+
) as TypeSpecUnion;
127+
expect(union).toBeDefined();
128+
// For reference + null, there's no description on the ref itself, so doc should be undefined
129+
expect(union.doc).toBeUndefined();
130+
131+
const generatedCode = generateDataType(union, context);
132+
expect(generatedCode).toContain("SomeModel");
133+
expect(generatedCode).toContain("null");
134+
});
135+
136+
it("handles oneOf with null type array properly", async () => {
137+
const docWithTypeArray = await parser.bundle({
138+
openapi: "3.1.0",
139+
info: { title: "Test", version: "1.0.0" },
140+
paths: {},
141+
components: {
142+
schemas: {
143+
NullableString: {
144+
type: ["string", "null"],
145+
enum: ["value1", "value2", null],
146+
default: "value1",
147+
description: "A nullable string with enum values",
148+
},
149+
NullableInteger: {
150+
type: ["integer", "null"],
151+
minimum: 1,
152+
maximum: 10,
153+
description: "A nullable integer between 1 and 10",
154+
},
155+
},
156+
},
157+
});
158+
159+
const context = createContext(parser, docWithTypeArray as OpenAPI3Document);
160+
const types: TypeSpecUnion[] = [];
161+
transformComponentSchemas(context, types);
162+
163+
const stringUnion = types.find(
164+
(t) => t.name === "NullableString" && t.kind === "union",
165+
) as TypeSpecUnion;
166+
expect(stringUnion).toBeDefined();
167+
expect(stringUnion.doc).toBe("A nullable string with enum values");
168+
169+
const integerUnion = types.find(
170+
(t) => t.name === "NullableInteger" && t.kind === "union",
171+
) as TypeSpecUnion;
172+
expect(integerUnion).toBeDefined();
173+
expect(integerUnion.doc).toBe("A nullable integer between 1 and 10");
174+
175+
// Check that constraints are preserved for the integer union
176+
const hasMinConstraint = integerUnion.decorators.some((d) => d.name === "minValue");
177+
const hasMaxConstraint = integerUnion.decorators.some((d) => d.name === "maxValue");
178+
expect(hasMinConstraint).toBe(true);
179+
expect(hasMaxConstraint).toBe(true);
180+
});
181+
});

0 commit comments

Comments
 (0)