Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -594,9 +594,7 @@ export class ExampleEndpointFactory {
);
example = undefined;
}
if (example == null) {
return [];
} else if (example != null) {
if (example != null) {
headers.push({
name: globalHeader.header,
value: example
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,9 @@ export class ExampleTypeFactory {
}
case "unknown":
return schema.example != null;
case "nullable":
case "optional":
return this.hasExample(schema.value, depth, visitedSchemaIds, options);
case "oneOf":
return Object.values(schema.value.schemas).some((schema) =>
this.hasExample(schema, depth, visitedSchemaIds, options)
Expand Down

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions packages/cli/cli/versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@
type: feat
createdAt: "2026-02-14"
irVersion: 65
- version: 3.78.1
changelogEntry:
- summary: |
Fix endpoint example generation for global headers, nullable params, and recursive types.
Global header example failures no longer drop all endpoint examples. Nullable/optional
wrappers are now traversed in `hasExample()`. Recursive types produce minimal stub examples
on cycle detection instead of cascading failures.
type: fix
createdAt: "2026-02-13"
irVersion: 65
- version: 3.78.0
changelogEntry:
- summary: |
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe("v1 cycle detection in generateTypeReferenceExample", () => {
}
});

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

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

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

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

it("should generate stubs for 3-way cycle with required fields (BulkSchedule-like)", () => {
// Simulates: BulkSchedule -> multi(optional) -> MultiConfig -> schedules(required list) -> ItemSchedule -> schedule(required) -> BulkSchedule
const enumRef = (): TypeReference =>
TypeReference.primitive({
v1: "STRING",
v2: PrimitiveTypeV2.string({ default: undefined, validation: undefined })
});
const listRef = (typeId: string): TypeReference =>
TypeReference.container(ContainerType.list(namedRef(typeId)));

const typeDeclarations: Record<TypeId, TypeDeclaration> = {
BulkSchedule: makeObjectTypeDeclaration(
"BulkSchedule",
["frequency", "multi"],
[enumRef(), optionalNamedRef("MultiConfig")]
),
MultiConfig: makeObjectTypeDeclaration("MultiConfig", ["schedules"], [listRef("ItemSchedule")]),
ItemSchedule: makeObjectTypeDeclaration(
"ItemSchedule",
["item", "schedule"],
[enumRef(), namedRef("BulkSchedule")]
)
};

const result = generateTypeReferenceExample({
fieldName: undefined,
typeReference: namedRef("BulkSchedule"),
typeDeclarations,
maxDepth: 10,
currentDepth: 0,
skipOptionalProperties: false
});

expect(result.type).toBe("success");
if (result.type === "success") {
const json = result.jsonExample as Record<string, unknown>;
expect(json).toHaveProperty("frequency");
expect(json).toHaveProperty("multi");
const multi = json.multi as Record<string, unknown>;
expect(multi).toHaveProperty("schedules");
const schedules = multi.schedules as Array<Record<string, unknown>>;
// The list should have items (not be empty) because ItemSchedule.schedule
// now gets a stub with leaf properties instead of failing
expect(schedules.length).toBeGreaterThan(0);
expect(schedules[0]).toHaveProperty("item");
expect(schedules[0]).toHaveProperty("schedule");
// The stub for BulkSchedule at cycle limit includes its leaf property "frequency"
const stubSchedule = schedules[0]?.schedule as Record<string, unknown>;
expect(stubSchedule).toHaveProperty("frequency");
}
});

it("should complete in O(N) time for N optional self-referencing fields, not O(N^N)", () => {
const fieldCounts = [5, 10, 20, 50];
const times: number[] = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
ExampleObjectProperty,
ExampleTypeReference,
ExampleTypeReferenceShape,
ExampleTypeShape,
FernIr,
TypeDeclaration,
TypeId,
TypeReference
Expand Down Expand Up @@ -48,7 +51,7 @@ export function generateTypeReferenceExample({
const visited = visitedTypes ?? new Map<string, number>();
const count = visited.get(typeReference.typeId) ?? 0;
if (count >= 2) {
return { type: "failure", message: `Detected recursive type ${typeReference.typeId}` };
return generateMinimalNamedExample({ typeDeclaration, typeDeclarations });
}
visited.set(typeReference.typeId, count + 1);
const generatedExample = generateTypeDeclarationExample({
Expand Down Expand Up @@ -145,3 +148,201 @@ export function generateTypeReferenceExample({
}
}
}

/**
* Checks whether a type reference is a "leaf" — i.e. it can be generated
* without recursing into named object/union types that might cycle.
* Leaf types: primitives, enums, literals, unknown, and optional/nullable wrappers of leaves.
*/
function isLeafTypeReference(typeRef: TypeReference, typeDeclarations: Record<TypeId, TypeDeclaration>): boolean {
switch (typeRef.type) {
case "primitive":
case "unknown":
return true;
case "named": {
const td = typeDeclarations[typeRef.typeId];
return td?.shape.type === "enum";
}
case "container": {
switch (typeRef.container.type) {
case "literal":
return true;
case "optional":
return isLeafTypeReference(typeRef.container.optional, typeDeclarations);
case "nullable":
return isLeafTypeReference(typeRef.container.nullable, typeDeclarations);
default:
return false;
}
}
}
}

/**
* Generates a stub example for a named type when cycle detection triggers.
* Instead of returning failure (which cascades up and kills parent examples),
* this produces a valid example with all leaf (non-recursive) properties filled in:
* - Objects → generates all primitive/enum/literal properties, skips recursive ones
* - Enums → first enum value
* - Aliases → resolve non-recursive targets; recursive ones get empty object
* - Unions → first noProperties variant if available; otherwise failure
*/
function generateMinimalNamedExample({
typeDeclaration,
typeDeclarations
}: {
typeDeclaration: TypeDeclaration;
typeDeclarations: Record<TypeId, TypeDeclaration>;
}): ExampleGenerationResult<ExampleTypeReference> {
switch (typeDeclaration.shape.type) {
case "object": {
const jsonExample: Record<string, unknown> = {};
const properties: ExampleObjectProperty[] = [];
for (const property of [
...(typeDeclaration.shape.properties ?? []),
...(typeDeclaration.shape.extendedProperties ?? [])
]) {
if (!isLeafTypeReference(property.valueType, typeDeclarations)) {
continue;
}
const propertyExample = generateTypeReferenceExample({
fieldName: property.name.wireValue,
typeReference: property.valueType,
typeDeclarations,
currentDepth: 0,
maxDepth: 3,
skipOptionalProperties: true
});
if (propertyExample.type === "failure") {
continue;
}
properties.push({
name: property.name,
originalTypeDeclaration: typeDeclaration.name,
value: propertyExample.example,
propertyAccess: property.propertyAccess
});
jsonExample[property.name.wireValue] = propertyExample.jsonExample;
}
const example = ExampleTypeShape.object({
properties,
extraProperties: undefined
});
return {
type: "success",
example: {
jsonExample,
shape: ExampleTypeReferenceShape.named({
shape: example,
typeName: typeDeclaration.name
})
},
jsonExample
};
}
case "enum": {
const enumValue = typeDeclaration.shape.values[0];
if (enumValue == null) {
return { type: "failure", message: "No enum values present for recursive type stub" };
}
const jsonExample = enumValue.name.wireValue;
const example = ExampleTypeShape.enum({ value: enumValue.name });
return {
type: "success",
example: {
jsonExample,
shape: ExampleTypeReferenceShape.named({
shape: example,
typeName: typeDeclaration.name
})
},
jsonExample
};
}
case "alias": {
const aliasOf = typeDeclaration.shape.aliasOf;
if (aliasOf.type === "primitive") {
const { jsonExample, example } = generatePrimitiveExample({
fieldName: undefined,
primitiveType: aliasOf.primitive
});
return {
type: "success",
example: {
jsonExample,
shape: ExampleTypeReferenceShape.named({
shape: ExampleTypeShape.alias({
value: { jsonExample, shape: ExampleTypeReferenceShape.primitive(example) }
}),
typeName: typeDeclaration.name
})
},
jsonExample
};
}
if (aliasOf.type === "named") {
const aliasedDeclaration = typeDeclarations[aliasOf.typeId];
if (aliasedDeclaration != null && aliasedDeclaration.name.typeId !== typeDeclaration.name.typeId) {
return generateMinimalNamedExample({ typeDeclaration: aliasedDeclaration, typeDeclarations });
}
}
const jsonExample = {};
return {
type: "success",
example: {
jsonExample,
shape: ExampleTypeReferenceShape.named({
shape: ExampleTypeShape.alias({
value: { jsonExample, shape: ExampleTypeReferenceShape.unknown(jsonExample) }
}),
typeName: typeDeclaration.name
})
},
jsonExample
};
}
case "union": {
const discriminant = typeDeclaration.shape.discriminant;
for (const variant of typeDeclaration.shape.types) {
const isNoProperties = variant.shape._visit<boolean>({
noProperties: () => true,
samePropertiesAsObject: () => false,
singleProperty: () => false,
_other: () => false
});
if (isNoProperties) {
const jsonExample = { [discriminant.wireValue]: variant.discriminantValue.wireValue };
return {
type: "success",
example: {
jsonExample,
shape: ExampleTypeReferenceShape.named({
shape: ExampleTypeShape.union({
discriminant,
singleUnionType: {
wireDiscriminantValue: variant.discriminantValue,
shape: FernIr.ExampleSingleUnionTypeProperties.noProperties()
},
baseProperties: [],
extendProperties: []
}),
typeName: typeDeclaration.name
})
},
jsonExample
};
}
}
return {
type: "failure",
message: `No simple variant available for recursive union ${typeDeclaration.name.typeId}`
};
}
case "undiscriminatedUnion": {
return {
type: "failure",
message: `Cannot generate stub for recursive undiscriminated union ${typeDeclaration.name.typeId}`
};
}
}
}
Loading