Skip to content

Commit 1bfbdd6

Browse files
feat(parser): x-fern-streaming.resumable for SSE auto-reconnection (#15795)
* feat(openapi): add x-fern-streaming.resumable for SSE reconnection Adds a `resumable` sub-property to the `x-fern-streaming` OpenAPI extension (and a corresponding `resumable` field on Fern Definition `response-stream` blocks). The flag is inheritable: setting `x-fern-streaming.resumable: true` at the document root applies to every SSE endpoint unless an operation overrides it. Defaults to false. The IR carries the value on `SseStreamChunk` so generators (Go SDK in a follow-up PR) can emit a client-side reconnect loop using standard SSE primitives (`Last-Event-ID`, `retry:`). * feat(openapi): wire resumable through v3.1 importer The newer openapi-to-ir importer (used for OpenAPI 3.1) had a parallel FernStreamingExtension that didn't read `resumable`. Mirror the document-level inheritance and propagation so the feature works identically across both importer paths. * chore(internal): regenerate fern-definition JSON schemas * chore(internal): refresh ir-generator-tests IR snapshots for resumable
1 parent 96a1b27 commit 1bfbdd6

50 files changed

Lines changed: 954 additions & 17 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

fern.schema.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2382,6 +2382,16 @@
23822382
"type": "null"
23832383
}
23842384
]
2385+
},
2386+
"resumable": {
2387+
"oneOf": [
2388+
{
2389+
"type": "boolean"
2390+
},
2391+
{
2392+
"type": "null"
2393+
}
2394+
]
23852395
}
23862396
},
23872397
"required": [

fern/apis/fern-definition/definition/service.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,12 @@ types:
199199
type: string
200200
format: optional<StreamFormat>
201201
terminator: optional<string>
202+
resumable:
203+
type: optional<boolean>
204+
docs: |
205+
For SSE responses (`format: sse`), when true, the endpoint
206+
participates in client-side reconnection using `Last-Event-ID`
207+
and `retry:`. Defaults to false. No effect on non-SSE streams.
202208
203209
StreamFormat:
204210
enum:

package-yml.schema.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2402,6 +2402,16 @@
24022402
"type": "null"
24032403
}
24042404
]
2405+
},
2406+
"resumable": {
2407+
"oneOf": [
2408+
{
2409+
"type": "boolean"
2410+
},
2411+
{
2412+
"type": "null"
2413+
}
2414+
]
24052415
}
24062416
},
24072417
"required": [

packages/cli/api-importers/openapi-to-ir/src/3.1/paths/PathConverter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
55
import { HttpMethods } from "../../constants/HttpMethods.js";
66
import { FernIdempotentExtension } from "../../extensions/x-fern-idempotent.js";
77
import { FernPaginationExtension } from "../../extensions/x-fern-pagination.js";
8-
import { FernStreamingExtension } from "../../extensions/x-fern-streaming.js";
8+
import { FernStreamingExtension, getDocumentLevelResumable } from "../../extensions/x-fern-streaming.js";
99
import { FernWebhookExtension } from "../../extensions/x-fern-webhook.js";
1010
import { OpenAPIConverterContext3_1 } from "../OpenAPIConverterContext3_1.js";
1111
import { OperationConverter } from "./operations/OperationConverter.js";
@@ -68,6 +68,7 @@ export class PathConverter extends AbstractConverter<OpenAPIConverterContext3_1,
6868

6969
const streamingExtensionConverter = new FernStreamingExtension({
7070
breadcrumbs: operationBreadcrumbs,
71+
document: this.context.spec,
7172
operation,
7273
context: this.context
7374
});
@@ -78,7 +79,12 @@ export class PathConverter extends AbstractConverter<OpenAPIConverterContext3_1,
7879
if (streamingExtension == null) {
7980
const hasTextEventStream = this.operationHasTextEventStreamResponse(operation);
8081
if (hasTextEventStream) {
81-
streamingExtension = { type: "stream", format: "sse", terminator: undefined };
82+
streamingExtension = {
83+
type: "stream",
84+
format: "sse",
85+
terminator: undefined,
86+
resumable: getDocumentLevelResumable(this.context.spec) ?? false
87+
};
8288
}
8389
}
8490

packages/cli/api-importers/openapi-to-ir/src/3.1/paths/ResponseBodyConverter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ export class ResponseBodyConverter extends Converters.AbstractConverters.Abstrac
300300
docs: this.responseBody.description,
301301
payload: convertedSchema.type,
302302
terminator: this.streamingExtension?.terminator,
303+
resumable: this.streamingExtension?.resumable,
303304
v2Examples: this.convertMediaTypeObjectExamples({
304305
mediaTypeObject,
305306
generateOptionalProperties: true,

packages/cli/api-importers/openapi-to-ir/src/extensions/x-fern-streaming.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const StreamingExtensionObjectSchema = z.object({
1111
"stream-request-name": z.string().optional(),
1212
"response-stream": z.any(),
1313
response: z.any(),
14-
terminator: z.string().optional()
14+
terminator: z.string().optional(),
15+
resumable: z.boolean().optional()
1516
});
1617

1718
const StreamingExtensionSchema = z.union([z.boolean(), StreamingExtensionObjectSchema]);
@@ -20,6 +21,7 @@ type OnlyStreamingEndpoint = {
2021
type: "stream";
2122
format: "sse" | "json";
2223
terminator: string | undefined;
24+
resumable: boolean;
2325
};
2426

2527
type StreamConditionEndpoint = {
@@ -31,22 +33,26 @@ type StreamConditionEndpoint = {
3133
streamRequestName: string | undefined;
3234
responseStream: OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject;
3335
response: OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject;
36+
resumable: boolean;
3437
};
3538

3639
export declare namespace FernStreamingExtension {
3740
export interface Args extends AbstractExtension.Args {
41+
document: object;
3842
operation: object;
3943
}
4044

4145
export type Output = OnlyStreamingEndpoint | StreamConditionEndpoint;
4246
}
4347

4448
export class FernStreamingExtension extends AbstractExtension<FernStreamingExtension.Output> {
49+
private readonly document: object;
4550
private readonly operation: object;
4651
public readonly key = "x-fern-streaming";
4752

48-
constructor({ breadcrumbs, operation, context }: FernStreamingExtension.Args) {
53+
constructor({ breadcrumbs, document, operation, context }: FernStreamingExtension.Args) {
4954
super({ breadcrumbs, context });
55+
this.document = document;
5056
this.operation = operation;
5157
}
5258

@@ -66,11 +72,17 @@ export class FernStreamingExtension extends AbstractExtension<FernStreamingExten
6672
}
6773

6874
if (typeof result.data === "boolean") {
69-
return result.data ? { type: "stream", format: "json", terminator: undefined } : undefined;
75+
// Boolean shorthand emits format: "json", which has no Last-Event-ID semantics —
76+
// do not inherit resumable from the document for this case.
77+
return result.data
78+
? { type: "stream", format: "json", terminator: undefined, resumable: false }
79+
: undefined;
7080
}
7181

82+
const resumable = result.data.resumable ?? getDocumentLevelResumable(this.document) ?? false;
83+
7284
if (result.data["stream-condition"] == null && result.data.format != null) {
73-
return { type: "stream", format: result.data.format, terminator: result.data.terminator };
85+
return { type: "stream", format: result.data.format, terminator: result.data.terminator, resumable };
7486
}
7587

7688
if (result.data["stream-condition"] == null) {
@@ -92,7 +104,17 @@ export class FernStreamingExtension extends AbstractExtension<FernStreamingExten
92104
),
93105
streamRequestName: result.data["stream-request-name"],
94106
responseStream: result.data["response-stream"],
95-
response: result.data.response
107+
response: result.data.response,
108+
resumable
96109
};
97110
}
98111
}
112+
113+
export function getDocumentLevelResumable(document: object): boolean | undefined {
114+
const docStreaming = (document as Record<string, unknown>)["x-fern-streaming"];
115+
if (docStreaming == null || typeof docStreaming === "boolean") {
116+
return undefined;
117+
}
118+
const resumable = (docStreaming as Record<string, unknown>).resumable;
119+
return typeof resumable === "boolean" ? resumable : undefined;
120+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { OpenAPIV3 } from "openapi-types";
2+
import { describe, expect, it } from "vitest";
3+
4+
import {
5+
getDocumentLevelResumable,
6+
getFernStreamingExtension
7+
} from "../openapi/v3/extensions/getFernStreamingExtension.js";
8+
9+
type DocumentWithExtensions = OpenAPIV3.Document & { [key: `x-${string}`]: unknown };
10+
type OperationWithExtensions = OpenAPIV3.OperationObject & { [key: `x-${string}`]: unknown };
11+
12+
function makeDocument(streamingExtension?: unknown): DocumentWithExtensions {
13+
const doc: DocumentWithExtensions = {
14+
openapi: "3.0.0",
15+
info: { title: "Test", version: "1.0.0" },
16+
paths: {}
17+
};
18+
if (streamingExtension !== undefined) {
19+
doc["x-fern-streaming"] = streamingExtension;
20+
}
21+
return doc;
22+
}
23+
24+
function makeOperation(streamingExtension?: unknown): OperationWithExtensions {
25+
const op: OperationWithExtensions = { responses: {} };
26+
if (streamingExtension !== undefined) {
27+
op["x-fern-streaming"] = streamingExtension;
28+
}
29+
return op;
30+
}
31+
32+
describe("getFernStreamingExtension - resumable inheritance (Option A: silent fallback)", () => {
33+
it("uses operation-level resumable when set", () => {
34+
const document = makeDocument();
35+
const operation = makeOperation({ format: "sse", resumable: true });
36+
37+
const result = getFernStreamingExtension(document, operation);
38+
39+
expect(result?.type).toBe("stream");
40+
expect(result?.resumable).toBe(true);
41+
});
42+
43+
it("falls back to document-level resumable when operation does not set it", () => {
44+
const document = makeDocument({ resumable: true });
45+
const operation = makeOperation({ format: "sse" });
46+
47+
const result = getFernStreamingExtension(document, operation);
48+
49+
expect(result?.resumable).toBe(true);
50+
});
51+
52+
it("operation-level resumable: false overrides document-level resumable: true", () => {
53+
const document = makeDocument({ resumable: true });
54+
const operation = makeOperation({ format: "sse", resumable: false });
55+
56+
const result = getFernStreamingExtension(document, operation);
57+
58+
expect(result?.resumable).toBe(false);
59+
});
60+
61+
it("defaults to false when neither operation nor document sets resumable", () => {
62+
const document = makeDocument();
63+
const operation = makeOperation({ format: "sse" });
64+
65+
const result = getFernStreamingExtension(document, operation);
66+
67+
expect(result?.resumable).toBe(false);
68+
});
69+
70+
it("ignores document-level resumable when operation uses boolean shorthand", () => {
71+
// Boolean shorthand emits format: "json", which has no Last-Event-ID semantics.
72+
const document = makeDocument({ resumable: true });
73+
const operation = makeOperation(true);
74+
75+
const result = getFernStreamingExtension(document, operation);
76+
77+
expect(result?.resumable).toBe(false);
78+
});
79+
80+
it("inherits resumable for stream-condition endpoints", () => {
81+
const document = makeDocument({ resumable: true });
82+
const operation = makeOperation({
83+
format: "sse",
84+
"stream-condition": "$request.stream",
85+
response: { type: "object" },
86+
"response-stream": { type: "object" }
87+
});
88+
89+
const result = getFernStreamingExtension(document, operation);
90+
91+
expect(result?.type).toBe("streamCondition");
92+
expect(result?.resumable).toBe(true);
93+
});
94+
});
95+
96+
describe("getDocumentLevelResumable", () => {
97+
it("returns true when document-level x-fern-streaming.resumable is true", () => {
98+
const document = makeDocument({ resumable: true });
99+
expect(getDocumentLevelResumable(document)).toBe(true);
100+
});
101+
102+
it("returns false when document-level x-fern-streaming.resumable is false", () => {
103+
const document = makeDocument({ resumable: false });
104+
expect(getDocumentLevelResumable(document)).toBe(false);
105+
});
106+
107+
it("returns undefined when document has no x-fern-streaming extension", () => {
108+
const document = makeDocument();
109+
expect(getDocumentLevelResumable(document)).toBeUndefined();
110+
});
111+
112+
it("returns undefined when document-level x-fern-streaming is a boolean", () => {
113+
const document = makeDocument(true);
114+
expect(getDocumentLevelResumable(document)).toBeUndefined();
115+
});
116+
117+
it("returns undefined when document-level resumable is a non-boolean value", () => {
118+
const document = makeDocument({ resumable: "yes" });
119+
expect(getDocumentLevelResumable(document)).toBeUndefined();
120+
});
121+
});

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ export function convertResponse({
3232
responseStatusCode,
3333
streamFormat,
3434
streamTerminator,
35+
streamResumable,
3536
source
3637
}: {
3738
operationContext: OperationContext;
3839
streamFormat: "sse" | "json" | undefined;
3940
streamTerminator?: string;
41+
streamResumable?: boolean;
4042
responses: OpenAPIV3.ResponsesObject;
4143
context: AbstractOpenAPIV3ParserContext;
4244
responseBreadcrumbs: string[];
@@ -67,6 +69,7 @@ export function convertResponse({
6769
responseBreadcrumbs,
6870
streamFormat,
6971
streamTerminator,
72+
streamResumable,
7073
source,
7174
namespace: context.namespace,
7275
statusCode: statusCodeNum
@@ -124,6 +127,7 @@ export function convertResponse({
124127
responseBreadcrumbs,
125128
streamFormat,
126129
streamTerminator,
130+
streamResumable,
127131
source,
128132
namespace: context.namespace
129133
});
@@ -165,6 +169,7 @@ function convertResolvedResponse({
165169
operationContext,
166170
streamFormat,
167171
streamTerminator,
172+
streamResumable,
168173
response,
169174
context,
170175
responseBreadcrumbs,
@@ -175,6 +180,7 @@ function convertResolvedResponse({
175180
operationContext: OperationContext;
176181
streamFormat: "sse" | "json" | undefined;
177182
streamTerminator?: string;
183+
streamResumable?: boolean;
178184
response: OpenAPIV3.ReferenceObject | OpenAPIV3.ResponseObject;
179185
context: AbstractOpenAPIV3ParserContext;
180186
responseBreadcrumbs: string[];
@@ -220,6 +226,7 @@ function convertResolvedResponse({
220226
FernOpenAPIExtension.RESPONSE_PROPERTY
221227
),
222228
terminator: streamTerminator,
229+
resumable: undefined,
223230
fullExamples: textEventStreamObject.examples,
224231
schema: convertSchema(
225232
textEventStreamObject.schema,
@@ -237,6 +244,7 @@ function convertResolvedResponse({
237244
description: resolvedResponse.description,
238245
responseProperty: undefined,
239246
terminator: streamTerminator,
247+
resumable: streamResumable,
240248
fullExamples: textEventStreamObject.examples,
241249
schema: convertSchema(
242250
textEventStreamObject.schema,
@@ -265,6 +273,7 @@ function convertResolvedResponse({
265273
description: resolvedResponse.description,
266274
responseProperty: undefined,
267275
terminator: streamTerminator,
276+
resumable: undefined,
268277
fullExamples: jsonMediaObject.examples,
269278
schema: convertSchema(
270279
jsonMediaObject.schema,
@@ -283,6 +292,7 @@ function convertResolvedResponse({
283292
description: resolvedResponse.description,
284293
responseProperty: undefined,
285294
terminator: streamTerminator,
295+
resumable: streamResumable,
286296
fullExamples: jsonMediaObject.examples,
287297
schema: convertSchema(
288298
jsonMediaObject.schema,
@@ -311,6 +321,7 @@ function convertResolvedResponse({
311321
),
312322
responseProperty: getExtension<string>(operationContext.operation, FernOpenAPIExtension.RESPONSE_PROPERTY),
313323
terminator: undefined,
324+
resumable: undefined,
314325
fullExamples: jsonMediaObject.examples,
315326
source,
316327
statusCode

0 commit comments

Comments
 (0)