Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Source } from "@fern-api/openapi-ir";
import { TaskContext } from "@fern-api/task-context";
import { OpenAPIV3 } from "openapi-types";
import { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
import { describe, expect, it, vi } from "vitest";
import { OpenAPIV3ParserContext } from "../openapi/v3/OpenAPIV3ParserContext.js";
import { DEFAULT_PARSE_OPENAPI_SETTINGS } from "../options.js";
Expand All @@ -17,9 +17,9 @@ function createMockTaskContext(): TaskContext {
} as unknown as TaskContext;
}

function createContext(document: OpenAPIV3.Document, source: Source): OpenAPIV3ParserContext {
function createContext(document: OpenAPIV3.Document | OpenAPIV3_1.Document, source: Source): OpenAPIV3ParserContext {
return new OpenAPIV3ParserContext({
document,
document: document as OpenAPIV3.Document,
taskContext: createMockTaskContext(),
authHeaders: new Set(),
options: DEFAULT_PARSE_OPENAPI_SETTINGS,
Expand All @@ -31,6 +31,92 @@ function createContext(document: OpenAPIV3.Document, source: Source): OpenAPIV3P
describe("convertReferenceObject nullability", () => {
const source: Source = Source.openapi({ file: "test.yaml" });

it("preserves type-array nullability after converting a documented allOf reference", () => {
const nullableDetails = {
type: ["object", "null"],
properties: {
value: { type: "string" }
},
required: ["value"]
} satisfies OpenAPIV3_1.SchemaObject;
const firstProfile: OpenAPIV3.SchemaObject = {
type: "object",
properties: {
details: { $ref: "#/components/schemas/NullableDetails" }
},
required: ["details"]
};
const composedProfile: OpenAPIV3.SchemaObject = {
type: "object",
properties: {
details: {
allOf: [{ $ref: "#/components/schemas/NullableDetails" }, { description: "Nullable details" }]
}
},
required: ["details"]
};
const secondProfile: OpenAPIV3.SchemaObject = {
type: "object",
properties: {
details: { $ref: "#/components/schemas/NullableDetails" }
},
required: ["details"]
};
const document = {
openapi: "3.1.0",
info: { title: "Test API", version: "1.0.0" },
paths: {},
components: {
schemas: {
FirstProfile: firstProfile,
ComposedProfile: composedProfile,
SecondProfile: secondProfile,
NullableDetails: nullableDetails
}
}
} satisfies OpenAPIV3_1.Document;
const context = createContext(document, source);

const convertedProfiles = [firstProfile, composedProfile, secondProfile].map((profile, index) =>
convertSchema(profile, false, false, context, [`Profile${index + 1}`], source, undefined)
);

expect(
convertedProfiles.map((profile) => {
expect(profile.type).toBe("object");
if (profile.type !== "object") {
return undefined;
}
return profile.properties.find((property) => property.key === "details")?.schema.type;
})
).toEqual(["nullable", "nullable", "nullable"]);
expect(nullableDetails.type).toEqual(["object", "null"]);
});

it("wraps a multi-type union in one nullable layer", () => {
const schema: OpenAPIV3.SchemaObject = {};
(schema as OpenAPIV3_1.SchemaObject).type = ["string", "integer", "null"];
const document = {
openapi: "3.1.0",
info: { title: "Test API", version: "1.0.0" },
paths: {}
} satisfies OpenAPIV3_1.Document;
const context = createContext(document, source);

const result = convertSchema(schema, false, false, context, ["NullableUnion"], source, undefined);

expect(result.type).toBe("nullable");
if (result.type !== "nullable") {
return;
}
expect(result.value.type).toBe("oneOf");
if (result.value.type !== "oneOf" || result.value.value.type !== "undiscriminated") {
return;
}
expect(result.value.value.schemas.map((variant) => variant.type)).toEqual(["primitive", "primitive"]);
expect((schema as OpenAPIV3_1.SchemaObject).type).toEqual(["string", "integer", "null"]);
});

it("propagates nullability from a referenced anyOf that includes a { type: null } branch", () => {
const document: OpenAPIV3.Document = {
openapi: "3.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "@fern-api/openapi-ir";
import { CliError } from "@fern-api/task-context";
import { size } from "lodash-es";
import type { OpenAPIV3 } from "openapi-types";
import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
import { getExtension } from "../getExtension.js";
import { OpenAPIExtension } from "../openapi/v3/extensions/extensions.js";
import { FernOpenAPIExtension } from "../openapi/v3/extensions/fernExtensions.js";
Expand Down Expand Up @@ -368,7 +368,7 @@ function getTitleAsName(title: string | undefined): string | undefined {
}

export function convertSchemaObject(
schema: OpenAPIV3.SchemaObject | string,
schemaInput: OpenAPIV3.SchemaObject | string,
wrapAsOptional: boolean,
wrapAsNullable: boolean,
context: SchemaParserContext,
Expand All @@ -380,9 +380,8 @@ export function convertSchemaObject(
referencedAsRequest = false,
fallback?: string | number | boolean | unknown[]
): SchemaWithExample {
if (typeof schema === "string") {
schema = { type: schema } as OpenAPIV3.SchemaObject;
}
let schema =
typeof schemaInput === "string" ? ({ type: schemaInput } as OpenAPIV3.SchemaObject) : { ...schemaInput };
Comment thread
ngyna marked this conversation as resolved.
const nameOverride = getDisambiguatedNameOverride(schema, context, breadcrumbs.join("."));
const mixedGroupName =
getExtension(schema, FernOpenAPIExtension.SDK_GROUP_NAME) ??
Expand Down Expand Up @@ -461,23 +460,22 @@ export function convertSchemaObject(
try {
// handle type array
if (Array.isArray(schema.type)) {
const nullIndex = schema.type.indexOf("null");
const hasNull = nullIndex !== -1;
const nonNullTypes = schema.type.filter((type) => type !== "null");
const hasNull = nonNullTypes.length !== schema.type.length;
if (schema.type.length === 1) {
schema.type = schema.type[0];
} else if (schema.type.length === 2 && hasNull) {
schema.type.splice(nullIndex, 1);
schema.type = schema.type[0];
schema.type = nonNullTypes[0] ?? "null";
schema.nullable = true;
} else {
if (hasNull) {
schema.type.splice(nullIndex, 1);
schema.nullable = true;
}
Comment thread
ngyna marked this conversation as resolved.
(schema as OpenAPIV3_1.SchemaObject).type = nonNullTypes;
if (schema.oneOf == null) {
schema.oneOf = [...new Set(schema.type)];
schema.oneOf = [...new Set(nonNullTypes)];
} else {
const uniqueTypes = new Set([...schema.oneOf, ...schema.type]);
const uniqueTypes = new Set([...schema.oneOf, ...nonNullTypes]);
schema.oneOf = [...uniqueTypes];
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json

- summary: |
Preserve OpenAPI 3.1 nullability on component references that follow a documented `allOf` reference to the same schema.
type: fix
Loading