@crowdstrike/aidr 1.0.2
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/.editorconfig +9 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/workflows/ci.yml +128 -0
- package/.pnpmfile.cjs +17 -0
- package/.releaserc.json +21 -0
- package/LICENSE.txt +21 -0
- package/README.md +3 -0
- package/biome.json +67 -0
- package/dist/chunk.cjs +34 -0
- package/dist/index.cjs +356 -0
- package/dist/index.d.cts +2347 -0
- package/dist/index.d.mts +2347 -0
- package/dist/index.mjs +354 -0
- package/dist/schemas/ai-guard.cjs +1000 -0
- package/dist/schemas/ai-guard.d.cts +1232 -0
- package/dist/schemas/ai-guard.d.mts +1232 -0
- package/dist/schemas/ai-guard.mjs +907 -0
- package/dist/schemas/index.cjs +7 -0
- package/dist/schemas/index.d.cts +64 -0
- package/dist/schemas/index.d.mts +64 -0
- package/dist/schemas/index.mjs +3 -0
- package/dist/schemas.cjs +139 -0
- package/dist/schemas.mjs +108 -0
- package/flake.lock +59 -0
- package/flake.nix +26 -0
- package/openapi-ts.config.ts +15 -0
- package/package.json +55 -0
- package/pnpm-workspace.yaml +3 -0
- package/scripts/generate-models +15 -0
- package/scripts/test +10 -0
- package/specs/ai-guard.openapi.json +3721 -0
- package/src/client.ts +441 -0
- package/src/core/error.ts +78 -0
- package/src/index.ts +2 -0
- package/src/internal/builtin-types.ts +18 -0
- package/src/internal/errors.ts +34 -0
- package/src/internal/headers.ts +100 -0
- package/src/internal/parse.ts +30 -0
- package/src/internal/request-options.ts +57 -0
- package/src/internal/types.ts +3 -0
- package/src/internal/utils/sleep.ts +3 -0
- package/src/internal/utils/values.ts +38 -0
- package/src/schemas/ai-guard.ts +1215 -0
- package/src/schemas/index.ts +114 -0
- package/src/services/ai-guard.ts +27 -0
- package/src/types/ai-guard.ts +2276 -0
- package/src/types/index.ts +161 -0
- package/tests/ai-guard.test.ts +29 -0
- package/tsconfig.json +26 -0
- package/tsdown.config.mts +14 -0
- package/vitest.config.mts +4 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import * as v from 'valibot';
|
|
2
|
+
|
|
3
|
+
import * as Errors from './core/error';
|
|
4
|
+
import type { BodyInit, Fetch, RequestInfo } from './internal/builtin-types';
|
|
5
|
+
import { castToError } from './internal/errors';
|
|
6
|
+
import {
|
|
7
|
+
buildHeaders,
|
|
8
|
+
type HeadersLike,
|
|
9
|
+
type NullableHeaders,
|
|
10
|
+
} from './internal/headers';
|
|
11
|
+
import { type APIResponseProps, defaultParseResponse } from './internal/parse';
|
|
12
|
+
import type {
|
|
13
|
+
FinalRequestOptions,
|
|
14
|
+
RequestOptions,
|
|
15
|
+
} from './internal/request-options';
|
|
16
|
+
import type {
|
|
17
|
+
FinalizedRequestInit,
|
|
18
|
+
HTTPMethod,
|
|
19
|
+
PromiseOrValue,
|
|
20
|
+
} from './internal/types';
|
|
21
|
+
import { sleep } from './internal/utils/sleep';
|
|
22
|
+
import { isAbsoluteURL, stringifyQuery } from './internal/utils/values';
|
|
23
|
+
import { AcceptedResponseSchema } from './schemas';
|
|
24
|
+
import type { AcceptedResponse, MaybeAcceptedResponse } from './types';
|
|
25
|
+
|
|
26
|
+
function isAcceptedResponse(response: unknown): response is AcceptedResponse {
|
|
27
|
+
return v.safeParse(AcceptedResponseSchema, response).success;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ClientOptions {
|
|
31
|
+
/** CS AIDR API token.*/
|
|
32
|
+
token: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Template for constructing the base URL for API requests. The placeholder
|
|
36
|
+
* `{SERVICE_NAME}` will be replaced with the service name slug.
|
|
37
|
+
*/
|
|
38
|
+
baseURLTemplate: string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The maximum number of times that the client will retry a request in case of
|
|
42
|
+
* a temporary failure, like a network error or a 5XX error from the server.
|
|
43
|
+
*
|
|
44
|
+
* @default 2
|
|
45
|
+
*/
|
|
46
|
+
maxRetries?: number | undefined;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The maximum number of times that the client will poll for an async request
|
|
50
|
+
* result when receiving a HTTP/202 response.
|
|
51
|
+
*
|
|
52
|
+
* @default 5
|
|
53
|
+
*/
|
|
54
|
+
maxPollingAttempts?: number | undefined;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The maximum amount of time (in milliseconds) that the client should wait for a response
|
|
58
|
+
* from the server before timing out a single request.
|
|
59
|
+
*
|
|
60
|
+
* @unit milliseconds
|
|
61
|
+
*/
|
|
62
|
+
timeout?: number;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Specify a custom `fetch` function implementation.
|
|
66
|
+
*
|
|
67
|
+
* If not provided, we expect that `fetch` is defined globally.
|
|
68
|
+
*/
|
|
69
|
+
fetch?: Fetch | undefined;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Default headers to include with every request to the API.
|
|
73
|
+
*
|
|
74
|
+
* These can be removed in individual requests by explicitly setting the
|
|
75
|
+
* header to `null` in request options.
|
|
76
|
+
*/
|
|
77
|
+
defaultHeaders?: HeadersLike | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export abstract class Client {
|
|
81
|
+
/** CS AIDR API token.*/
|
|
82
|
+
token: string;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Template for constructing the base URL for API requests. The placeholder
|
|
86
|
+
* `{SERVICE_NAME}` will be replaced with the service name slug.
|
|
87
|
+
*/
|
|
88
|
+
baseURLTemplate: string;
|
|
89
|
+
timeout: number;
|
|
90
|
+
maxRetries: number;
|
|
91
|
+
maxPollingAttempts: number;
|
|
92
|
+
|
|
93
|
+
private readonly fetch: Fetch;
|
|
94
|
+
private readonly _options: ClientOptions;
|
|
95
|
+
protected abstract serviceName: string;
|
|
96
|
+
|
|
97
|
+
constructor(options: ClientOptions) {
|
|
98
|
+
if (options.token === undefined) {
|
|
99
|
+
throw new Errors.AIDRError(
|
|
100
|
+
'Client was instantiated without an API token.'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (options.baseURLTemplate === undefined) {
|
|
104
|
+
throw new Errors.AIDRError(
|
|
105
|
+
'Client was instantiated without a base URL template.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.baseURLTemplate = options.baseURLTemplate;
|
|
110
|
+
this.fetch = options.fetch ?? fetch;
|
|
111
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
112
|
+
this.maxPollingAttempts = options.maxPollingAttempts ?? 5;
|
|
113
|
+
this.timeout = options.timeout ?? 60_000;
|
|
114
|
+
this.token = options.token;
|
|
115
|
+
|
|
116
|
+
this._options = options;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Will retrieve the result, or will return HTTP/202 if the original request
|
|
121
|
+
* is still in progress.
|
|
122
|
+
*/
|
|
123
|
+
getAsyncRequest<T>(requestId: string): Promise<MaybeAcceptedResponse<T>> {
|
|
124
|
+
return this.get(`/v1/request/${requestId}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Polls for an async request result with exponential backoff.
|
|
129
|
+
* Continues polling until a success response is received or max attempts are reached.
|
|
130
|
+
*/
|
|
131
|
+
private async pollAsyncRequest<T>(
|
|
132
|
+
requestId: string,
|
|
133
|
+
maxAttempts: number
|
|
134
|
+
): Promise<MaybeAcceptedResponse<T>> {
|
|
135
|
+
let lastResponse: MaybeAcceptedResponse<T> | null = null;
|
|
136
|
+
|
|
137
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
138
|
+
const response = await this.getAsyncRequest<T>(requestId);
|
|
139
|
+
|
|
140
|
+
// If we got a success response, return it immediately
|
|
141
|
+
if (response.status === 'Success') {
|
|
142
|
+
return response;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Store the last response in case we exhaust attempts
|
|
146
|
+
lastResponse = response;
|
|
147
|
+
|
|
148
|
+
// If this is the last attempt, don't sleep
|
|
149
|
+
if (attempt < maxAttempts - 1) {
|
|
150
|
+
const timeoutMillis = this.calculateDefaultRetryTimeoutMillis(
|
|
151
|
+
maxAttempts - attempt - 1,
|
|
152
|
+
maxAttempts
|
|
153
|
+
);
|
|
154
|
+
await sleep(timeoutMillis);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Return the last response (should be AcceptedResponse)
|
|
159
|
+
// lastResponse is guaranteed to be set since maxAttempts > 0
|
|
160
|
+
if (lastResponse === null) {
|
|
161
|
+
throw new Errors.AIDRError('Polling failed: no response received');
|
|
162
|
+
}
|
|
163
|
+
return lastResponse;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
protected async get<R>(
|
|
167
|
+
path: string,
|
|
168
|
+
opts?: PromiseOrValue<RequestOptions>
|
|
169
|
+
): Promise<R> {
|
|
170
|
+
return await this.methodRequest('get', path, opts);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
protected async post<R>(
|
|
174
|
+
path: string,
|
|
175
|
+
opts?: PromiseOrValue<RequestOptions>
|
|
176
|
+
): Promise<R> {
|
|
177
|
+
return await this.methodRequest('post', path, opts);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async methodRequest<Rsp>(
|
|
181
|
+
method: HTTPMethod,
|
|
182
|
+
path: string,
|
|
183
|
+
opts?: PromiseOrValue<RequestOptions>
|
|
184
|
+
): Promise<Rsp> {
|
|
185
|
+
return await this.request(
|
|
186
|
+
Promise.resolve(opts).then((opts) => {
|
|
187
|
+
return { method, path, ...opts };
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async request<R>(
|
|
193
|
+
options: PromiseOrValue<FinalRequestOptions>,
|
|
194
|
+
remainingRetries: number | null = null
|
|
195
|
+
): Promise<R> {
|
|
196
|
+
const props = await this.makeRequest(options, remainingRetries);
|
|
197
|
+
const parsed = await defaultParseResponse<R>(props);
|
|
198
|
+
|
|
199
|
+
if (isAcceptedResponse(parsed)) {
|
|
200
|
+
const finalOptions = await Promise.resolve(options);
|
|
201
|
+
const maxPollingAttempts =
|
|
202
|
+
finalOptions.maxPollingAttempts ?? this.maxPollingAttempts;
|
|
203
|
+
|
|
204
|
+
if (maxPollingAttempts <= 0) {
|
|
205
|
+
return parsed;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return (await this.pollAsyncRequest<R>(
|
|
209
|
+
parsed.request_id,
|
|
210
|
+
maxPollingAttempts
|
|
211
|
+
)) as R;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return parsed;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async makeRequest(
|
|
218
|
+
optionsInput: PromiseOrValue<FinalRequestOptions>,
|
|
219
|
+
retriesRemaining: number | null
|
|
220
|
+
): Promise<APIResponseProps> {
|
|
221
|
+
const options = await optionsInput;
|
|
222
|
+
const maxRetries = options.maxRetries ?? this.maxRetries;
|
|
223
|
+
if (retriesRemaining == null) {
|
|
224
|
+
retriesRemaining = maxRetries;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const { req, url, timeout } = this.buildRequest(options, {
|
|
228
|
+
retryCount: maxRetries - retriesRemaining,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (options.signal?.aborted) {
|
|
232
|
+
throw new Errors.APIUserAbortError();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const response = await this.fetchWithTimeout(
|
|
237
|
+
url,
|
|
238
|
+
req,
|
|
239
|
+
timeout,
|
|
240
|
+
controller
|
|
241
|
+
).catch(castToError);
|
|
242
|
+
|
|
243
|
+
if (response instanceof globalThis.Error) {
|
|
244
|
+
if (options.signal?.aborted) {
|
|
245
|
+
throw new Errors.APIUserAbortError();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (retriesRemaining) {
|
|
249
|
+
return await this.retryRequest(options, retriesRemaining);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
throw new Errors.APIConnectionError({ cause: response });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!response.ok) {
|
|
256
|
+
const shouldRetry = this.shouldRetry(response);
|
|
257
|
+
|
|
258
|
+
if (retriesRemaining && shouldRetry) {
|
|
259
|
+
return await this.retryRequest(options, retriesRemaining);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// TODO: throw error based on status
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { response, options, controller };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private shouldRetry(response: Response): boolean {
|
|
269
|
+
return (
|
|
270
|
+
response.status === 408 ||
|
|
271
|
+
response.status === 409 ||
|
|
272
|
+
response.status === 429 ||
|
|
273
|
+
response.status >= 500
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async retryRequest(
|
|
278
|
+
options: FinalRequestOptions,
|
|
279
|
+
retriesRemaining: number
|
|
280
|
+
): Promise<APIResponseProps> {
|
|
281
|
+
const maxRetries = options.maxRetries ?? this.maxRetries;
|
|
282
|
+
const timeoutMillis = this.calculateDefaultRetryTimeoutMillis(
|
|
283
|
+
retriesRemaining,
|
|
284
|
+
maxRetries
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
await sleep(timeoutMillis);
|
|
288
|
+
|
|
289
|
+
return this.makeRequest(options, retriesRemaining - 1);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private calculateDefaultRetryTimeoutMillis(
|
|
293
|
+
retriesRemaining: number,
|
|
294
|
+
maxRetries: number
|
|
295
|
+
): number {
|
|
296
|
+
const initialRetryDelay = 0.5;
|
|
297
|
+
const maxRetryDelay = 8.0;
|
|
298
|
+
|
|
299
|
+
const numRetries = maxRetries - retriesRemaining;
|
|
300
|
+
|
|
301
|
+
// Apply exponential backoff, but not more than the max.
|
|
302
|
+
const sleepSeconds = Math.min(
|
|
303
|
+
initialRetryDelay * 2 ** numRetries,
|
|
304
|
+
maxRetryDelay
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// Apply some jitter, take up to at most 25 percent of the retry time.
|
|
308
|
+
const jitter = 1 - Math.random() * 0.25;
|
|
309
|
+
|
|
310
|
+
return sleepSeconds * jitter * 1000;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async fetchWithTimeout(
|
|
314
|
+
url: RequestInfo,
|
|
315
|
+
init: RequestInit | undefined,
|
|
316
|
+
ms: number,
|
|
317
|
+
controller: AbortController
|
|
318
|
+
): Promise<Response> {
|
|
319
|
+
const { signal, method, ...options } = init || {};
|
|
320
|
+
if (signal) {
|
|
321
|
+
signal.addEventListener('abort', () => controller.abort());
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const timeout = setTimeout(() => controller.abort(), ms);
|
|
325
|
+
|
|
326
|
+
const fetchOptions: RequestInit = {
|
|
327
|
+
signal: controller.signal,
|
|
328
|
+
method: 'GET',
|
|
329
|
+
...options,
|
|
330
|
+
};
|
|
331
|
+
if (method) {
|
|
332
|
+
fetchOptions.method = method.toUpperCase();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
return await this.fetch.call(undefined, url, fetchOptions);
|
|
337
|
+
} finally {
|
|
338
|
+
clearTimeout(timeout);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private buildRequest(
|
|
343
|
+
inputOptions: FinalRequestOptions,
|
|
344
|
+
{ retryCount = 0 }: { retryCount?: number } = {}
|
|
345
|
+
): { req: FinalizedRequestInit; url: string; timeout: number } {
|
|
346
|
+
const options = { ...inputOptions };
|
|
347
|
+
const { method, path, query, baseURLTemplate } = options;
|
|
348
|
+
|
|
349
|
+
const url = this.buildURL(
|
|
350
|
+
path,
|
|
351
|
+
query as Record<string, unknown>,
|
|
352
|
+
baseURLTemplate
|
|
353
|
+
);
|
|
354
|
+
options.timeout = options.timeout ?? this.timeout;
|
|
355
|
+
const { bodyHeaders, body } = this.buildBody({ options });
|
|
356
|
+
const reqHeaders = this.buildHeaders({
|
|
357
|
+
options: inputOptions,
|
|
358
|
+
method,
|
|
359
|
+
bodyHeaders,
|
|
360
|
+
retryCount,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const req: FinalizedRequestInit = {
|
|
364
|
+
method,
|
|
365
|
+
headers: reqHeaders,
|
|
366
|
+
...(options.signal && { signal: options.signal }),
|
|
367
|
+
...(body && { body }),
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
return { req, url, timeout: options.timeout };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private buildURL(
|
|
374
|
+
path: string,
|
|
375
|
+
query: Record<string, unknown> | null | undefined,
|
|
376
|
+
baseURLTemplate: string = this.baseURLTemplate
|
|
377
|
+
): string {
|
|
378
|
+
const url = new URL(
|
|
379
|
+
(isAbsoluteURL(path)
|
|
380
|
+
? path
|
|
381
|
+
: baseURLTemplate +
|
|
382
|
+
(baseURLTemplate.endsWith('/') && path.startsWith('/')
|
|
383
|
+
? path.slice(1)
|
|
384
|
+
: path)
|
|
385
|
+
).replaceAll('{SERVICE_NAME}', this.serviceName)
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
if (typeof query === 'object' && query && !Array.isArray(query)) {
|
|
389
|
+
url.search = stringifyQuery(query as Record<string, unknown>);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return url.toString();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private buildBody({
|
|
396
|
+
options: { body, headers: _rawHeaders },
|
|
397
|
+
}: {
|
|
398
|
+
options: FinalRequestOptions;
|
|
399
|
+
}): {
|
|
400
|
+
bodyHeaders: HeadersLike;
|
|
401
|
+
body: BodyInit | undefined;
|
|
402
|
+
} {
|
|
403
|
+
if (!body) {
|
|
404
|
+
return { bodyHeaders: undefined, body: undefined };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// TODO: other body types.
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
bodyHeaders: { 'content-type': 'application/json' },
|
|
411
|
+
body: JSON.stringify(body),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private buildHeaders({
|
|
416
|
+
options,
|
|
417
|
+
bodyHeaders,
|
|
418
|
+
}: {
|
|
419
|
+
options: FinalRequestOptions;
|
|
420
|
+
method: HTTPMethod;
|
|
421
|
+
bodyHeaders: HeadersLike;
|
|
422
|
+
retryCount: number;
|
|
423
|
+
}): Headers {
|
|
424
|
+
const headers = buildHeaders([
|
|
425
|
+
{
|
|
426
|
+
Accept: 'application/json',
|
|
427
|
+
'User-Agent': 'aidr-typescript',
|
|
428
|
+
},
|
|
429
|
+
this.authHeaders(),
|
|
430
|
+
this._options.defaultHeaders,
|
|
431
|
+
bodyHeaders,
|
|
432
|
+
options.headers,
|
|
433
|
+
]);
|
|
434
|
+
|
|
435
|
+
return headers.values;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private authHeaders(): NullableHeaders {
|
|
439
|
+
return buildHeaders([{ Authorization: `Bearer ${this.token}` }]);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export class AIDRError extends Error {}
|
|
2
|
+
|
|
3
|
+
export class APIError<
|
|
4
|
+
TStatus extends number | undefined = number | undefined,
|
|
5
|
+
THeaders extends Headers | undefined = Headers | undefined,
|
|
6
|
+
TError extends object | undefined = object | undefined,
|
|
7
|
+
> extends AIDRError {
|
|
8
|
+
/** HTTP status for the response that caused the error */
|
|
9
|
+
readonly status: TStatus;
|
|
10
|
+
/** HTTP headers for the response that caused the error */
|
|
11
|
+
readonly headers: THeaders;
|
|
12
|
+
/** JSON body of the response that caused the error */
|
|
13
|
+
readonly error: TError;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
status: TStatus,
|
|
17
|
+
error: TError,
|
|
18
|
+
message: string | undefined,
|
|
19
|
+
headers: THeaders
|
|
20
|
+
) {
|
|
21
|
+
super(`${APIError.makeMessage(status, error, message)}`);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.headers = headers;
|
|
24
|
+
this.error = error;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private static makeMessage(
|
|
28
|
+
status: number | undefined,
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: matches upstream.
|
|
30
|
+
error: any,
|
|
31
|
+
message: string | undefined
|
|
32
|
+
) {
|
|
33
|
+
const msg = error?.message
|
|
34
|
+
? typeof error.message === 'string'
|
|
35
|
+
? error.message
|
|
36
|
+
: JSON.stringify(error.message)
|
|
37
|
+
: error
|
|
38
|
+
? JSON.stringify(error)
|
|
39
|
+
: message;
|
|
40
|
+
|
|
41
|
+
if (status && msg) {
|
|
42
|
+
return `${status} ${msg}`;
|
|
43
|
+
}
|
|
44
|
+
if (status) {
|
|
45
|
+
return `${status} status code (no body)`;
|
|
46
|
+
}
|
|
47
|
+
if (msg) {
|
|
48
|
+
return msg;
|
|
49
|
+
}
|
|
50
|
+
return '(no status code or body)';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class APIUserAbortError extends APIError<
|
|
55
|
+
undefined,
|
|
56
|
+
undefined,
|
|
57
|
+
undefined
|
|
58
|
+
> {
|
|
59
|
+
constructor({ message }: { message?: string } = {}) {
|
|
60
|
+
super(undefined, undefined, message || 'Request was aborted.', undefined);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class APIConnectionError extends APIError<
|
|
65
|
+
undefined,
|
|
66
|
+
undefined,
|
|
67
|
+
undefined
|
|
68
|
+
> {
|
|
69
|
+
constructor({
|
|
70
|
+
message,
|
|
71
|
+
cause,
|
|
72
|
+
}: { message?: string | undefined; cause?: Error | undefined }) {
|
|
73
|
+
super(undefined, undefined, message || 'Connection error.', undefined);
|
|
74
|
+
if (cause) {
|
|
75
|
+
this.cause = cause;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type Fetch = (
|
|
2
|
+
input: string | URL | Request,
|
|
3
|
+
init?: RequestInit
|
|
4
|
+
) => Promise<Response>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The type for constructing `RequestInit` body.
|
|
8
|
+
*
|
|
9
|
+
* https://developer.mozilla.org/docs/Web/API/RequestInit#body
|
|
10
|
+
*/
|
|
11
|
+
export type BodyInit = RequestInit['body'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The type for the first argument to `fetch`.
|
|
15
|
+
*
|
|
16
|
+
* https://developer.mozilla.org/docs/Web/API/Window/fetch#resource
|
|
17
|
+
*/
|
|
18
|
+
export type RequestInfo = Request | URL | string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// biome-ignore lint/suspicious/noExplicitAny: matches upstream.
|
|
2
|
+
export function castToError(err: any): Error {
|
|
3
|
+
if (err instanceof Error) {
|
|
4
|
+
return err;
|
|
5
|
+
}
|
|
6
|
+
if (typeof err === 'object' && err !== null) {
|
|
7
|
+
try {
|
|
8
|
+
if (Object.prototype.toString.call(err) === '[object Error]') {
|
|
9
|
+
const error = new Error(
|
|
10
|
+
err.message,
|
|
11
|
+
err.cause ? { cause: err.cause } : {}
|
|
12
|
+
);
|
|
13
|
+
if (err.stack) {
|
|
14
|
+
error.stack = err.stack;
|
|
15
|
+
}
|
|
16
|
+
if (err.cause && !error.cause) {
|
|
17
|
+
error.cause = err.cause;
|
|
18
|
+
}
|
|
19
|
+
if (err.name) {
|
|
20
|
+
error.name = err.name;
|
|
21
|
+
}
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
/** no-op */
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return new Error(JSON.stringify(err));
|
|
29
|
+
} catch {
|
|
30
|
+
/** no-op */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return new Error(err);
|
|
34
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { isReadonlyArray } from './utils/values';
|
|
2
|
+
|
|
3
|
+
type HeaderValue = string | undefined | null;
|
|
4
|
+
|
|
5
|
+
export type NullableHeaders = {
|
|
6
|
+
/** Brand check, prevent users from creating a NullableHeaders. */
|
|
7
|
+
[brand_privateNullableHeaders]: true;
|
|
8
|
+
|
|
9
|
+
/** Parsed headers. */
|
|
10
|
+
values: Headers;
|
|
11
|
+
|
|
12
|
+
/** Set of lowercase header names explicitly set to null. */
|
|
13
|
+
nulls: Set<string>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type HeadersLike =
|
|
17
|
+
| Headers
|
|
18
|
+
| readonly HeaderValue[][]
|
|
19
|
+
| Record<string, HeaderValue | readonly HeaderValue[]>
|
|
20
|
+
| undefined
|
|
21
|
+
| null
|
|
22
|
+
| NullableHeaders;
|
|
23
|
+
|
|
24
|
+
const brand_privateNullableHeaders = /* @__PURE__ */ Symbol(
|
|
25
|
+
'brand.privateNullableHeaders'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
function* iterateHeaders(
|
|
29
|
+
headers: HeadersLike
|
|
30
|
+
): IterableIterator<readonly [string, string | null]> {
|
|
31
|
+
if (!headers) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (brand_privateNullableHeaders in headers) {
|
|
36
|
+
const { values, nulls } = headers;
|
|
37
|
+
yield* values.entries();
|
|
38
|
+
for (const name of nulls) {
|
|
39
|
+
yield [name, null];
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let shouldClear = false;
|
|
45
|
+
let iter: Iterable<readonly (HeaderValue | readonly HeaderValue[])[]>;
|
|
46
|
+
if (headers instanceof Headers) {
|
|
47
|
+
iter = headers.entries();
|
|
48
|
+
} else if (isReadonlyArray(headers)) {
|
|
49
|
+
iter = headers;
|
|
50
|
+
} else {
|
|
51
|
+
shouldClear = true;
|
|
52
|
+
iter = Object.entries(headers ?? {});
|
|
53
|
+
}
|
|
54
|
+
for (const row of iter) {
|
|
55
|
+
const name = row[0];
|
|
56
|
+
if (typeof name !== 'string') {
|
|
57
|
+
throw new TypeError('expected header name to be a string');
|
|
58
|
+
}
|
|
59
|
+
const values = isReadonlyArray(row[1]) ? row[1] : [row[1]];
|
|
60
|
+
let didClear = false;
|
|
61
|
+
for (const value of values) {
|
|
62
|
+
if (value === undefined) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (shouldClear && !didClear) {
|
|
67
|
+
didClear = true;
|
|
68
|
+
yield [name, null];
|
|
69
|
+
}
|
|
70
|
+
yield [name, value];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildHeaders(newHeaders: HeadersLike[]): NullableHeaders {
|
|
76
|
+
const targetHeaders = new Headers();
|
|
77
|
+
const nullHeaders = new Set<string>();
|
|
78
|
+
for (const headers of newHeaders) {
|
|
79
|
+
const seenHeaders = new Set<string>();
|
|
80
|
+
for (const [name, value] of iterateHeaders(headers)) {
|
|
81
|
+
const lowerName = name.toLowerCase();
|
|
82
|
+
if (!seenHeaders.has(lowerName)) {
|
|
83
|
+
targetHeaders.delete(name);
|
|
84
|
+
seenHeaders.add(lowerName);
|
|
85
|
+
}
|
|
86
|
+
if (value === null) {
|
|
87
|
+
targetHeaders.delete(name);
|
|
88
|
+
nullHeaders.add(lowerName);
|
|
89
|
+
} else {
|
|
90
|
+
targetHeaders.append(name, value);
|
|
91
|
+
nullHeaders.delete(lowerName);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
[brand_privateNullableHeaders]: true,
|
|
97
|
+
values: targetHeaders,
|
|
98
|
+
nulls: nullHeaders,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FinalRequestOptions } from './request-options';
|
|
2
|
+
|
|
3
|
+
export type APIResponseProps = {
|
|
4
|
+
controller: AbortController;
|
|
5
|
+
options: FinalRequestOptions;
|
|
6
|
+
response: Response;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function defaultParseResponse<T>(
|
|
10
|
+
props: APIResponseProps
|
|
11
|
+
): Promise<T> {
|
|
12
|
+
const { response } = props;
|
|
13
|
+
return await (async () => {
|
|
14
|
+
if (response.status === 204) {
|
|
15
|
+
return null as T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const contentType = response.headers.get('content-type');
|
|
19
|
+
const mediaType = contentType?.split(';')[0]?.trim();
|
|
20
|
+
const isJSON =
|
|
21
|
+
mediaType?.includes('application/json') || mediaType?.endsWith('+json');
|
|
22
|
+
if (isJSON) {
|
|
23
|
+
const json = await response.json();
|
|
24
|
+
return json as T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const text = await response.text();
|
|
28
|
+
return text as unknown as T;
|
|
29
|
+
})();
|
|
30
|
+
}
|