Skip to content

Commit b411f38

Browse files
feat(js): add streaming support for streamedListObjects
Updates JavaScript SDK templates to support the streaming API endpoint for unlimited object retrieval. Templates now handle streaming operations differently using vendor extension conditionals. Changes: - Add streaming.mustache template with NDJSON parser for Node.js - Update api.mustache to import createStreamingRequestFunction - Update apiInner.mustache with x-fga-streaming vendor extension logic - Uses createStreamingRequestFunction for streaming ops - Returns Promise<any> instead of PromiseResult<T> - Simplified telemetry (method name only) - Update index.mustache to export parseNDJSONStream - Update config.overrides.json with streaming file + feature flag - Add README documentation for Streamed List Objects API - Update API endpoints table with streaming endpoint Implementation: - Conditional template logic based on x-fga-streaming vendor extension - Preserves telemetry while returning raw Node.js stream - Aligned with Python SDK template patterns Dependencies: - Requires x-fga-streaming: true in OpenAPI spec (openfga/api) Related: - Fixes #76 (JavaScript SDK) - Implements openfga/js-sdk#236 - Related PR: openfga/js-sdk#280
1 parent 19d1271 commit b411f38

File tree

7 files changed

+204
-8
lines changed

7 files changed

+204
-8
lines changed

config/clients/js/config.overrides.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"fossaComplianceNoticeId": "9c7d9da4-2a75-47c9-bfc9-31b301fb764f",
1010
"useSingleRequestParameter": false,
1111
"supportsES6": true,
12+
"supportsStreamedListObjects": true,
1213
"modelPropertyNaming": "original",
1314
"openTelemetryDocumentation": "docs/opentelemetry.md",
1415
"files": {
@@ -23,6 +24,10 @@
2324
"npmrc.mustache": {
2425
"destinationFilename": ".npmrc",
2526
"templateType": "SupportingFiles"
27+
},
28+
"streaming.mustache": {
29+
"destinationFilename": "streaming.ts",
30+
"templateType": "SupportingFiles"
2631
}
2732
}
2833
}

