@zmdb/client 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +57 -0
- package/dist/body/index.d.ts +7 -0
- package/dist/body/index.d.ts.map +1 -0
- package/dist/body/index.js +55 -0
- package/dist/body/index.js.map +1 -0
- package/dist/errors/index.d.ts +59 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +117 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/headers/index.d.ts +6 -0
- package/dist/headers/index.d.ts.map +1 -0
- package/dist/headers/index.js +52 -0
- package/dist/headers/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +585 -0
- package/dist/runtime.js.map +1 -0
- package/dist/testing/index.d.ts +13 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +53 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/transport/index.d.ts +4 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +47 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/url/index.d.ts +17 -0
- package/dist/url/index.d.ts.map +1 -0
- package/dist/url/index.js +98 -0
- package/dist/url/index.js.map +1 -0
- package/package.json +68 -0
- package/src/body/index.ts +58 -0
- package/src/errors/index.ts +152 -0
- package/src/headers/index.ts +57 -0
- package/src/index.ts +42 -0
- package/src/runtime.ts +741 -0
- package/src/testing/index.ts +67 -0
- package/src/transport/index.ts +56 -0
- package/src/types.ts +128 -0
- package/src/url/index.ts +113 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
import { assertPositiveByteLimit, DEFAULT_MAX_ERROR_BODY_BYTES, DEFAULT_MAX_RESPONSE_BYTES } from './body/index.js';
|
|
2
|
+
import {
|
|
3
|
+
AuthenticationError,
|
|
4
|
+
ClientError,
|
|
5
|
+
ClientRequestError,
|
|
6
|
+
ClientTimeoutError,
|
|
7
|
+
MissingAuthenticationError,
|
|
8
|
+
ResponseDecodeError,
|
|
9
|
+
ResponseTooLargeError,
|
|
10
|
+
ResponseValidationError,
|
|
11
|
+
TransportError,
|
|
12
|
+
UnexpectedContentTypeError,
|
|
13
|
+
UnexpectedStatusError,
|
|
14
|
+
} from './errors/index.js';
|
|
15
|
+
import { assertNoTransportOwnedHeaders, mergeClientHeaders, normalizeClientHeaders } from './headers/index.js';
|
|
16
|
+
import { createFetchTransport } from './transport/index.js';
|
|
17
|
+
import type {
|
|
18
|
+
AuthenticationPatch,
|
|
19
|
+
AuthenticationProvider,
|
|
20
|
+
CallOptions,
|
|
21
|
+
ClientHeaders,
|
|
22
|
+
ClientBytes,
|
|
23
|
+
ClientOperationResponse,
|
|
24
|
+
ClientOptions,
|
|
25
|
+
ClientQueryPair,
|
|
26
|
+
ClientRequest,
|
|
27
|
+
ClientResponse,
|
|
28
|
+
ClientResponseBody,
|
|
29
|
+
ClientRuntime,
|
|
30
|
+
ClientSecurityScheme,
|
|
31
|
+
ClientTransport,
|
|
32
|
+
DecodeResult,
|
|
33
|
+
GeneratedOperation,
|
|
34
|
+
PreparedClientRequest,
|
|
35
|
+
} from './types.js';
|
|
36
|
+
import { encodeClientComponent, normalizeClientBaseUrl, resolveClientUrl, type ClientBaseUrl } from './url/index.js';
|
|
37
|
+
|
|
38
|
+
export const CLIENT_RUNTIME_ABI = 1;
|
|
39
|
+
|
|
40
|
+
interface RuntimeConfiguration {
|
|
41
|
+
readonly baseUrl: ClientBaseUrl;
|
|
42
|
+
readonly transport: ClientTransport;
|
|
43
|
+
readonly authentication?: AuthenticationProvider;
|
|
44
|
+
readonly headers: ClientHeaders;
|
|
45
|
+
readonly maxResponseBytes: number;
|
|
46
|
+
readonly maxErrorBodyBytes: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface CancellationScope {
|
|
50
|
+
readonly signal: AbortSignal | undefined;
|
|
51
|
+
cleanup(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface AuthenticationResult {
|
|
55
|
+
readonly headers: ClientHeaders;
|
|
56
|
+
readonly query: readonly ClientQueryPair[];
|
|
57
|
+
readonly cookies: readonly ClientQueryPair[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CONTRACT_OWNED_OPTION_HEADERS = new Set(['accept', 'content-type']);
|
|
61
|
+
const EMPTY_STATUSES = new Set([204, 205, 304]);
|
|
62
|
+
|
|
63
|
+
function operationInit(
|
|
64
|
+
operationId: string,
|
|
65
|
+
cause?: unknown,
|
|
66
|
+
): { readonly operationId: string; readonly cause?: unknown } {
|
|
67
|
+
return cause === undefined ? { operationId } : { operationId, cause };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function configuration(options: ClientOptions): RuntimeConfiguration {
|
|
71
|
+
const headers = normalizeClientHeaders(options.headers);
|
|
72
|
+
assertNoTransportOwnedHeaders(headers);
|
|
73
|
+
for (const name of Object.keys(headers)) {
|
|
74
|
+
if (CONTRACT_OWNED_OPTION_HEADERS.has(name)) {
|
|
75
|
+
throw new ClientRequestError(`HTTP header ${name} is owned by the generated operation`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
baseUrl: normalizeClientBaseUrl(options.baseUrl),
|
|
80
|
+
transport: options.transport ?? createFetchTransport(),
|
|
81
|
+
...(options.authentication === undefined ? {} : { authentication: options.authentication }),
|
|
82
|
+
headers,
|
|
83
|
+
maxResponseBytes: assertPositiveByteLimit(
|
|
84
|
+
options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
|
|
85
|
+
'maxResponseBytes',
|
|
86
|
+
),
|
|
87
|
+
maxErrorBodyBytes: assertPositiveByteLimit(
|
|
88
|
+
options.maxErrorBodyBytes ?? DEFAULT_MAX_ERROR_BODY_BYTES,
|
|
89
|
+
'maxErrorBodyBytes',
|
|
90
|
+
),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function timeoutValue(value: number | undefined): number | undefined {
|
|
95
|
+
if (value === undefined) return undefined;
|
|
96
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
97
|
+
throw new ClientRequestError('timeoutMs must be a positive finite integer');
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cancellation(
|
|
103
|
+
operationId: string,
|
|
104
|
+
caller: AbortSignal | undefined,
|
|
105
|
+
timeoutMs: number | undefined,
|
|
106
|
+
): CancellationScope {
|
|
107
|
+
if (caller?.aborted === true) throw caller.reason;
|
|
108
|
+
if (caller === undefined && timeoutMs === undefined) return { signal: undefined, cleanup() {} };
|
|
109
|
+
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const onAbort = (): void => {
|
|
112
|
+
if (!controller.signal.aborted) controller.abort(caller?.reason);
|
|
113
|
+
};
|
|
114
|
+
if (caller !== undefined) caller.addEventListener('abort', onAbort, { once: true });
|
|
115
|
+
|
|
116
|
+
const timeoutError = timeoutMs === undefined ? undefined : new ClientTimeoutError(operationId, timeoutMs);
|
|
117
|
+
const timer =
|
|
118
|
+
timeoutMs === undefined
|
|
119
|
+
? undefined
|
|
120
|
+
: globalThis.setTimeout(() => {
|
|
121
|
+
if (!controller.signal.aborted) controller.abort(timeoutError);
|
|
122
|
+
}, timeoutMs);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
signal: controller.signal,
|
|
126
|
+
cleanup() {
|
|
127
|
+
if (caller !== undefined) caller.removeEventListener('abort', onAbort);
|
|
128
|
+
if (timer !== undefined) globalThis.clearTimeout(timer);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function withSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
134
|
+
if (signal === undefined) return promise;
|
|
135
|
+
if (signal.aborted) throw signal.reason;
|
|
136
|
+
return new Promise<T>((resolve, reject) => {
|
|
137
|
+
const onAbort = (): void => {
|
|
138
|
+
reject(signal.reason);
|
|
139
|
+
};
|
|
140
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
141
|
+
void promise.then(
|
|
142
|
+
value => {
|
|
143
|
+
signal.removeEventListener('abort', onAbort);
|
|
144
|
+
resolve(value);
|
|
145
|
+
},
|
|
146
|
+
error => {
|
|
147
|
+
signal.removeEventListener('abort', onAbort);
|
|
148
|
+
reject(signal.aborted ? signal.reason : error);
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function selectedVersion(
|
|
155
|
+
operation: GeneratedOperation<unknown, unknown>,
|
|
156
|
+
supplied: string | undefined,
|
|
157
|
+
): string | undefined {
|
|
158
|
+
if (operation.version.kind === 'none') {
|
|
159
|
+
if (supplied !== undefined) {
|
|
160
|
+
throw new ClientRequestError(`Operation ${operation.operationId} does not accept a version`, {
|
|
161
|
+
operationId: operation.operationId,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
const version = supplied ?? operation.version.default;
|
|
167
|
+
if (!operation.version.values.includes(version)) {
|
|
168
|
+
throw new ClientRequestError(
|
|
169
|
+
`Operation ${operation.operationId} version must be one of ${operation.version.values.join(', ')}`,
|
|
170
|
+
{ operationId: operation.operationId },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return version;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function preparedRequest(
|
|
177
|
+
operationId: string,
|
|
178
|
+
operation: GeneratedOperation<unknown, unknown>,
|
|
179
|
+
input: unknown,
|
|
180
|
+
version: string | undefined,
|
|
181
|
+
): PreparedClientRequest {
|
|
182
|
+
try {
|
|
183
|
+
const prepared = operation.prepare(input, version);
|
|
184
|
+
if (!prepared.path.startsWith('/') || prepared.path.includes('?') || prepared.path.includes('#')) {
|
|
185
|
+
throw new ClientRequestError(`Operation ${operationId} prepared an invalid path`, { operationId });
|
|
186
|
+
}
|
|
187
|
+
const headers = normalizeClientHeaders(prepared.headers);
|
|
188
|
+
assertNoTransportOwnedHeaders(headers);
|
|
189
|
+
return Object.freeze({
|
|
190
|
+
path: prepared.path,
|
|
191
|
+
query: Object.freeze(prepared.query.map(pair => Object.freeze({ ...pair }))),
|
|
192
|
+
headers,
|
|
193
|
+
cookies: Object.freeze(prepared.cookies.map(pair => Object.freeze({ ...pair }))),
|
|
194
|
+
...(prepared.body === undefined ? {} : { body: prepared.body }),
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error instanceof ClientError) throw error;
|
|
198
|
+
throw new ClientRequestError(
|
|
199
|
+
`Operation ${operationId} could not prepare its request`,
|
|
200
|
+
operationInit(operationId, error),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function schemeLocation(scheme: ClientSecurityScheme): {
|
|
206
|
+
readonly in: 'header' | 'query' | 'cookie' | 'transport';
|
|
207
|
+
readonly name?: string;
|
|
208
|
+
} {
|
|
209
|
+
if (scheme.type === 'mutualTLS') return { in: 'transport' };
|
|
210
|
+
if (scheme.type === 'apiKey') return { in: scheme.in, name: scheme.name };
|
|
211
|
+
return { in: 'header', name: 'authorization' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function patchEntries(patch: AuthenticationPatch, location: 'headers' | 'query' | 'cookies'): readonly string[] {
|
|
215
|
+
return Object.keys(patch[location] ?? {}).toSorted();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function authenticationPatch(
|
|
219
|
+
operationId: string,
|
|
220
|
+
operation: GeneratedOperation<unknown, unknown>,
|
|
221
|
+
patch: AuthenticationPatch,
|
|
222
|
+
prepared: PreparedClientRequest,
|
|
223
|
+
): AuthenticationResult {
|
|
224
|
+
if (!Number.isInteger(patch.requirement) || patch.requirement < 0) {
|
|
225
|
+
throw new ClientRequestError(`Operation ${operationId} authentication selected an invalid requirement`, {
|
|
226
|
+
operationId,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const requirement = operation.security[patch.requirement];
|
|
230
|
+
if (requirement === undefined) {
|
|
231
|
+
throw new ClientRequestError(`Operation ${operationId} authentication selected an unknown requirement`, {
|
|
232
|
+
operationId,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const expected = {
|
|
237
|
+
headers: new Map<string, ClientSecurityScheme>(),
|
|
238
|
+
query: new Map<string, ClientSecurityScheme>(),
|
|
239
|
+
cookies: new Map<string, ClientSecurityScheme>(),
|
|
240
|
+
};
|
|
241
|
+
for (const schemeName of Object.keys(requirement).toSorted()) {
|
|
242
|
+
const scheme = operation.schemes[schemeName];
|
|
243
|
+
if (scheme === undefined) {
|
|
244
|
+
throw new ClientRequestError(`Operation ${operationId} references unknown security scheme ${schemeName}`, {
|
|
245
|
+
operationId,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const location = schemeLocation(scheme);
|
|
249
|
+
if (location.in === 'transport') continue;
|
|
250
|
+
const name = location.name;
|
|
251
|
+
if (name === undefined) continue;
|
|
252
|
+
const collection =
|
|
253
|
+
location.in === 'header' ? expected.headers : location.in === 'query' ? expected.query : expected.cookies;
|
|
254
|
+
if (collection.has(name.toLowerCase())) {
|
|
255
|
+
throw new ClientRequestError(`Operation ${operationId} has colliding authentication wire names`, {
|
|
256
|
+
operationId,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
collection.set(location.in === 'header' ? name.toLowerCase() : name, scheme);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const headers = normalizeClientHeaders(patch.headers);
|
|
263
|
+
const supplied = {
|
|
264
|
+
headers: patchEntries({ ...patch, headers }, 'headers'),
|
|
265
|
+
query: patchEntries(patch, 'query'),
|
|
266
|
+
cookies: patchEntries(patch, 'cookies'),
|
|
267
|
+
};
|
|
268
|
+
for (const location of ['headers', 'query', 'cookies'] as const) {
|
|
269
|
+
const wanted = [...expected[location].keys()].toSorted();
|
|
270
|
+
if (JSON.stringify(supplied[location]) !== JSON.stringify(wanted)) {
|
|
271
|
+
throw new ClientRequestError(
|
|
272
|
+
`Operation ${operationId} authentication patch does not exactly satisfy its ${location} requirement`,
|
|
273
|
+
{ operationId },
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const name of expected.headers.keys()) {
|
|
279
|
+
if (prepared.headers[name] !== undefined) {
|
|
280
|
+
throw new ClientRequestError(`Operation ${operationId} authentication collides with header ${name}`, {
|
|
281
|
+
operationId,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const declaredQuery = new Set(prepared.query.map(pair => pair.name));
|
|
286
|
+
for (const name of expected.query.keys()) {
|
|
287
|
+
if (declaredQuery.has(name)) {
|
|
288
|
+
throw new ClientRequestError(`Operation ${operationId} authentication collides with query ${name}`, {
|
|
289
|
+
operationId,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const declaredCookies = new Set(prepared.cookies.map(pair => pair.name));
|
|
294
|
+
for (const name of expected.cookies.keys()) {
|
|
295
|
+
if (declaredCookies.has(name)) {
|
|
296
|
+
throw new ClientRequestError(`Operation ${operationId} authentication collides with cookie ${name}`, {
|
|
297
|
+
operationId,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const query: ClientQueryPair[] = [];
|
|
303
|
+
const cookies: ClientQueryPair[] = [];
|
|
304
|
+
for (const schemeName of Object.keys(requirement).toSorted()) {
|
|
305
|
+
const scheme = operation.schemes[schemeName];
|
|
306
|
+
if (scheme === undefined) continue;
|
|
307
|
+
const location = schemeLocation(scheme);
|
|
308
|
+
const name = location.name;
|
|
309
|
+
if (name === undefined || location.in === 'transport' || location.in === 'header') continue;
|
|
310
|
+
if (location.in === 'cookie') {
|
|
311
|
+
const value = patch.cookies?.[name];
|
|
312
|
+
if (typeof value !== 'string') {
|
|
313
|
+
throw new ClientRequestError(`Operation ${operationId} authentication cookie ${name} must be scalar`, {
|
|
314
|
+
operationId,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
cookies.push(Object.freeze({ name, value }));
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const value = patch.query?.[name];
|
|
321
|
+
const values = typeof value === 'string' ? [value] : value;
|
|
322
|
+
if (values === undefined || values.some(item => typeof item !== 'string')) {
|
|
323
|
+
throw new ClientRequestError(`Operation ${operationId} authentication query ${name} must contain strings`, {
|
|
324
|
+
operationId,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
for (const item of values) query.push(Object.freeze({ name, value: item }));
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
headers,
|
|
331
|
+
query: Object.freeze(query),
|
|
332
|
+
cookies: Object.freeze(cookies),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function authenticate(
|
|
337
|
+
operation: GeneratedOperation<unknown, unknown>,
|
|
338
|
+
prepared: PreparedClientRequest,
|
|
339
|
+
version: string | undefined,
|
|
340
|
+
configured: AuthenticationProvider | undefined,
|
|
341
|
+
perCall: AuthenticationProvider | undefined,
|
|
342
|
+
signal: AbortSignal | undefined,
|
|
343
|
+
): Promise<AuthenticationResult> {
|
|
344
|
+
if (operation.security.length === 0) return { headers: {}, query: [], cookies: [] };
|
|
345
|
+
const provider = perCall ?? configured;
|
|
346
|
+
if (provider === undefined) throw new MissingAuthenticationError(operation.operationId);
|
|
347
|
+
|
|
348
|
+
let patch: AuthenticationPatch;
|
|
349
|
+
try {
|
|
350
|
+
patch = await withSignal(
|
|
351
|
+
Promise.resolve(
|
|
352
|
+
provider(
|
|
353
|
+
Object.freeze({
|
|
354
|
+
operationId: operation.operationId,
|
|
355
|
+
requirements: operation.security,
|
|
356
|
+
schemes: operation.schemes,
|
|
357
|
+
...(version === undefined ? {} : { version }),
|
|
358
|
+
...(signal === undefined ? {} : { signal }),
|
|
359
|
+
}),
|
|
360
|
+
),
|
|
361
|
+
),
|
|
362
|
+
signal,
|
|
363
|
+
);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (signal?.aborted === true) throw signal.reason;
|
|
366
|
+
throw new AuthenticationError(operation.operationId, error);
|
|
367
|
+
}
|
|
368
|
+
return authenticationPatch(operation.operationId, operation, patch, prepared);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function cookieHeader(cookies: readonly ClientQueryPair[]): ClientHeaders {
|
|
372
|
+
if (cookies.length === 0) return {};
|
|
373
|
+
const value = cookies.map(pair => `${pair.name}=${encodeClientComponent(pair.value)}`).join('; ');
|
|
374
|
+
return Object.freeze({ cookie: value });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function finalRequest(
|
|
378
|
+
config: RuntimeConfiguration,
|
|
379
|
+
operation: GeneratedOperation<unknown, unknown>,
|
|
380
|
+
prepared: PreparedClientRequest,
|
|
381
|
+
authentication: AuthenticationResult,
|
|
382
|
+
signal: AbortSignal | undefined,
|
|
383
|
+
): ClientRequest {
|
|
384
|
+
const headers = mergeClientHeaders(
|
|
385
|
+
config.headers,
|
|
386
|
+
prepared.headers,
|
|
387
|
+
authentication.headers,
|
|
388
|
+
cookieHeader([...prepared.cookies, ...authentication.cookies]),
|
|
389
|
+
);
|
|
390
|
+
assertNoTransportOwnedHeaders(headers);
|
|
391
|
+
const url = resolveClientUrl(config.baseUrl, prepared.path, [...prepared.query, ...authentication.query]);
|
|
392
|
+
return Object.freeze({
|
|
393
|
+
method: operation.method,
|
|
394
|
+
url,
|
|
395
|
+
headers,
|
|
396
|
+
...(prepared.body === undefined ? {} : { body: prepared.body }),
|
|
397
|
+
...(signal === undefined ? {} : { signal }),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function normalizedResponse(operationId: string, response: ClientResponse): ClientResponse {
|
|
402
|
+
if (!Number.isInteger(response.status) || response.status < 100 || response.status > 599) {
|
|
403
|
+
throw new TransportError(operationId, new Error(`Transport returned invalid status ${String(response.status)}`));
|
|
404
|
+
}
|
|
405
|
+
return Object.freeze({
|
|
406
|
+
status: response.status,
|
|
407
|
+
headers: normalizeClientHeaders(response.headers),
|
|
408
|
+
body: response.body,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function contentTypeParts(value: string): { readonly base: string; readonly parameters: ReadonlyMap<string, string> } {
|
|
413
|
+
const [base = '', ...rawParameters] = value.split(';');
|
|
414
|
+
const parameters = new Map<string, string>();
|
|
415
|
+
for (const raw of rawParameters) {
|
|
416
|
+
const separator = raw.indexOf('=');
|
|
417
|
+
if (separator < 0) continue;
|
|
418
|
+
const name = raw.slice(0, separator).trim().toLowerCase();
|
|
419
|
+
const parameter = raw
|
|
420
|
+
.slice(separator + 1)
|
|
421
|
+
.trim()
|
|
422
|
+
.replace(/^"|"$/gu, '');
|
|
423
|
+
parameters.set(name, parameter);
|
|
424
|
+
}
|
|
425
|
+
return { base: base.trim().toLowerCase(), parameters };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function mediaTypeMatches(expected: string, received: string): boolean {
|
|
429
|
+
const wanted = contentTypeParts(expected);
|
|
430
|
+
const actual = contentTypeParts(received);
|
|
431
|
+
if (wanted.base !== actual.base) return false;
|
|
432
|
+
for (const [name, value] of wanted.parameters) {
|
|
433
|
+
if (actual.parameters.get(name) !== value) return false;
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function safeCancel(stream: ReadableStream<ClientBytes> | null, reason: unknown): Promise<void> {
|
|
439
|
+
if (stream === null || stream.locked) return;
|
|
440
|
+
try {
|
|
441
|
+
await stream.cancel(reason);
|
|
442
|
+
} catch {}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
class OperationResponseBody implements ClientResponseBody {
|
|
446
|
+
readonly #operationId: string;
|
|
447
|
+
readonly #status: number;
|
|
448
|
+
readonly #headers: ClientHeaders;
|
|
449
|
+
readonly #maxResponseBytes: number;
|
|
450
|
+
readonly #maxErrorBodyBytes: number;
|
|
451
|
+
readonly #signal: AbortSignal | undefined;
|
|
452
|
+
#stream: ReadableStream<ClientBytes> | null;
|
|
453
|
+
#used = false;
|
|
454
|
+
#transferred = false;
|
|
455
|
+
|
|
456
|
+
constructor(
|
|
457
|
+
operationId: string,
|
|
458
|
+
response: ClientResponse,
|
|
459
|
+
maxResponseBytes: number,
|
|
460
|
+
maxErrorBodyBytes: number,
|
|
461
|
+
signal: AbortSignal | undefined,
|
|
462
|
+
) {
|
|
463
|
+
this.#operationId = operationId;
|
|
464
|
+
this.#status = response.status;
|
|
465
|
+
this.#headers = response.headers;
|
|
466
|
+
this.#stream = response.body;
|
|
467
|
+
this.#maxResponseBytes = maxResponseBytes;
|
|
468
|
+
this.#maxErrorBodyBytes = maxErrorBodyBytes;
|
|
469
|
+
this.#signal = signal;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async cancel(reason: unknown): Promise<void> {
|
|
473
|
+
if (this.#transferred) return;
|
|
474
|
+
const stream = this.#stream;
|
|
475
|
+
this.#stream = null;
|
|
476
|
+
await safeCancel(stream, reason);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#take(): ReadableStream<ClientBytes> | null {
|
|
480
|
+
if (this.#used) {
|
|
481
|
+
throw new ClientRequestError(`Operation ${this.#operationId} response body was already consumed`, {
|
|
482
|
+
operationId: this.#operationId,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
this.#used = true;
|
|
486
|
+
const stream = this.#stream;
|
|
487
|
+
this.#stream = null;
|
|
488
|
+
return stream;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#assertMediaType(expected: string): void {
|
|
492
|
+
const received = this.#headers['content-type'];
|
|
493
|
+
if (received === undefined || !mediaTypeMatches(expected, received)) {
|
|
494
|
+
throw new UnexpectedContentTypeError(this.#operationId, this.#status, [expected], received);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async #read(limit: number): Promise<ClientBytes> {
|
|
499
|
+
const declared = this.#headers['content-length'];
|
|
500
|
+
if (declared !== undefined && /^\d+$/u.test(declared) && Number(declared) > limit) {
|
|
501
|
+
const error = new ResponseTooLargeError(this.#operationId, this.#status, limit);
|
|
502
|
+
const stream = this.#take();
|
|
503
|
+
await safeCancel(stream, error);
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
const stream = this.#take();
|
|
507
|
+
if (stream === null) return new Uint8Array();
|
|
508
|
+
const reader = stream.getReader();
|
|
509
|
+
const chunks: ClientBytes[] = [];
|
|
510
|
+
let total = 0;
|
|
511
|
+
try {
|
|
512
|
+
while (true) {
|
|
513
|
+
const result = await withSignal(reader.read(), this.#signal);
|
|
514
|
+
if (result.done) break;
|
|
515
|
+
total += result.value.byteLength;
|
|
516
|
+
if (total > limit) {
|
|
517
|
+
const error = new ResponseTooLargeError(this.#operationId, this.#status, limit);
|
|
518
|
+
await reader.cancel(error);
|
|
519
|
+
throw error;
|
|
520
|
+
}
|
|
521
|
+
chunks.push(result.value);
|
|
522
|
+
}
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (this.#signal?.aborted === true) {
|
|
525
|
+
try {
|
|
526
|
+
await reader.cancel(this.#signal.reason);
|
|
527
|
+
} catch {}
|
|
528
|
+
throw this.#signal.reason;
|
|
529
|
+
}
|
|
530
|
+
throw error;
|
|
531
|
+
} finally {
|
|
532
|
+
reader.releaseLock();
|
|
533
|
+
}
|
|
534
|
+
const bytes = new Uint8Array(total);
|
|
535
|
+
let offset = 0;
|
|
536
|
+
for (const chunk of chunks) {
|
|
537
|
+
bytes.set(chunk, offset);
|
|
538
|
+
offset += chunk.byteLength;
|
|
539
|
+
}
|
|
540
|
+
return bytes;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async #snippet(): Promise<string> {
|
|
544
|
+
const stream = this.#take();
|
|
545
|
+
if (stream === null) return '';
|
|
546
|
+
const reader = stream.getReader();
|
|
547
|
+
const bytes = new Uint8Array(this.#maxErrorBodyBytes);
|
|
548
|
+
let offset = 0;
|
|
549
|
+
try {
|
|
550
|
+
while (offset < bytes.byteLength) {
|
|
551
|
+
const result = await withSignal(reader.read(), this.#signal);
|
|
552
|
+
if (result.done) break;
|
|
553
|
+
const remaining = bytes.byteLength - offset;
|
|
554
|
+
const accepted = result.value.subarray(0, remaining);
|
|
555
|
+
bytes.set(accepted, offset);
|
|
556
|
+
offset += accepted.byteLength;
|
|
557
|
+
if (accepted.byteLength < result.value.byteLength || offset === bytes.byteLength) {
|
|
558
|
+
await reader.cancel();
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (this.#signal?.aborted === true) {
|
|
564
|
+
try {
|
|
565
|
+
await reader.cancel(this.#signal.reason);
|
|
566
|
+
} catch {}
|
|
567
|
+
throw this.#signal.reason;
|
|
568
|
+
}
|
|
569
|
+
throw error;
|
|
570
|
+
} finally {
|
|
571
|
+
reader.releaseLock();
|
|
572
|
+
}
|
|
573
|
+
return new TextDecoder().decode(bytes.subarray(0, offset));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
#diagnostic(bytes: ClientBytes): string {
|
|
577
|
+
return new TextDecoder().decode(bytes.subarray(0, this.#maxErrorBodyBytes));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async empty(): Promise<void> {
|
|
581
|
+
if (EMPTY_STATUSES.has(this.#status)) {
|
|
582
|
+
const stream = this.#take();
|
|
583
|
+
await safeCancel(stream, undefined);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const bytes = await this.#read(this.#maxResponseBytes);
|
|
587
|
+
if (bytes.byteLength > 0) {
|
|
588
|
+
throw new ResponseDecodeError(this.#operationId, this.#status, this.#diagnostic(bytes));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async json<T>(mediaType: string, decode: (wire: unknown) => DecodeResult<T>): Promise<T> {
|
|
593
|
+
this.#assertMediaType(mediaType);
|
|
594
|
+
const bytes = await this.#read(this.#maxResponseBytes);
|
|
595
|
+
let text: string;
|
|
596
|
+
try {
|
|
597
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
throw new ResponseDecodeError(this.#operationId, this.#status, this.#diagnostic(bytes), error);
|
|
600
|
+
}
|
|
601
|
+
let wire: unknown;
|
|
602
|
+
try {
|
|
603
|
+
wire = JSON.parse(text);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
throw new ResponseDecodeError(this.#operationId, this.#status, this.#diagnostic(bytes), error);
|
|
606
|
+
}
|
|
607
|
+
const result = decode(wire);
|
|
608
|
+
if (!result.ok) {
|
|
609
|
+
throw new ResponseValidationError(this.#operationId, this.#status, result.issues);
|
|
610
|
+
}
|
|
611
|
+
return result.value;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async text(mediaType: string): Promise<string> {
|
|
615
|
+
this.#assertMediaType(mediaType);
|
|
616
|
+
const bytes = await this.#read(this.#maxResponseBytes);
|
|
617
|
+
try {
|
|
618
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
throw new ResponseDecodeError(this.#operationId, this.#status, this.#diagnostic(bytes), error);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async bytes(mediaType: string): Promise<ClientBytes> {
|
|
625
|
+
this.#assertMediaType(mediaType);
|
|
626
|
+
return this.#read(this.#maxResponseBytes);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
stream(mediaType: string): ReadableStream<ClientBytes> {
|
|
630
|
+
this.#assertMediaType(mediaType);
|
|
631
|
+
const stream = this.#take();
|
|
632
|
+
this.#transferred = true;
|
|
633
|
+
return (
|
|
634
|
+
stream ??
|
|
635
|
+
new ReadableStream<ClientBytes>({
|
|
636
|
+
start(controller) {
|
|
637
|
+
controller.close();
|
|
638
|
+
},
|
|
639
|
+
})
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async unexpectedStatus(): Promise<never> {
|
|
644
|
+
const snippet = await this.#snippet();
|
|
645
|
+
throw new UnexpectedStatusError(this.#operationId, this.#status, this.#headers, snippet);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function operationResponse(
|
|
650
|
+
operationId: string,
|
|
651
|
+
response: ClientResponse,
|
|
652
|
+
body: OperationResponseBody,
|
|
653
|
+
): ClientOperationResponse {
|
|
654
|
+
return Object.freeze({
|
|
655
|
+
status: response.status,
|
|
656
|
+
headers: response.headers,
|
|
657
|
+
body,
|
|
658
|
+
unexpectedStatus: () => body.unexpectedStatus(),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function transportError(operationId: string, error: unknown): never {
|
|
663
|
+
if (error instanceof TransportError && error.operationId === undefined) {
|
|
664
|
+
throw new TransportError(operationId, error.cause);
|
|
665
|
+
}
|
|
666
|
+
if (error instanceof ClientRequestError && error.operationId === undefined) {
|
|
667
|
+
throw new ClientRequestError(error.message, operationInit(operationId, error.cause));
|
|
668
|
+
}
|
|
669
|
+
if (error instanceof ClientError) throw error;
|
|
670
|
+
throw new TransportError(operationId, error);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
class Runtime implements ClientRuntime {
|
|
674
|
+
readonly #config: RuntimeConfiguration;
|
|
675
|
+
|
|
676
|
+
constructor(options: ClientOptions) {
|
|
677
|
+
this.#config = configuration(options);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async call<Input, Result>(
|
|
681
|
+
operation: GeneratedOperation<Input, Result>,
|
|
682
|
+
input: Input,
|
|
683
|
+
options: CallOptions & { readonly version?: string } = {},
|
|
684
|
+
): Promise<Result> {
|
|
685
|
+
if (operation.abi !== CLIENT_RUNTIME_ABI) {
|
|
686
|
+
throw new ClientRequestError(
|
|
687
|
+
`Operation ${operation.operationId} ABI ${String(operation.abi)} does not match client ABI ${String(CLIENT_RUNTIME_ABI)}`,
|
|
688
|
+
{ operationId: operation.operationId },
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
const timeoutMs = timeoutValue(options.timeoutMs);
|
|
692
|
+
const scope = cancellation(operation.operationId, options.signal, timeoutMs);
|
|
693
|
+
let body: OperationResponseBody | undefined;
|
|
694
|
+
try {
|
|
695
|
+
const version = selectedVersion(operation, options.version);
|
|
696
|
+
const prepared = preparedRequest(operation.operationId, operation, input, version);
|
|
697
|
+
const authentication = await authenticate(
|
|
698
|
+
operation,
|
|
699
|
+
prepared,
|
|
700
|
+
version,
|
|
701
|
+
this.#config.authentication,
|
|
702
|
+
options.authentication,
|
|
703
|
+
scope.signal,
|
|
704
|
+
);
|
|
705
|
+
const request = finalRequest(this.#config, operation, prepared, authentication, scope.signal);
|
|
706
|
+
|
|
707
|
+
let response: ClientResponse;
|
|
708
|
+
try {
|
|
709
|
+
response = normalizedResponse(
|
|
710
|
+
operation.operationId,
|
|
711
|
+
await withSignal(Promise.resolve(this.#config.transport(request)), scope.signal),
|
|
712
|
+
);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
if (scope.signal?.aborted === true) throw scope.signal.reason;
|
|
715
|
+
transportError(operation.operationId, error);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
body = new OperationResponseBody(
|
|
719
|
+
operation.operationId,
|
|
720
|
+
response,
|
|
721
|
+
this.#config.maxResponseBytes,
|
|
722
|
+
this.#config.maxErrorBodyBytes,
|
|
723
|
+
scope.signal,
|
|
724
|
+
);
|
|
725
|
+
return await withSignal(
|
|
726
|
+
Promise.resolve(operation.read(operationResponse(operation.operationId, response, body), version)),
|
|
727
|
+
scope.signal,
|
|
728
|
+
);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
const reason = scope.signal?.aborted === true ? scope.signal.reason : error;
|
|
731
|
+
if (body !== undefined) await body.cancel(reason);
|
|
732
|
+
throw reason;
|
|
733
|
+
} finally {
|
|
734
|
+
scope.cleanup();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function createClientRuntime(options: ClientOptions): ClientRuntime {
|
|
740
|
+
return new Runtime(options);
|
|
741
|
+
}
|