diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/ExampleTypeFactory.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/ExampleTypeFactory.ts index c988491cc44e..38d0ab66407c 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/ExampleTypeFactory.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/ExampleTypeFactory.ts @@ -91,6 +91,10 @@ export class ExampleTypeFactory { case "literal": return FullExample.literal(schema.value); case "nullable": { + // Explicit example of null should win over schema-level examples + if (example === null) { + return FullExample.null({}); + } if ( example == null && !this.hasExample(schema.value, 0, visitedSchemaIds, options) && @@ -474,7 +478,9 @@ export class ExampleTypeFactory { const propertyExampleFromParent = fullExample[property]; const propertySchemaExample = this.getSchemaExample(schema.schema); - const exampleToUse = propertyExampleFromParent ?? propertySchemaExample; + // If the property is explicitly present in the example (even if null), + // treat that as authoritative; only fall back to schema example if it's absent. + const exampleToUse = inExample ? propertyExampleFromParent : propertySchemaExample; const propertyExample = this.buildExampleHelper({ schema: schema.schema, diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/valtown.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/valtown.json index e89016fa8511..4c88f1444333 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/valtown.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/valtown.json @@ -475,11 +475,7 @@ "type": "primitive" }, "profileImageUrl": { - "value": { - "value": "profileImageUrl", - "type": "string" - }, - "type": "primitive" + "type": "null" }, "url": { "value": { @@ -1390,11 +1386,7 @@ "type": "primitive" }, "profileImageUrl": { - "value": { - "value": "profileImageUrl", - "type": "string" - }, - "type": "primitive" + "type": "null" }, "url": { "value": { @@ -3334,11 +3326,7 @@ "type": "primitive" }, "profileImageUrl": { - "value": { - "value": "profileImageUrl", - "type": "string" - }, - "type": "primitive" + "type": "null" }, "url": { "value": { @@ -4608,13 +4596,6 @@ "type": "double" }, "type": "primitive" - }, - "lastInsertRowid": { - "value": { - "value": "lastInsertRowid", - "type": "string" - }, - "type": "primitive" } }, "type": "object" diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/valtown.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/valtown.json index 4a36cc284074..740a622ce522 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/valtown.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/valtown.json @@ -999,7 +999,7 @@ API endpoints", "links": { "self": "self", }, - "profileImageUrl": "profileImageUrl", + "profileImageUrl": null, "url": "https://val.town/u/tmcw", "username": "tmcw", }, @@ -1303,7 +1303,7 @@ service: id: 00000000-0000-0000-0000-000000000000 bio: Hello world username: tmcw - profileImageUrl: profileImageUrl + profileImageUrl: null url: https://val.town/u/tmcw links: self: self @@ -2534,7 +2534,7 @@ give access to details and data from the requesting user.", "links": { "self": "self", }, - "profileImageUrl": "profileImageUrl", + "profileImageUrl": null, "tier": "pro", "url": "url", "username": "tmcw", @@ -3304,7 +3304,7 @@ service: id: 00000000-0000-0000-0000-000000000000 bio: Hello world username: tmcw - profileImageUrl: profileImageUrl + profileImageUrl: null url: url links: self: self @@ -3728,7 +3728,6 @@ docs: Search "columns": [ "id", ], - "lastInsertRowid": "lastInsertRowid", "rows": [ [ 1, @@ -4238,7 +4237,6 @@ service: rows: - - 1 rowsAffected: 0 - lastInsertRowid: lastInsertRowid source: openapi: ../openapi.yml display-name: sqlite @@ -4272,7 +4270,7 @@ docs: SQLite "links": { "self": "self", }, - "profileImageUrl": "profileImageUrl", + "profileImageUrl": null, "url": "https://val.town/u/tmcw", "username": "tmcw", }, @@ -4441,7 +4439,7 @@ service: id: 00000000-0000-0000-0000-000000000000 bio: Hello world username: tmcw - profileImageUrl: profileImageUrl + profileImageUrl: null url: https://val.town/u/tmcw links: self: self diff --git a/packages/cli/api-importers/v3-importer-commons/src/converters/ExampleConverter.ts b/packages/cli/api-importers/v3-importer-commons/src/converters/ExampleConverter.ts index ce485ce31333..7c8ec9018ba8 100644 --- a/packages/cli/api-importers/v3-importer-commons/src/converters/ExampleConverter.ts +++ b/packages/cli/api-importers/v3-importer-commons/src/converters/ExampleConverter.ts @@ -832,7 +832,12 @@ export class ExampleConverter extends AbstractConverter { await expect(intermediateRepresentation).toMatchFileSnapshot("__snapshots__/webhook-openapi-responses-ir.snap"); }); + it("should handle OpenAPI with nullable balance_max in tiered rates", async () => { + const context = createMockTaskContext(); + const workspace = await loadAPIWorkspace({ + absolutePathToWorkspace: join( + AbsoluteFilePath.of(__dirname), + RelativeFilePath.of("fixtures/balance-max-null") + ), + context, + cliVersion: "0.0.0", + workspaceName: "balance-max-null" + }); + + expect(workspace.didSucceed).toBe(true); + assert(workspace.didSucceed); + + if (!(workspace.workspace instanceof OSSWorkspace)) { + throw new Error( + `Expected OSSWorkspace for OpenAPI processing, got ${workspace.workspace.constructor.name}` + ); + } + + const intermediateRepresentation = await workspace.workspace.getIntermediateRepresentation({ + context, + audiences: { type: "all" }, + enableUniqueErrorsPerEndpoint: true, + generateV1Examples: false, + logWarnings: false + }); + + const fdrApiDefinition = await convertIrToFdrApi({ + ir: intermediateRepresentation, + snippetsConfig: { + typescriptSdk: undefined, + pythonSdk: undefined, + javaSdk: undefined, + rubySdk: undefined, + goSdk: undefined, + csharpSdk: undefined, + phpSdk: undefined, + swiftSdk: undefined, + rustSdk: undefined + }, + playgroundConfig: { + oauth: true + }, + context + }); + + // Validate that the RateTier type with nullable balance_max was processed + expect(intermediateRepresentation.types).toBeDefined(); + expect(fdrApiDefinition.types).toBeDefined(); + + // Check that RateTier type exists and has the expected structure + const rateTierType = Object.values(intermediateRepresentation.types).find( + (type) => type.name.name.originalName === "RateTier" + ); + expect(rateTierType).toBeDefined(); + + // Verify the endpoint example preserves null value for balance_max + const endpoint = fdrApiDefinition.rootPackage?.endpoints?.find((e) => e.id === "getRates"); + expect(endpoint).toBeDefined(); + expect(endpoint?.examples).toBeDefined(); + expect(endpoint?.examples?.length).toBeGreaterThan(0); + + // Get the tiers from the example response + const example = endpoint?.examples?.[0]; + const responseBody = example?.responseBody as unknown as Record | undefined; + const fixedRate = responseBody?.fixed_rate as { tiers: Array<{ balance_max: string | null }> } | undefined; + const tiers = fixedRate?.tiers; + expect(Array.isArray(tiers)).toBe(true); + expect(tiers).toHaveLength(3); + + // Critical regression test: the third tier's balance_max must be null, not a string value + expect(tiers?.[0]?.balance_max).toBe("100000000"); + expect(tiers?.[1]?.balance_max).toBe("500000000"); + expect(tiers?.[2]?.balance_max).toBeNull(); + + await expect(fdrApiDefinition).toMatchFileSnapshot("__snapshots__/balance-max-null-fdr.snap"); + await expect(intermediateRepresentation).toMatchFileSnapshot("__snapshots__/balance-max-null-ir.snap"); + }); + it("should preserve human-generated examples when ai-examples is enabled - OpenAPI example format", async () => { // Test case to verify that human-generated examples specified in OpenAPI spec // are NOT overwritten when ai-examples: true is set in docs.yml