config/clients/js/template/README_api_endpoints.mustache

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@
1414
| [**BatchCheck**]({{apiDocsUrl}}#/Relationship%20Queries/BatchCheck) | **POST** /stores/{store_id}/batch-check | Similar to check, but accepts a list of relations to check |
1515
| [**Expand**]({{apiDocsUrl}}#/Relationship%20Queries/Expand) | **POST** /stores/{store_id}/expand | Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason about and debug a certain relationship |
1616
| [**ListObjects**]({{apiDocsUrl}}#/Relationship%20Queries/ListObjects) | **POST** /stores/{store_id}/list-objects | [EXPERIMENTAL] Get all objects of the given type that the user has a relation with |
17-
| [**ReadAssertions**]({{apiDocsUrl}}#/Assertions/ReadAssertions) | **GET** /stores/{store_id}/assertions/{authorization_model_id} | Read assertions for an authorization model ID |
17+
{{#supportsStreamedListObjects}}| [**StreamedListObjects**]({{apiDocsUrl}}#/Relationship%20Queries/StreamedListObjects) | **POST** /stores/{store_id}/streamed-list-objects | Stream all objects of the given type that the user has a relation with (Node.js only) |
18+
{{/supportsStreamedListObjects}}| [**ReadAssertions**]({{apiDocsUrl}}#/Assertions/ReadAssertions) | **GET** /stores/{store_id}/assertions/{authorization_model_id} | Read assertions for an authorization model ID |
1819
| [**WriteAssertions**]({{apiDocsUrl}}#/Assertions/WriteAssertions) | **PUT** /stores/{store_id}/assertions/{authorization_model_id} | Upsert assertions for an authorization model ID |

config/clients/js/template/README_calling_api.mustache

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,28 @@ const response = await fgaClient.listObjects({
466466
// response.objects = ["document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"]
467467
```
468468

469+
{{#supportsStreamedListObjects}}
470+
##### Streamed List Objects
471+
472+
List the objects of a particular type that the user has a certain relation to, using the streaming API.
473+
474+
> **Note**: This is a Node.js-only feature. The streaming API allows you to retrieve more than the standard 1000 object limit.
475+
476+
[API Documentation]({{apiDocsUrl}}#/Relationship%20Queries/StreamedListObjects)
477+
478+
```javascript
479+
const fgaClient = new OpenFgaClient({ apiUrl: "http://localhost:8080", storeId: "01H0H015178Y2V4CX10C2KGHF4" });
480+
481+
for await (const response of fgaClient.streamedListObjects({
482+
user: "user:anne",
483+
relation: "owner",
484+
type: "document"
485+
})) {
486+
console.log(response.object);
487+
}
488+
```
489+
490+
{{/supportsStreamedListObjects}}
469491
##### List Relations
470492

471493
List the relations a user has with an object. This wraps around [BatchCheck](#batchcheck) to allow checking multiple relationships at once.

config/clients/js/template/api.mustache

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
serializeDataIfNeeded,
1111
toPathString,
1212
createRequestFunction,
13+
createStreamingRequestFunction,
1314
RequestArgs,
1415
CallResult,
1516
PromiseResult

config/clients/js/template/apiInner.mustache

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
serializeDataIfNeeded,
99
toPathString,
1010
createRequestFunction,
11+
createStreamingRequestFunction,
1112
CallResult,
1213
RequestArgs,
1314
PromiseResult
@@ -209,16 +210,22 @@ export const {{classname}}Fp = function(configuration: Configuration, credential
209210
* @deprecated{{/isDeprecated}}
210211
* @throws { FgaError }
211212
*/
212-
async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: any): Promise<(axios?: AxiosInstance) => PromiseResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>> {
213+
async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: any): Promise<(axios?: AxiosInstance) => {{#vendorExtensions}}{{#x-fga-streaming}}Promise<any>{{/x-fga-streaming}}{{^x-fga-streaming}}PromiseResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>{{/x-fga-streaming}}{{/vendorExtensions}}{{^vendorExtensions}}PromiseResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>{{/vendorExtensions}}> {
213214
const localVarAxiosArgs = localVarAxiosParamCreator.{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}options);
214-
return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, {
215-
[TelemetryAttribute.FgaClientRequestMethod]: "{{operationIdCamelCase}}",
215+
return {{#vendorExtensions}}{{#x-fga-streaming}}createStreamingRequestFunction{{/x-fga-streaming}}{{^x-fga-streaming}}createRequestFunction{{/x-fga-streaming}}{{/vendorExtensions}}{{^vendorExtensions}}createRequestFunction{{/vendorExtensions}}(localVarAxiosArgs, globalAxios, configuration, credentials, {
216+
[TelemetryAttribute.FgaClientRequestMethod]: "{{operationIdCamelCase}}"{{#vendorExtensions}}{{^x-fga-streaming}},
216217
{{#pathParams.0}}
217218
[TelemetryAttribute.FgaClientRequestStoreId]: storeId ?? "",
218219
{{/pathParams.0}}
219220
{{#bodyParam}}
220221
...TelemetryAttributes.fromRequestBody(body)
221-
{{/bodyParam}}
222+
{{/bodyParam}}{{/x-fga-streaming}}{{/vendorExtensions}}{{^vendorExtensions}},
223+
{{#pathParams.0}}
224+
[TelemetryAttribute.FgaClientRequestStoreId]: storeId ?? "",
225+
{{/pathParams.0}}
226+
{{#bodyParam}}
227+
...TelemetryAttributes.fromRequestBody(body)
228+
{{/bodyParam}}{{/vendorExtensions}}
222229
});
223230
},
224231
{{/operation}}
@@ -246,7 +253,7 @@ export const {{classname}}Factory = function (configuration: Configuration, cred
246253
* @deprecated{{/isDeprecated}}
247254
* @throws { FgaError }
248255
*/
249-
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: any): PromiseResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}> {
256+
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: any): {{#vendorExtensions}}{{#x-fga-streaming}}Promise<any>{{/x-fga-streaming}}{{^x-fga-streaming}}PromiseResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>{{/x-fga-streaming}}{{/vendorExtensions}}{{^vendorExtensions}}PromiseResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>{{/vendorExtensions}} {
250257
return localVarFp.{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}options).then((request) => request(axios));
251258
},
252259
{{/operation}}
@@ -341,12 +348,12 @@ export class {{classname}} extends BaseAPI {
341348
* @memberof {{classname}}
342349
*/
343350
{{#useSingleRequestParameter}}
344-
public {{nickname}}({{#allParams.0}}requestParameters: {{classname}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}options?: any): Promise<CallResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>> {
351+
public {{nickname}}({{#allParams.0}}requestParameters: {{classname}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}options?: any): {{#vendorExtensions}}{{#x-fga-streaming}}Promise<any>{{/x-fga-streaming}}{{^x-fga-streaming}}Promise<CallResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>>{{/x-fga-streaming}}{{/vendorExtensions}}{{^vendorExtensions}}Promise<CallResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>>{{/vendorExtensions}} {
345352
return {{classname}}Fp(this.configuration, this.credentials).{{nickname}}({{#allParams.0}}{{#allParams}}requestParameters.{{paramName}}, {{/allParams}}{{/allParams.0}}options).then((request) => request(this.axios));
346353
}
347354
{{/useSingleRequestParameter}}
348355
{{^useSingleRequestParameter}}
349-
public {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: any): Promise<CallResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>> {
356+
public {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: any): {{#vendorExtensions}}{{#x-fga-streaming}}Promise<any>{{/x-fga-streaming}}{{^x-fga-streaming}}Promise<CallResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>>{{/x-fga-streaming}}{{/vendorExtensions}}{{^vendorExtensions}}Promise<CallResult<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>>{{/vendorExtensions}} {
350357
return {{classname}}Fp(this.configuration, this.credentials).{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}options).then((request) => request(this.axios));
351358
}
352359
{{/useSingleRequestParameter}}

config/clients/js/template/index.mustache

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ export * from "./telemetry/counters";
1313
export * from "./telemetry/histograms";
1414
export * from "./telemetry/metrics";
1515
export * from "./errors";
16+
export { parseNDJSONStream } from "./streaming";
1617

1718
{{#withSeparateModelsAndApi}}export * from "./{{tsModelPackage}}";{{/withSeparateModelsAndApi}}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
{{>partial_header}}
2+
3+
import type { Readable } from "node:stream";
4+
5+
// Helper: create async iterable from classic EventEmitter-style Readable streams
6+
const createAsyncIterableFromReadable = (readable: any): AsyncIterable<any> => {
7+
return {
8+
[Symbol.asyncIterator](): AsyncIterator<any> {
9+
const chunkQueue: any[] = [];
10+
const pendingResolvers: Array<(value: IteratorResult<any>) => void> = [];
11+
let ended = false;
12+
let error: any = null;
13+
14+
const onData = (chunk: any) => {
15+
if (pendingResolvers.length > 0) {
16+
const resolve = pendingResolvers.shift()!;
17+
resolve({ value: chunk, done: false });
18+
} else {
19+
chunkQueue.push(chunk);
20+
}
21+
};
22+
23+
const onEnd = () => {
24+
ended = true;
25+
while (pendingResolvers.length > 0) {
26+
const resolve = pendingResolvers.shift()!;
27+
resolve({ value: undefined, done: true });
28+
}
29+
};
30+
31+
const onError = (err: any) => {
32+
error = err;
33+
while (pendingResolvers.length > 0) {
34+
const resolve = pendingResolvers.shift()!;
35+
// Rejecting inside async iterator isn't straightforward; surface as end and throw later
36+
resolve({ value: undefined, done: true });
37+
}
38+
};
39+
40+
readable.on("data", onData);
41+
readable.once("end", onEnd);
42+
readable.once("error", onError);
43+
44+
const cleanup = () => {
45+
readable.off("data", onData);
46+
readable.off("end", onEnd);
47+
readable.off("error", onError);
48+
};
49+
50+
return {
51+
next(): Promise<IteratorResult<any>> {
52+
if (error) {
53+
cleanup();
54+
return Promise.reject(error);
55+
}
56+
if (chunkQueue.length > 0) {
57+
const value = chunkQueue.shift();
58+
return Promise.resolve({ value, done: false });
59+
}
60+
if (ended) {
61+
cleanup();
62+
return Promise.resolve({ value: undefined, done: true });
63+
}
64+
return new Promise<IteratorResult<any>>((resolve) => {
65+
pendingResolvers.push(resolve);
66+
});
67+
},
68+
return(): Promise<IteratorResult<any>> {
69+
cleanup();
70+
return Promise.resolve({ value: undefined, done: true });
71+
},
72+
throw(e?: any): Promise<IteratorResult<any>> {
73+
cleanup();
74+
return Promise.reject(e);
75+
}
76+
};
77+
}
78+
};
79+
};
80+
81+
/**
82+
* Parse newline-delimited JSON (NDJSON) from a Node.js readable stream
83+
* @param stream - Node.js readable stream
84+
* @returns AsyncGenerator that yields parsed JSON objects
85+
*/
86+
export async function* parseNDJSONStream(stream: Readable): AsyncGenerator<any> {
87+
const decoder = new TextDecoder("utf-8");
88+
let buffer = "";
89+
90+
// If stream is actually a string or Buffer-like, handle as whole payload
91+
const isString = typeof stream === "string";
92+
const isBuffer = typeof Buffer !== "undefined" && Buffer.isBuffer && Buffer.isBuffer(stream);
93+
94+
if (isString || isBuffer) {
95+
const text = isString ? stream : new TextDecoder("utf-8").decode(new Uint8Array(stream));
96+
const lines = text.split("\n");
97+
98+
for (const line of lines) {
99+
const trimmed = line.trim();
100+
if (!trimmed) {
101+
continue;
102+
}
103+
104+
try {
105+
yield JSON.parse(trimmed);
106+
} catch (err) {
107+
console.warn("Failed to parse JSON line:", err);
108+
}
109+
}
110+
return;
111+
}
112+
113+
const isAsyncIterable = stream && typeof stream[Symbol.asyncIterator] === "function";
114+
const source: AsyncIterable<any> = isAsyncIterable ? stream : createAsyncIterableFromReadable(stream);
115+
116+
for await (const chunk of source) {
117+
// Node.js streams can return Buffer or string chunks
118+
// Convert to Uint8Array if needed for TextDecoder
119+
const uint8Chunk = typeof chunk === "string"
120+
? new TextEncoder().encode(chunk)
121+
: chunk instanceof Buffer
122+
? new Uint8Array(chunk)
123+
: chunk;
124+
125+
// Append decoded chunk to buffer
126+
buffer += decoder.decode(uint8Chunk, { stream: true });
127+
128+
// Split on newlines
129+
const lines = buffer.split("\n");
130+
131+
// Keep the last (potentially incomplete) line in the buffer
132+
buffer = lines.pop() || "";
133+
134+
// Parse and yield complete lines
135+
for (const line of lines) {
136+
const trimmed = line.trim();
137+
if (trimmed) {
138+
try {
139+
yield JSON.parse(trimmed);
140+
} catch (err) {
141+
console.warn("Failed to parse JSON line:", err);
142+
}
143+
}
144+
}
145+
}
146+
147+
// Flush any remaining decoder state
148+
buffer += decoder.decode();
149+
150+
// Handle any remaining data in buffer
151+
if (buffer.trim()) {
152+
try {
153+
yield JSON.parse(buffer);
154+
} catch (err) {
155+
console.warn("Failed to parse final JSON buffer:", err);
156+
}
157+
}
158+
}
159+

0 commit comments

Comments
 (0)