Skip to content

Commit 3d9d1ac

Browse files
iamnamananand996devin-ai-integration[bot]fern-support
authored
fix(cli): fix endpoint example generation for global headers, nullable params, and recursive types (#12342)
* fix(cli): skip global header example failures instead of dropping all endpoint examples Co-Authored-By: naman.anand@buildwithfern.com <iamnamananand996@gmail.com> * Handle nullable/optional examples and stub cycles Traverse nullable/optional wrappers when checking for examples and generate minimal stub examples on recursive cycle detection. ExampleTypeFactory.hasExample now recurses into nullable/optional containers so examples (e.g. nullable query params) are not dropped during OpenAPI parsing. The v1 example generator (generateTypeReferenceExample) returns a minimal filled stub for named types when recursion is detected instead of failing, filling only leaf properties (primitives/enums/literals) to avoid cascade failures. Tests were updated/extended to reflect the new behavior (including a BulkSchedule-like 3-way cycle), and versions.yml was bumped with a changelog entry describing the fixes. Minor imports were adjusted to support the new helper functions. * Reformat object literals in example generator Adjust formatting in packages/commons/ir-utils/src/examples/v1/generateTypeReferenceExample.ts: expand inline object literals into multi-line form for the alias shape and the failure return values in recursive union and undiscriminated union cases. This is a whitespace/formatting change only to improve readability; no logic was modified. * Update v3 SDK snapshot for TreeNode examples Refresh test snapshot to match new generated examples and shapes for the v3 SDK. Changes include an updated example id, expanded jsonExample values (adding left/right empty objects), and richer type/shape metadata for the TreeNode optional/container fields (including empty jsonExample placeholders and named type details). This is a snapshot-only update under packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/example-depth.json. * fix: update simple-fhir IR snapshot for cycle detection stub examples Co-Authored-By: naman.anand@buildwithfern.com <iamnamananand996@gmail.com> * fix: consolidate changelog entries into single 3.78.1 version Co-Authored-By: naman.anand@buildwithfern.com <iamnamananand996@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Naman Anand <info@buildwithfern.com>
1 parent 395f3b5 commit 3d9d1ac

7 files changed

Lines changed: 42336 additions & 36975 deletions

File tree

packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/ExampleEndpointFactory.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -594,9 +594,7 @@ export class ExampleEndpointFactory {
594594
);
595595
example = undefined;
596596
}
597-
if (example == null) {
598-
return [];
599-
} else if (example != null) {
597+
if (example != null) {
600598
headers.push({
601599
name: globalHeader.header,
602600
value: example

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,9 @@ export class ExampleTypeFactory {
788788
}
789789
case "unknown":
790790
return schema.example != null;
791+
case "nullable":
792+
case "optional":
793+
return this.hasExample(schema.value, depth, visitedSchemaIds, options);
791794
case "oneOf":
792795
return Object.values(schema.value.schemas).some((schema) =>
793796
this.hasExample(schema, depth, visitedSchemaIds, options)

packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/example-depth.json

Lines changed: 443 additions & 7 deletions
Large diffs are not rendered by default.

packages/cli/cli/versions.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66
type: feat
77
createdAt: "2026-02-14"
88
irVersion: 65
9+
- version: 3.78.1
10+
changelogEntry:
11+
- summary: |
12+
Fix endpoint example generation for global headers, nullable params, and recursive types.
13+
Global header example failures no longer drop all endpoint examples. Nullable/optional
14+
wrappers are now traversed in `hasExample()`. Recursive types produce minimal stub examples
15+
on cycle detection instead of cascading failures.
16+
type: fix
17+
createdAt: "2026-02-13"
18+
irVersion: 65
919
- version: 3.78.0
1020
changelogEntry:
1121
- summary: |

packages/cli/generation/ir-generator-tests/src/ir/__test__/test-definitions/simple-fhir.json

Lines changed: 41608 additions & 36961 deletions
Large diffs are not rendered by default.

packages/commons/ir-utils/src/__test__/generateTypeReferenceExample.test.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ describe("v1 cycle detection in generateTypeReferenceExample", () => {
109109
}
110110
});
111111

112-
it("should return failure when all required properties are recursive", () => {
112+
it("should generate stub when all required properties are recursive", () => {
113113
const typeDeclarations: Record<TypeId, TypeDeclaration> = {
114114
AllRequired: makeObjectTypeDeclaration("AllRequired", ["self"], [namedRef("AllRequired")])
115115
};
@@ -123,7 +123,19 @@ describe("v1 cycle detection in generateTypeReferenceExample", () => {
123123
skipOptionalProperties: false
124124
});
125125

126-
expect(result.type).toBe("failure");
126+
// Now succeeds: first visit generates the object, inner "self" property
127+
// hits cycle limit and gets a stub empty object instead of failing.
128+
// The stub itself is an object with a "self" property that is also a stub.
129+
expect(result.type).toBe("success");
130+
if (result.type === "success") {
131+
const json = result.jsonExample as Record<string, unknown>;
132+
expect(json).toHaveProperty("self");
133+
const inner = json.self as Record<string, unknown>;
134+
// The inner stub also has "self" because it recursed one more level
135+
// before hitting the limit. At the deepest level, "self" is an empty stub.
136+
expect(inner).toHaveProperty("self");
137+
expect(inner.self).toEqual({});
138+
}
127139
});
128140

129141
it("should detect triangle cycle (A -> B -> C -> A) with optional back-edge", () => {
@@ -161,7 +173,9 @@ describe("v1 cycle detection in generateTypeReferenceExample", () => {
161173
expect(toB2).toHaveProperty("toC");
162174
const toC2 = toB2.toC as Record<string, unknown>;
163175
expect(toC2).toHaveProperty("value");
164-
expect(toC2.toA).toBeUndefined();
176+
// toA gets a stub with leaf properties filled in (value is a string primitive)
177+
// but recursive properties (toB) are skipped
178+
expect(toC2.toA).toEqual({ value: "value" });
165179
}
166180
});
167181

@@ -188,6 +202,58 @@ describe("v1 cycle detection in generateTypeReferenceExample", () => {
188202
}
189203
});
190204

205+
it("should generate stubs for 3-way cycle with required fields (BulkSchedule-like)", () => {
206+
// Simulates: BulkSchedule -> multi(optional) -> MultiConfig -> schedules(required list) -> ItemSchedule -> schedule(required) -> BulkSchedule
207+
const enumRef = (): TypeReference =>
208+
TypeReference.primitive({
209+
v1: "STRING",
210+
v2: PrimitiveTypeV2.string({ default: undefined, validation: undefined })
211+
});
212+
const listRef = (typeId: string): TypeReference =>
213+
TypeReference.container(ContainerType.list(namedRef(typeId)));
214+
215+
const typeDeclarations: Record<TypeId, TypeDeclaration> = {
216+
BulkSchedule: makeObjectTypeDeclaration(
217+
"BulkSchedule",
218+
["frequency", "multi"],
219+
[enumRef(), optionalNamedRef("MultiConfig")]
220+
),
221+
MultiConfig: makeObjectTypeDeclaration("MultiConfig", ["schedules"], [listRef("ItemSchedule")]),
222+
ItemSchedule: makeObjectTypeDeclaration(
223+
"ItemSchedule",
224+
["item", "schedule"],
225+
[enumRef(), namedRef("BulkSchedule")]
226+
)
227+
};
228+
229+
const result = generateTypeReferenceExample({
230+
fieldName: undefined,
231+
typeReference: namedRef("BulkSchedule"),
232+
typeDeclarations,
233+
maxDepth: 10,
234+
currentDepth: 0,
235+
skipOptionalProperties: false
236+
});
237+
238+
expect(result.type).toBe("success");
239+
if (result.type === "success") {
240+
const json = result.jsonExample as Record<string, unknown>;
241+
expect(json).toHaveProperty("frequency");
242+
expect(json).toHaveProperty("multi");
243+
const multi = json.multi as Record<string, unknown>;
244+
expect(multi).toHaveProperty("schedules");
245+
const schedules = multi.schedules as Array<Record<string, unknown>>;
246+
// The list should have items (not be empty) because ItemSchedule.schedule
247+
// now gets a stub with leaf properties instead of failing
248+
expect(schedules.length).toBeGreaterThan(0);
249+
expect(schedules[0]).toHaveProperty("item");
250+
expect(schedules[0]).toHaveProperty("schedule");
251+
// The stub for BulkSchedule at cycle limit includes its leaf property "frequency"
252+
const stubSchedule = schedules[0]?.schedule as Record<string, unknown>;
253+
expect(stubSchedule).toHaveProperty("frequency");
254+
}
255+
});
256+
191257
it("should complete in O(N) time for N optional self-referencing fields, not O(N^N)", () => {
192258
const fieldCounts = [5, 10, 20, 50];
193259
const times: number[] = [];

packages/commons/ir-utils/src/examples/v1/generateTypeReferenceExample.ts

Lines changed: 202 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import {
2+
ExampleObjectProperty,
23
ExampleTypeReference,
34
ExampleTypeReferenceShape,
5+
ExampleTypeShape,
6+
FernIr,
47
TypeDeclaration,
58
TypeId,
69
TypeReference
@@ -48,7 +51,7 @@ export function generateTypeReferenceExample({
4851
const visited = visitedTypes ?? new Map<string, number>();
4952
const count = visited.get(typeReference.typeId) ?? 0;
5053
if (count >= 2) {
51-
return { type: "failure", message: `Detected recursive type ${typeReference.typeId}` };
54+
return generateMinimalNamedExample({ typeDeclaration, typeDeclarations });
5255
}
5356
visited.set(typeReference.typeId, count + 1);
5457
const generatedExample = generateTypeDeclarationExample({
@@ -145,3 +148,201 @@ export function generateTypeReferenceExample({
145148
}
146149
}
147150
}
151+
152+
/**
153+
* Checks whether a type reference is a "leaf" — i.e. it can be generated
154+
* without recursing into named object/union types that might cycle.
155+
* Leaf types: primitives, enums, literals, unknown, and optional/nullable wrappers of leaves.
156+
*/
157+
function isLeafTypeReference(typeRef: TypeReference, typeDeclarations: Record<TypeId, TypeDeclaration>): boolean {
158+
switch (typeRef.type) {
159+
case "primitive":
160+
case "unknown":
161+
return true;
162+
case "named": {
163+
const td = typeDeclarations[typeRef.typeId];
164+
return td?.shape.type === "enum";
165+
}
166+
case "container": {
167+
switch (typeRef.container.type) {
168+
case "literal":
169+
return true;
170+
case "optional":
171+
return isLeafTypeReference(typeRef.container.optional, typeDeclarations);
172+
case "nullable":
173+
return isLeafTypeReference(typeRef.container.nullable, typeDeclarations);
174+
default:
175+
return false;
176+
}
177+
}
178+
}
179+
}
180+
181+
/**
182+
* Generates a stub example for a named type when cycle detection triggers.
183+
* Instead of returning failure (which cascades up and kills parent examples),
184+
* this produces a valid example with all leaf (non-recursive) properties filled in:
185+
* - Objects → generates all primitive/enum/literal properties, skips recursive ones
186+
* - Enums → first enum value
187+
* - Aliases → resolve non-recursive targets; recursive ones get empty object
188+
* - Unions → first noProperties variant if available; otherwise failure
189+
*/
190+
function generateMinimalNamedExample({
191+
typeDeclaration,
192+
typeDeclarations
193+
}: {
194+
typeDeclaration: TypeDeclaration;
195+
typeDeclarations: Record<TypeId, TypeDeclaration>;
196+
}): ExampleGenerationResult<ExampleTypeReference> {
197+
switch (typeDeclaration.shape.type) {
198+
case "object": {
199+
const jsonExample: Record<string, unknown> = {};
200+
const properties: ExampleObjectProperty[] = [];
201+
for (const property of [
202+
...(typeDeclaration.shape.properties ?? []),
203+
...(typeDeclaration.shape.extendedProperties ?? [])
204+
]) {
205+
if (!isLeafTypeReference(property.valueType, typeDeclarations)) {
206+
continue;
207+
}
208+
const propertyExample = generateTypeReferenceExample({
209+
fieldName: property.name.wireValue,
210+
typeReference: property.valueType,
211+
typeDeclarations,
212+
currentDepth: 0,
213+
maxDepth: 3,
214+
skipOptionalProperties: true
215+
});
216+
if (propertyExample.type === "failure") {
217+
continue;
218+
}
219+
properties.push({
220+
name: property.name,
221+
originalTypeDeclaration: typeDeclaration.name,
222+
value: propertyExample.example,
223+
propertyAccess: property.propertyAccess
224+
});
225+
jsonExample[property.name.wireValue] = propertyExample.jsonExample;
226+
}
227+
const example = ExampleTypeShape.object({
228+
properties,
229+
extraProperties: undefined
230+
});
231+
return {
232+
type: "success",
233+
example: {
234+
jsonExample,
235+
shape: ExampleTypeReferenceShape.named({
236+
shape: example,
237+
typeName: typeDeclaration.name
238+
})
239+
},
240+
jsonExample
241+
};
242+
}
243+
case "enum": {
244+
const enumValue = typeDeclaration.shape.values[0];
245+
if (enumValue == null) {
246+
return { type: "failure", message: "No enum values present for recursive type stub" };
247+
}
248+
const jsonExample = enumValue.name.wireValue;
249+
const example = ExampleTypeShape.enum({ value: enumValue.name });
250+
return {
251+
type: "success",
252+
example: {
253+
jsonExample,
254+
shape: ExampleTypeReferenceShape.named({
255+
shape: example,
256+
typeName: typeDeclaration.name
257+
})
258+
},
259+
jsonExample
260+
};
261+
}
262+
case "alias": {
263+
const aliasOf = typeDeclaration.shape.aliasOf;
264+
if (aliasOf.type === "primitive") {
265+
const { jsonExample, example } = generatePrimitiveExample({
266+
fieldName: undefined,
267+
primitiveType: aliasOf.primitive
268+
});
269+
return {
270+
type: "success",
271+
example: {
272+
jsonExample,
273+
shape: ExampleTypeReferenceShape.named({
274+
shape: ExampleTypeShape.alias({
275+
value: { jsonExample, shape: ExampleTypeReferenceShape.primitive(example) }
276+
}),
277+
typeName: typeDeclaration.name
278+
})
279+
},
280+
jsonExample
281+
};
282+
}
283+
if (aliasOf.type === "named") {
284+
const aliasedDeclaration = typeDeclarations[aliasOf.typeId];
285+
if (aliasedDeclaration != null && aliasedDeclaration.name.typeId !== typeDeclaration.name.typeId) {
286+
return generateMinimalNamedExample({ typeDeclaration: aliasedDeclaration, typeDeclarations });
287+
}
288+
}
289+
const jsonExample = {};
290+
return {
291+
type: "success",
292+
example: {
293+
jsonExample,
294+
shape: ExampleTypeReferenceShape.named({
295+
shape: ExampleTypeShape.alias({
296+
value: { jsonExample, shape: ExampleTypeReferenceShape.unknown(jsonExample) }
297+
}),
298+
typeName: typeDeclaration.name
299+
})
300+
},
301+
jsonExample
302+
};
303+
}
304+
case "union": {
305+
const discriminant = typeDeclaration.shape.discriminant;
306+
for (const variant of typeDeclaration.shape.types) {
307+
const isNoProperties = variant.shape._visit<boolean>({
308+
noProperties: () => true,
309+
samePropertiesAsObject: () => false,
310+
singleProperty: () => false,
311+
_other: () => false
312+
});
313+
if (isNoProperties) {
314+
const jsonExample = { [discriminant.wireValue]: variant.discriminantValue.wireValue };
315+
return {
316+
type: "success",
317+
example: {
318+
jsonExample,
319+
shape: ExampleTypeReferenceShape.named({
320+
shape: ExampleTypeShape.union({
321+
discriminant,
322+
singleUnionType: {
323+
wireDiscriminantValue: variant.discriminantValue,
324+
shape: FernIr.ExampleSingleUnionTypeProperties.noProperties()
325+
},
326+
baseProperties: [],
327+
extendProperties: []
328+
}),
329+
typeName: typeDeclaration.name
330+
})
331+
},
332+
jsonExample
333+
};
334+
}
335+
}
336+
return {
337+
type: "failure",
338+
message: `No simple variant available for recursive union ${typeDeclaration.name.typeId}`
339+
};
340+
}
341+
case "undiscriminatedUnion": {
342+
return {
343+
type: "failure",
344+
message: `Cannot generate stub for recursive undiscriminated union ${typeDeclaration.name.typeId}`
345+
};
346+
}
347+
}
348+
}

0 commit comments

Comments
 (0)