Skip to content

Commit 78be336

Browse files
author
Andy Nguyen
committed
fix(openapi): preserve nullable component references
1 parent cc2f39a commit 78be336

3 files changed

Lines changed: 78 additions & 14 deletions

File tree

packages/cli/api-importers/openapi/openapi-ir-parser/src/__test__/convertReferenceNullability.test.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Source } from "@fern-api/openapi-ir";
22
import { TaskContext } from "@fern-api/task-context";
3-
import { OpenAPIV3 } from "openapi-types";
3+
import { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
44
import { describe, expect, it, vi } from "vitest";
55
import { OpenAPIV3ParserContext } from "../openapi/v3/OpenAPIV3ParserContext.js";
66
import { DEFAULT_PARSE_OPENAPI_SETTINGS } from "../options.js";
@@ -17,9 +17,9 @@ function createMockTaskContext(): TaskContext {
1717
} as unknown as TaskContext;
1818
}
1919

20-
function createContext(document: OpenAPIV3.Document, source: Source): OpenAPIV3ParserContext {
20+
function createContext(document: OpenAPIV3.Document | OpenAPIV3_1.Document, source: Source): OpenAPIV3ParserContext {
2121
return new OpenAPIV3ParserContext({
22-
document,
22+
document: document as OpenAPIV3.Document,
2323
taskContext: createMockTaskContext(),
2424
authHeaders: new Set(),
2525
options: DEFAULT_PARSE_OPENAPI_SETTINGS,
@@ -31,6 +31,68 @@ function createContext(document: OpenAPIV3.Document, source: Source): OpenAPIV3P
3131
describe("convertReferenceObject nullability", () => {
3232
const source: Source = Source.openapi({ file: "test.yaml" });
3333

34+
it("preserves type-array nullability after converting a documented allOf reference", () => {
35+
const nullableDetails = {
36+
type: ["object", "null"],
37+
properties: {
38+
value: { type: "string" }
39+
},
40+
required: ["value"]
41+
} satisfies OpenAPIV3_1.SchemaObject;
42+
const firstProfile: OpenAPIV3.SchemaObject = {
43+
type: "object",
44+
properties: {
45+
details: { $ref: "#/components/schemas/NullableDetails" }
46+
},
47+
required: ["details"]
48+
};
49+
const composedProfile: OpenAPIV3.SchemaObject = {
50+
type: "object",
51+
properties: {
52+
details: {
53+
allOf: [{ $ref: "#/components/schemas/NullableDetails" }, { description: "Nullable details" }]
54+
}
55+
},
56+
required: ["details"]
57+
};
58+
const secondProfile: OpenAPIV3.SchemaObject = {
59+
type: "object",
60+
properties: {
61+
details: { $ref: "#/components/schemas/NullableDetails" }
62+
},
63+
required: ["details"]
64+
};
65+
const document = {
66+
openapi: "3.1.0",
67+
info: { title: "Test API", version: "1.0.0" },
68+
paths: {},
69+
components: {
70+
schemas: {
71+
FirstProfile: firstProfile,
72+
ComposedProfile: composedProfile,
73+
SecondProfile: secondProfile,
74+
NullableDetails: nullableDetails
75+
}
76+
}
77+
} satisfies OpenAPIV3_1.Document;
78+
const context = createContext(document, source);
79+
80+
const convertedProfiles = [firstProfile, composedProfile, secondProfile].map((profile, index) =>
81+
convertSchema(profile, false, false, context, [`Profile${index + 1}`], source, undefined)
82+
);
83+
84+
expect(
85+
convertedProfiles.map((profile) => {
86+
expect(profile.type).toBe("object");
87+
if (profile.type !== "object") {
88+
return undefined;
89+
}
90+
return profile.properties.find((property) => property.key === "details")?.schema.type;
91+
})
92+
).toEqual(["nullable", "nullable", "nullable"]);
93+
expect(nullableDetails.type).toEqual(["object", "null"]);
94+
});
95+
3496
it("propagates nullability from a referenced anyOf that includes a { type: null } branch", () => {
3597
const document: OpenAPIV3.Document = {
3698
openapi: "3.0.0",

packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/convertSchemas.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ function getTitleAsName(title: string | undefined): string | undefined {
368368
}
369369

370370
export function convertSchemaObject(
371-
schema: OpenAPIV3.SchemaObject | string,
371+
schemaInput: OpenAPIV3.SchemaObject | string,
372372
wrapAsOptional: boolean,
373373
wrapAsNullable: boolean,
374374
context: SchemaParserContext,
@@ -380,9 +380,8 @@ export function convertSchemaObject(
380380
referencedAsRequest = false,
381381
fallback?: string | number | boolean | unknown[]
382382
): SchemaWithExample {
383-
if (typeof schema === "string") {
384-
schema = { type: schema } as OpenAPIV3.SchemaObject;
385-
}
383+
let schema =
384+
typeof schemaInput === "string" ? ({ type: schemaInput } as OpenAPIV3.SchemaObject) : { ...schemaInput };
386385
const nameOverride = getDisambiguatedNameOverride(schema, context, breadcrumbs.join("."));
387386
const mixedGroupName =
388387
getExtension(schema, FernOpenAPIExtension.SDK_GROUP_NAME) ??
@@ -461,23 +460,21 @@ export function convertSchemaObject(
461460
try {
462461
// handle type array
463462
if (Array.isArray(schema.type)) {
464-
const nullIndex = schema.type.indexOf("null");
465-
const hasNull = nullIndex !== -1;
463+
const nonNullTypes = schema.type.filter((type) => type !== "null");
464+
const hasNull = nonNullTypes.length !== schema.type.length;
466465
if (schema.type.length === 1) {
467466
schema.type = schema.type[0];
468467
} else if (schema.type.length === 2 && hasNull) {
469-
schema.type.splice(nullIndex, 1);
470-
schema.type = schema.type[0];
468+
schema.type = nonNullTypes[0];
471469
schema.nullable = true;
472470
} else {
473471
if (hasNull) {
474-
schema.type.splice(nullIndex, 1);
475472
schema.nullable = true;
476473
}
477474
if (schema.oneOf == null) {
478-
schema.oneOf = [...new Set(schema.type)];
475+
schema.oneOf = [...new Set(nonNullTypes)];
479476
} else {
480-
const uniqueTypes = new Set([...schema.oneOf, ...schema.type]);
477+
const uniqueTypes = new Set([...schema.oneOf, ...nonNullTypes]);
481478
schema.oneOf = [...uniqueTypes];
482479
}
483480
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json
2+
3+
- summary: |
4+
Preserve OpenAPI 3.1 nullability on component references that follow a documented `allOf` reference to the same schema.
5+
type: fix

0 commit comments

Comments
 (0)