@askrjs/fetch 0.0.5 → 0.0.6
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/README.md +21 -6
- package/dist/client.d.ts +19 -0
- package/dist/client.js +24 -5
- package/dist/codecs.d.ts +13 -0
- package/dist/codecs.js +11 -0
- package/dist/dsl.d.ts +29 -0
- package/dist/dsl.js +23 -0
- package/dist/middleware.d.ts +36 -0
- package/dist/middleware.js +42 -2
- package/dist/types.d.ts +39 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -108,6 +108,10 @@ get("/search")
|
|
|
108
108
|
.returns(json());
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
+
Query serialization treats `null` as the literal `null` and `NaN` as the literal `NaN`, whether
|
|
112
|
+
the value is scalar or array-wrapped. `undefined` is omitted in both positions. This avoids
|
|
113
|
+
silently turning scalar `null` into an empty string or array-wrapped `undefined` into text.
|
|
114
|
+
|
|
111
115
|
## Middleware
|
|
112
116
|
|
|
113
117
|
Import middleware from the dedicated subpath:
|
|
@@ -119,21 +123,32 @@ import { bearerAuth, logging, retry, telemetry } from "@askrjs/fetch/middleware"
|
|
|
119
123
|
const client = createClient(api, {
|
|
120
124
|
baseUrl: "https://api.example.com",
|
|
121
125
|
middleware: [
|
|
126
|
+
retry({ attempts: 3 }),
|
|
122
127
|
bearerAuth({ token: () => session.accessToken }),
|
|
123
128
|
logging(),
|
|
124
129
|
telemetry(hooks),
|
|
125
|
-
retry({ attempts: 3 }),
|
|
126
130
|
],
|
|
127
131
|
});
|
|
128
132
|
```
|
|
129
133
|
|
|
130
|
-
Middleware runs outward in declaration order.
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
Middleware is a linear onion and runs outward in declaration order. Middleware after `retry()`
|
|
135
|
+
runs once per attempt; middleware before it runs once around the complete retry sequence. Put
|
|
136
|
+
token-refreshing authentication, per-attempt logging, and per-attempt telemetry after `retry()` as
|
|
137
|
+
shown above. Put aggregate timing or logging before `retry()` when one observation for the complete
|
|
138
|
+
sequence is intentional. Logging redacts common credential names as well as arbitrary header or
|
|
139
|
+
query names added by `apiKeyAuth()`.
|
|
133
140
|
|
|
134
141
|
Retries default to `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS`, and to statuses `408`, `425`,
|
|
135
|
-
`429`, `500`, `502`, `503`, and `504`. `Retry-After` is honored when present.
|
|
136
|
-
are
|
|
142
|
+
`429`, `500`, `502`, `503`, and `504`. `Retry-After` is honored when present. Cloneable request
|
|
143
|
+
bodies are replayed with the original bytes and headers. `ReadableStream` bodies are explicitly
|
|
144
|
+
single-attempt so retry does not buffer an unbounded stream. If an earlier middleware has already
|
|
145
|
+
consumed any body, retry also sends it once and does not surface an incidental cloning error.
|
|
146
|
+
|
|
147
|
+
Do not include a status such as `401` in `retry()` when an upstream authentication middleware
|
|
148
|
+
already handles that status. The outer authentication layer cannot react until retry's complete
|
|
149
|
+
inner attempt loop returns, so including `401` would spend the retry budget on the same upstream
|
|
150
|
+
credentials. Prefer the order above so credentials resolve per attempt, or let the authentication
|
|
151
|
+
middleware own `401` without adding it to retry's statuses.
|
|
137
152
|
|
|
138
153
|
## Cancellation and timeouts
|
|
139
154
|
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AnyEndpointDescriptor, ApiDefinition, ClientOptions, Codec, EndpointDescriptor, FailureResult, FetchResult, HttpResult, InferCodec, ParameterMap, SuccessResult } from "./types.js";
|
|
2
2
|
//#region src/client.d.ts
|
|
3
|
+
/** A one-off, untyped request description accepted by {@link createFetch}'s returned function. */
|
|
3
4
|
interface AdHocCall {
|
|
4
5
|
url: string;
|
|
5
6
|
method?: string;
|
|
@@ -16,6 +17,12 @@ interface AdHocCall {
|
|
|
16
17
|
endpoint?: EndpointDescriptor;
|
|
17
18
|
operationId?: string;
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Creates a low-level fetch function that builds a `Request` from an {@link AdHocCall},
|
|
22
|
+
* runs it through the configured middleware chain, and decodes the response with the
|
|
23
|
+
* matching codec. Used internally by {@link createClient}, and usable directly for
|
|
24
|
+
* requests without a full {@link EndpointDescriptor}.
|
|
25
|
+
*/
|
|
19
26
|
declare function createFetch(options?: ClientOptions): (call: AdHocCall) => Promise<FetchResult>;
|
|
20
27
|
type InputParts<D> = D extends EndpointDescriptor<infer P, infer Q, infer H, infer B, any, any> ? {
|
|
21
28
|
params: P;
|
|
@@ -31,10 +38,18 @@ type EndpointInput<D extends AnyEndpointDescriptor> = Container<"params", InputP
|
|
|
31
38
|
};
|
|
32
39
|
type Successes<D> = D extends EndpointDescriptor<any, any, any, any, infer R, any> ? { [S in keyof R & number]: SuccessResult<InferCodec<R[S]>, S>; }[keyof R & number] : never;
|
|
33
40
|
type Errors<D> = D extends EndpointDescriptor<any, any, any, any, any, infer E> ? { [S in Exclude<keyof E, "default"> & number]: HttpResult<InferCodec<NonNullable<E[S]>>, S>; }[Exclude<keyof E, "default"> & number] | ("default" extends keyof E ? HttpResult<InferCodec<NonNullable<E["default"]>>, number> : never) : never;
|
|
41
|
+
/** The possible results of calling a typed client method for endpoint descriptor `D`. */
|
|
34
42
|
type ClientResult<D extends AnyEndpointDescriptor> = Successes<D> | Errors<D> | FailureResult;
|
|
35
43
|
type ClientMethod<D extends AnyEndpointDescriptor> = RequiredKeys<EndpointInput<D>> extends never ? (input?: EndpointInput<D>) => Promise<ClientResult<D>> : (input: EndpointInput<D>) => Promise<ClientResult<D>>;
|
|
44
|
+
/** A fully-typed client for an {@link ApiDefinition}, with one method per endpoint. */
|
|
36
45
|
type ApiClient<A extends ApiDefinition> = Readonly<{ [K in keyof A["endpoints"]]: ClientMethod<A["endpoints"][K]>; }>;
|
|
46
|
+
/**
|
|
47
|
+
* Builds a typed {@link ApiClient} from an {@link ApiDefinition}. Each endpoint becomes
|
|
48
|
+
* a method that fills in the path, query, header, and body parameters, executes the
|
|
49
|
+
* request via {@link createFetch}, and returns a {@link ClientResult}.
|
|
50
|
+
*/
|
|
37
51
|
declare function createClient<A extends ApiDefinition>(api: A, options?: ClientOptions): ApiClient<A>;
|
|
52
|
+
/** An `Error` thrown by {@link unwrap} that wraps a failed (non-`ok`) {@link FetchResult}. */
|
|
38
53
|
declare class FetchError extends Error {
|
|
39
54
|
readonly result: Exclude<FetchResult, {
|
|
40
55
|
ok: true;
|
|
@@ -43,6 +58,10 @@ declare class FetchError extends Error {
|
|
|
43
58
|
ok: true;
|
|
44
59
|
}>);
|
|
45
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Returns the data of a successful {@link FetchResult}, or throws a {@link FetchError}
|
|
63
|
+
* wrapping the result if it was not `ok`.
|
|
64
|
+
*/
|
|
46
65
|
declare function unwrap<T>(result: FetchResult<T>): T;
|
|
47
66
|
//#endregion
|
|
48
67
|
export { AdHocCall, ApiClient, ClientResult, FetchError, createClient, createFetch, unwrap };
|
package/dist/client.js
CHANGED
|
@@ -98,8 +98,10 @@ function pathParameter(name, value, spec = {}) {
|
|
|
98
98
|
function queryParameter(output, name, value, spec = {}) {
|
|
99
99
|
const style = spec.style ?? "form";
|
|
100
100
|
const explode = spec.explode ?? true;
|
|
101
|
-
const object = entries(value);
|
|
101
|
+
const object = entries(value).filter(([, item]) => item !== void 0);
|
|
102
102
|
const array = Array.isArray(value);
|
|
103
|
+
const emptyArray = array && value.length === 0;
|
|
104
|
+
const items = values(value).filter((item) => item !== void 0).map(String);
|
|
103
105
|
const append = (key, item) => output.append(key, String(item));
|
|
104
106
|
if (style === "deepObject") {
|
|
105
107
|
if (!object.length) throw new TypeError(`deepObject query parameter ${name} must be an object`);
|
|
@@ -108,17 +110,17 @@ function queryParameter(output, name, value, spec = {}) {
|
|
|
108
110
|
}
|
|
109
111
|
if (style === "spaceDelimited" || style === "pipeDelimited") {
|
|
110
112
|
if (!array) throw new TypeError(`${style} query parameter ${name} must be an array`);
|
|
111
|
-
append(name,
|
|
113
|
+
if (items.length || emptyArray) append(name, items.join(style === "spaceDelimited" ? " " : "|"));
|
|
112
114
|
return;
|
|
113
115
|
}
|
|
114
116
|
if (style !== "form") throw new TypeError(`Unsupported query parameter style: ${style}`);
|
|
115
117
|
if (object.length) {
|
|
116
118
|
if (explode) for (const [key, item] of object) append(key, item);
|
|
117
|
-
else append(name, object.flatMap(([key, item]) => [key, item]).join(","));
|
|
119
|
+
else append(name, object.flatMap(([key, item]) => [key, String(item)]).join(","));
|
|
118
120
|
return;
|
|
119
121
|
}
|
|
120
|
-
if (array && explode) for (const item of
|
|
121
|
-
else append(name,
|
|
122
|
+
if (array && explode) for (const item of items) append(name, item);
|
|
123
|
+
else if (items.length || emptyArray) append(name, items.join(","));
|
|
122
124
|
}
|
|
123
125
|
function headerParameter(value, spec = {}) {
|
|
124
126
|
if ((spec.style ?? "simple") !== "simple") throw new TypeError(`Unsupported header parameter style: ${spec.style}`);
|
|
@@ -138,6 +140,12 @@ const parameterValue = (map, name, value) => {
|
|
|
138
140
|
if (!parsed.success) throw parsed.error;
|
|
139
141
|
return parsed.data;
|
|
140
142
|
};
|
|
143
|
+
/**
|
|
144
|
+
* Creates a low-level fetch function that builds a `Request` from an {@link AdHocCall},
|
|
145
|
+
* runs it through the configured middleware chain, and decodes the response with the
|
|
146
|
+
* matching codec. Used internally by {@link createClient}, and usable directly for
|
|
147
|
+
* requests without a full {@link EndpointDescriptor}.
|
|
148
|
+
*/
|
|
141
149
|
function createFetch(options = {}) {
|
|
142
150
|
return async (call) => {
|
|
143
151
|
let url = `${options.baseUrl?.replace(/\/$/, "") ?? ""}${call.url}`;
|
|
@@ -236,6 +244,7 @@ function createFetch(options = {}) {
|
|
|
236
244
|
operationId: call.operationId,
|
|
237
245
|
security: call.endpoint?.security,
|
|
238
246
|
attempt: 1,
|
|
247
|
+
...body === void 0 ? {} : { replayableBody: !(body instanceof ReadableStream) },
|
|
239
248
|
...timeout === void 0 ? {} : { deadline: Date.now() + timeout }
|
|
240
249
|
}));
|
|
241
250
|
} finally {
|
|
@@ -243,6 +252,11 @@ function createFetch(options = {}) {
|
|
|
243
252
|
}
|
|
244
253
|
};
|
|
245
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Builds a typed {@link ApiClient} from an {@link ApiDefinition}. Each endpoint becomes
|
|
257
|
+
* a method that fills in the path, query, header, and body parameters, executes the
|
|
258
|
+
* request via {@link createFetch}, and returns a {@link ClientResult}.
|
|
259
|
+
*/
|
|
246
260
|
function createClient(api, options = {}) {
|
|
247
261
|
const execute = createFetch(options);
|
|
248
262
|
const methods = Object.fromEntries(Object.entries(api.endpoints).map(([id, endpoint]) => [id, async (input = {}) => {
|
|
@@ -283,6 +297,7 @@ function createClient(api, options = {}) {
|
|
|
283
297
|
}]));
|
|
284
298
|
return Object.freeze(methods);
|
|
285
299
|
}
|
|
300
|
+
/** An `Error` thrown by {@link unwrap} that wraps a failed (non-`ok`) {@link FetchResult}. */
|
|
286
301
|
var FetchError = class extends Error {
|
|
287
302
|
result;
|
|
288
303
|
constructor(result) {
|
|
@@ -291,6 +306,10 @@ var FetchError = class extends Error {
|
|
|
291
306
|
this.name = "FetchError";
|
|
292
307
|
}
|
|
293
308
|
};
|
|
309
|
+
/**
|
|
310
|
+
* Returns the data of a successful {@link FetchResult}, or throws a {@link FetchError}
|
|
311
|
+
* wrapping the result if it was not `ok`.
|
|
312
|
+
*/
|
|
294
313
|
function unwrap(result) {
|
|
295
314
|
if (!result.ok) throw new FetchError(result);
|
|
296
315
|
return result.data;
|
package/dist/codecs.d.ts
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import { Codec, Validator } from "./types.js";
|
|
2
2
|
//#region src/codecs.d.ts
|
|
3
|
+
/** Creates a JSON codec, matching `application/json` and `+json` suffixed media types. */
|
|
3
4
|
declare function json<T = unknown>(): Codec<T>;
|
|
5
|
+
/** Creates a JSON codec that validates/parses the decoded value with the given schema. */
|
|
4
6
|
declare function json<V extends Validator>(schema: V): Codec<V extends Validator<infer T> ? T : never>;
|
|
7
|
+
/** Creates a codec for plain text bodies (`text/*`), decoded as a string. */
|
|
5
8
|
declare const text: () => Codec<string>;
|
|
9
|
+
/** Creates a codec for `application/x-www-form-urlencoded` bodies, decoded as `URLSearchParams`. */
|
|
6
10
|
declare const urlEncoded: () => Codec<URLSearchParams>;
|
|
11
|
+
/** Creates a codec for `multipart/form-data` bodies, decoded as `FormData`. */
|
|
7
12
|
declare const multipart: () => Codec<FormData>;
|
|
13
|
+
/** Creates a codec that decodes any response body as a `Blob`. */
|
|
8
14
|
declare const blob: () => Codec<Blob>;
|
|
15
|
+
/** Creates a codec that decodes any response body as an `ArrayBuffer`. */
|
|
9
16
|
declare const arrayBuffer: () => Codec<ArrayBuffer>;
|
|
17
|
+
/** Creates a codec that exposes the raw response body as a `ReadableStream`, without buffering it. */
|
|
10
18
|
declare const stream: () => Codec<ReadableStream<Uint8Array>>;
|
|
19
|
+
/** Creates a codec for bodies expected to be empty (e.g. 204 No Content), decoded as `undefined`. */
|
|
11
20
|
declare const empty: () => Codec<undefined>;
|
|
21
|
+
/**
|
|
22
|
+
* Creates a content-negotiated codec that selects among `variants` by the response's
|
|
23
|
+
* media type, decoding with whichever variant matches.
|
|
24
|
+
*/
|
|
12
25
|
declare const content: <T extends Record<string, Codec>>(variants: T) => Codec<{ [K in keyof T]: T[K] extends Codec<infer V> ? V : never; }[keyof T]>;
|
|
13
26
|
//#endregion
|
|
14
27
|
export { arrayBuffer, blob, content, empty, json, multipart, stream, text, urlEncoded };
|
package/dist/codecs.js
CHANGED
|
@@ -7,13 +7,24 @@ const codec = (kind, mediaTypes, validator) => Object.freeze({
|
|
|
7
7
|
function json(schema) {
|
|
8
8
|
return codec("json", ["application/json", "+json"], schema);
|
|
9
9
|
}
|
|
10
|
+
/** Creates a codec for plain text bodies (`text/*`), decoded as a string. */
|
|
10
11
|
const text = () => codec("text", ["text/*"]);
|
|
12
|
+
/** Creates a codec for `application/x-www-form-urlencoded` bodies, decoded as `URLSearchParams`. */
|
|
11
13
|
const urlEncoded = () => codec("urlEncoded", ["application/x-www-form-urlencoded"]);
|
|
14
|
+
/** Creates a codec for `multipart/form-data` bodies, decoded as `FormData`. */
|
|
12
15
|
const multipart = () => codec("multipart", ["multipart/form-data"]);
|
|
16
|
+
/** Creates a codec that decodes any response body as a `Blob`. */
|
|
13
17
|
const blob = () => codec("blob", ["*/*"]);
|
|
18
|
+
/** Creates a codec that decodes any response body as an `ArrayBuffer`. */
|
|
14
19
|
const arrayBuffer = () => codec("arrayBuffer", ["*/*"]);
|
|
20
|
+
/** Creates a codec that exposes the raw response body as a `ReadableStream`, without buffering it. */
|
|
15
21
|
const stream = () => codec("stream", ["*/*"]);
|
|
22
|
+
/** Creates a codec for bodies expected to be empty (e.g. 204 No Content), decoded as `undefined`. */
|
|
16
23
|
const empty = () => codec("empty", []);
|
|
24
|
+
/**
|
|
25
|
+
* Creates a content-negotiated codec that selects among `variants` by the response's
|
|
26
|
+
* media type, decoding with whichever variant matches.
|
|
27
|
+
*/
|
|
17
28
|
const content = (variants) => Object.freeze({
|
|
18
29
|
kind: "content",
|
|
19
30
|
mediaTypes: Object.freeze(Object.keys(variants).sort()),
|
package/dist/dsl.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { AnyEndpointDescriptor, ApiDefinition, ApiMetadata, Codec, EndpointDescriptor, ParameterMap } from "./types.js";
|
|
2
2
|
//#region src/dsl.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Extracts the ordered list of `{param}` placeholder names from an endpoint path.
|
|
5
|
+
*
|
|
6
|
+
* @throws {TypeError} If the path uses colon/wildcard syntax, has malformed
|
|
7
|
+
* `{...}` placeholders, or contains a duplicate parameter name.
|
|
8
|
+
*/
|
|
3
9
|
declare function pathNames(path: string): string[];
|
|
4
10
|
type Responses = Record<number, Codec>;
|
|
5
11
|
type Errors = Partial<Record<number | "default", Codec>>;
|
|
@@ -8,6 +14,12 @@ type State = Omit<EndpointDescriptor, "responses" | "errors"> & {
|
|
|
8
14
|
errors: Errors;
|
|
9
15
|
};
|
|
10
16
|
declare const STATE: unique symbol;
|
|
17
|
+
/**
|
|
18
|
+
* Fluent, immutable builder for describing a single endpoint's params, query,
|
|
19
|
+
* headers, body, responses, errors, and security requirements. Each method
|
|
20
|
+
* returns a new builder reflecting the added configuration; the accumulated
|
|
21
|
+
* state is finalized into an {@link EndpointDescriptor} by {@link defineApi}.
|
|
22
|
+
*/
|
|
11
23
|
interface EndpointBuilder<P = undefined, Q = undefined, H = undefined, B = undefined, R extends Responses = {}, E extends Errors = {}> {
|
|
12
24
|
readonly [STATE]: State;
|
|
13
25
|
params<T extends Record<string, unknown>>(spec?: ParameterMap): EndpointBuilder<T, Q, H, B, R, E>;
|
|
@@ -19,15 +31,32 @@ interface EndpointBuilder<P = undefined, Q = undefined, H = undefined, B = undef
|
|
|
19
31
|
errors<T extends Errors>(spec: T): EndpointBuilder<P, Q, H, B, R, E & T>;
|
|
20
32
|
security(requirements: readonly Readonly<Record<string, readonly string[]>>[]): EndpointBuilder<P, Q, H, B, R, E>;
|
|
21
33
|
}
|
|
34
|
+
/** Starts building a GET endpoint at the given path. */
|
|
22
35
|
declare const get: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
|
|
36
|
+
/** Starts building a POST endpoint at the given path. */
|
|
23
37
|
declare const post: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
|
|
38
|
+
/** Starts building a PUT endpoint at the given path. */
|
|
24
39
|
declare const put: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
|
|
40
|
+
/** Starts building a PATCH endpoint at the given path. */
|
|
25
41
|
declare const patch: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
|
|
42
|
+
/** Starts building a DELETE endpoint at the given path. */
|
|
26
43
|
declare const del: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
|
|
44
|
+
/** Starts building a HEAD endpoint at the given path. */
|
|
27
45
|
declare const head: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
|
|
46
|
+
/** Starts building an OPTIONS endpoint at the given path. */
|
|
28
47
|
declare const options: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
|
|
29
48
|
type DescriptorOf<T> = T extends EndpointBuilder<infer P, infer Q, infer H, infer B, infer R, infer E> ? EndpointDescriptor<P, Q, H, B, R, E> : T extends AnyEndpointDescriptor ? T : never;
|
|
30
49
|
type Defined<E extends Record<string, AnyEndpointDescriptor | EndpointBuilder>> = { [K in keyof E]: DescriptorOf<E[K]>; };
|
|
50
|
+
/**
|
|
51
|
+
* Finalizes a map of {@link EndpointBuilder}s and/or raw {@link EndpointDescriptor}s into
|
|
52
|
+
* a frozen {@link ApiDefinition}, stamping each endpoint's `operationId` from its key and
|
|
53
|
+
* deep-freezing its parameters, security, responses, and errors.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* const api = defineApi({
|
|
57
|
+
* getUser: get("/users/{id}").returns(json()),
|
|
58
|
+
* });
|
|
59
|
+
*/
|
|
31
60
|
declare function defineApi<E extends Record<string, AnyEndpointDescriptor | EndpointBuilder>>(endpoints: E, metadata?: ApiMetadata): ApiDefinition<Defined<E>>;
|
|
32
61
|
//#endregion
|
|
33
62
|
export { EndpointBuilder, defineApi, del, get, head, options, patch, pathNames, post, put };
|
package/dist/dsl.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
//#region src/dsl.ts
|
|
2
2
|
const PATH_NAME = /^[A-Za-z_$][\w$]*$/;
|
|
3
|
+
/**
|
|
4
|
+
* Extracts the ordered list of `{param}` placeholder names from an endpoint path.
|
|
5
|
+
*
|
|
6
|
+
* @throws {TypeError} If the path uses colon/wildcard syntax, has malformed
|
|
7
|
+
* `{...}` placeholders, or contains a duplicate parameter name.
|
|
8
|
+
*/
|
|
3
9
|
function pathNames(path) {
|
|
4
10
|
if (path.includes(":") || path.includes("*")) throw new TypeError(`Invalid path syntax: ${path}`);
|
|
5
11
|
const names = [];
|
|
@@ -69,12 +75,19 @@ const method = (value) => (path) => {
|
|
|
69
75
|
errors: {}
|
|
70
76
|
});
|
|
71
77
|
};
|
|
78
|
+
/** Starts building a GET endpoint at the given path. */
|
|
72
79
|
const get = method("GET");
|
|
80
|
+
/** Starts building a POST endpoint at the given path. */
|
|
73
81
|
const post = method("POST");
|
|
82
|
+
/** Starts building a PUT endpoint at the given path. */
|
|
74
83
|
const put = method("PUT");
|
|
84
|
+
/** Starts building a PATCH endpoint at the given path. */
|
|
75
85
|
const patch = method("PATCH");
|
|
86
|
+
/** Starts building a DELETE endpoint at the given path. */
|
|
76
87
|
const del = method("DELETE");
|
|
88
|
+
/** Starts building a HEAD endpoint at the given path. */
|
|
77
89
|
const head = method("HEAD");
|
|
90
|
+
/** Starts building an OPTIONS endpoint at the given path. */
|
|
78
91
|
const options = method("OPTIONS");
|
|
79
92
|
const snapshotParameters = (parameters) => parameters ? Object.freeze(Object.fromEntries(Object.entries(parameters).map(([name, definition]) => [name, definition && typeof definition === "object" && !("safeParse" in definition) ? Object.freeze({ ...definition }) : definition]))) : void 0;
|
|
80
93
|
const snapshotSecurity = (security) => security ? Object.freeze(security.map((requirement) => Object.freeze(Object.fromEntries(Object.entries(requirement).map(([name, scopes]) => [name, Object.freeze([...scopes])]))))) : void 0;
|
|
@@ -84,6 +97,16 @@ const snapshotMetadata = (metadata) => Object.freeze({
|
|
|
84
97
|
...metadata.tags ? { tags: Object.freeze([...metadata.tags]) } : {},
|
|
85
98
|
...metadata.securitySchemes ? { securitySchemes: Object.freeze({ ...metadata.securitySchemes }) } : {}
|
|
86
99
|
});
|
|
100
|
+
/**
|
|
101
|
+
* Finalizes a map of {@link EndpointBuilder}s and/or raw {@link EndpointDescriptor}s into
|
|
102
|
+
* a frozen {@link ApiDefinition}, stamping each endpoint's `operationId` from its key and
|
|
103
|
+
* deep-freezing its parameters, security, responses, and errors.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* const api = defineApi({
|
|
107
|
+
* getUser: get("/users/{id}").returns(json()),
|
|
108
|
+
* });
|
|
109
|
+
*/
|
|
87
110
|
function defineApi(endpoints, metadata) {
|
|
88
111
|
const frozen = Object.fromEntries(Object.entries(endpoints).map(([id, value]) => {
|
|
89
112
|
const endpoint = STATE in value ? value[STATE] : value;
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,28 +1,64 @@
|
|
|
1
1
|
import { FetchResult, Middleware, RequestContext } from "./types.js";
|
|
2
2
|
//#region src/middleware.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Middleware that attaches an `Authorization: Bearer <token>` header to every request.
|
|
5
|
+
* `token` may be a static string or a (possibly async) function resolved on each request.
|
|
6
|
+
*/
|
|
3
7
|
declare const bearerAuth: ({ token }: {
|
|
4
8
|
token: string | (() => string | Promise<string>);
|
|
5
9
|
}) => Middleware;
|
|
10
|
+
/**
|
|
11
|
+
* Middleware that attaches an API key to every request, either as a header or a query
|
|
12
|
+
* parameter (`in`, default `"header"`). `value` may be a static string or a (possibly
|
|
13
|
+
* async) function resolved on each request. The key is marked sensitive so the
|
|
14
|
+
* {@link logging} middleware redacts it.
|
|
15
|
+
*/
|
|
6
16
|
declare function apiKeyAuth({ key, value, in: location }: {
|
|
7
17
|
key: string;
|
|
8
18
|
value: string | (() => string | Promise<string>);
|
|
9
19
|
in?: "header" | "query";
|
|
10
20
|
}): Middleware;
|
|
21
|
+
/** Options controlling the {@link retry} middleware's behavior. */
|
|
11
22
|
interface RetryOptions {
|
|
23
|
+
/** Maximum number of attempts, including the first. Defaults to 3. */
|
|
12
24
|
attempts?: number;
|
|
25
|
+
/** HTTP methods eligible for retry. Defaults to idempotent-by-convention methods. */
|
|
13
26
|
methods?: readonly string[];
|
|
27
|
+
/** Response statuses that trigger a retry. Defaults to common transient failure codes. */
|
|
14
28
|
statuses?: readonly number[];
|
|
29
|
+
/** Computes the delay (ms) before the given retry attempt, if no `Retry-After` header is present. */
|
|
15
30
|
delay?: (attempt: number) => number;
|
|
16
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Middleware that retries failed requests. Retries eligible methods on network failures
|
|
34
|
+
* or eligible response statuses, honoring a `Retry-After` header when present and
|
|
35
|
+
* stopping early if the request's deadline would be exceeded. A non-streaming request body
|
|
36
|
+
* is replayed only when it can be cloned before the first attempt; streaming, already-consumed,
|
|
37
|
+
* or otherwise non-cloneable bodies are sent once without retrying.
|
|
38
|
+
*/
|
|
17
39
|
declare function retry(options?: RetryOptions): Middleware;
|
|
40
|
+
/**
|
|
41
|
+
* Middleware that logs a `"request"` event before and a `"response"` event after each
|
|
42
|
+
* request, via the given logger (defaults to `console`). Headers and query parameters
|
|
43
|
+
* marked sensitive (e.g. by {@link apiKeyAuth}) or matching common sensitive-name
|
|
44
|
+
* patterns (authorization, cookie, token, secret, password, api key) are redacted.
|
|
45
|
+
*/
|
|
18
46
|
declare function logging(logger?: {
|
|
19
47
|
log(event: Record<string, unknown>): void;
|
|
20
48
|
}): Middleware;
|
|
49
|
+
/** Hooks invoked by the {@link telemetry} middleware around each request. */
|
|
21
50
|
interface TelemetryHooks {
|
|
51
|
+
/** Called before the request proceeds; its return value (a "span") is passed to `end`/`error`. */
|
|
22
52
|
start?(context: RequestContext): unknown;
|
|
53
|
+
/** Called after the request completes successfully (from this middleware's perspective). */
|
|
23
54
|
end?(span: unknown, result: FetchResult): void;
|
|
55
|
+
/** Called if a downstream middleware or the transport throws. */
|
|
24
56
|
error?(span: unknown, error: unknown): void;
|
|
25
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Middleware that wraps each request with {@link TelemetryHooks}, calling `start` before
|
|
60
|
+
* the request, `end` after it completes, and `error` (then rethrowing) if it throws.
|
|
61
|
+
*/
|
|
26
62
|
declare function telemetry(hooks: TelemetryHooks): Middleware;
|
|
27
63
|
//#endregion
|
|
28
64
|
export { RetryOptions, TelemetryHooks, apiKeyAuth, bearerAuth, logging, retry, telemetry };
|
package/dist/middleware.js
CHANGED
|
@@ -21,10 +21,20 @@ const replaceHeaders = (context, mutate) => {
|
|
|
21
21
|
request: new Request(context.request, { headers })
|
|
22
22
|
});
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Middleware that attaches an `Authorization: Bearer <token>` header to every request.
|
|
26
|
+
* `token` may be a static string or a (possibly async) function resolved on each request.
|
|
27
|
+
*/
|
|
24
28
|
const bearerAuth = ({ token }) => async (context, next) => {
|
|
25
29
|
const resolved = typeof token === "function" ? await token() : token;
|
|
26
30
|
return next(replaceHeaders(context, (headers) => headers.set("authorization", `Bearer ${resolved}`)));
|
|
27
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Middleware that attaches an API key to every request, either as a header or a query
|
|
34
|
+
* parameter (`in`, default `"header"`). `value` may be a static string or a (possibly
|
|
35
|
+
* async) function resolved on each request. The key is marked sensitive so the
|
|
36
|
+
* {@link logging} middleware redacts it.
|
|
37
|
+
*/
|
|
28
38
|
function apiKeyAuth({ key, value, in: location = "header" }) {
|
|
29
39
|
return async (context, next) => {
|
|
30
40
|
const resolved = typeof value === "function" ? await value() : value;
|
|
@@ -37,6 +47,13 @@ function apiKeyAuth({ key, value, in: location = "header" }) {
|
|
|
37
47
|
}), "query", key));
|
|
38
48
|
};
|
|
39
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Middleware that retries failed requests. Retries eligible methods on network failures
|
|
52
|
+
* or eligible response statuses, honoring a `Retry-After` header when present and
|
|
53
|
+
* stopping early if the request's deadline would be exceeded. A non-streaming request body
|
|
54
|
+
* is replayed only when it can be cloned before the first attempt; streaming, already-consumed,
|
|
55
|
+
* or otherwise non-cloneable bodies are sent once without retrying.
|
|
56
|
+
*/
|
|
40
57
|
function retry(options = {}) {
|
|
41
58
|
const attempts = options.attempts ?? 3;
|
|
42
59
|
const methods = options.methods ?? [
|
|
@@ -56,7 +73,14 @@ function retry(options = {}) {
|
|
|
56
73
|
504
|
|
57
74
|
];
|
|
58
75
|
return async (context, next) => {
|
|
59
|
-
if (!methods.includes(context.request.method)
|
|
76
|
+
if (!methods.includes(context.request.method)) return next(context);
|
|
77
|
+
if (context.replayableBody === false) return next(context);
|
|
78
|
+
let replayable;
|
|
79
|
+
if (context.request.body) try {
|
|
80
|
+
replayable = context.request.clone();
|
|
81
|
+
} catch {
|
|
82
|
+
return next(context);
|
|
83
|
+
}
|
|
60
84
|
let result = await next(context);
|
|
61
85
|
for (let attempt = 2; attempt <= attempts && !result.ok && (result.kind === "network" || result.response !== void 0 && statuses.includes(result.status)); attempt++) {
|
|
62
86
|
const retryAfter = "response" in result ? result.response?.headers.get("retry-after") : null;
|
|
@@ -81,10 +105,16 @@ function retry(options = {}) {
|
|
|
81
105
|
headers: new Headers(),
|
|
82
106
|
url: context.request.url
|
|
83
107
|
};
|
|
108
|
+
let request;
|
|
109
|
+
try {
|
|
110
|
+
request = (replayable ?? context.request).clone();
|
|
111
|
+
} catch {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
84
114
|
result = await next(Object.freeze({
|
|
85
115
|
...context,
|
|
86
116
|
attempt,
|
|
87
|
-
request
|
|
117
|
+
request
|
|
88
118
|
}));
|
|
89
119
|
}
|
|
90
120
|
return result;
|
|
@@ -101,6 +131,12 @@ const redactedUrl = (value, names = []) => {
|
|
|
101
131
|
}
|
|
102
132
|
return url.toString();
|
|
103
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Middleware that logs a `"request"` event before and a `"response"` event after each
|
|
136
|
+
* request, via the given logger (defaults to `console`). Headers and query parameters
|
|
137
|
+
* marked sensitive (e.g. by {@link apiKeyAuth}) or matching common sensitive-name
|
|
138
|
+
* patterns (authorization, cookie, token, secret, password, api key) are redacted.
|
|
139
|
+
*/
|
|
104
140
|
function logging(logger = console) {
|
|
105
141
|
return async (context, next) => {
|
|
106
142
|
const sensitive = context[SENSITIVE];
|
|
@@ -123,6 +159,10 @@ function logging(logger = console) {
|
|
|
123
159
|
return result;
|
|
124
160
|
};
|
|
125
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Middleware that wraps each request with {@link TelemetryHooks}, calling `start` before
|
|
164
|
+
* the request, `end` after it completes, and `error` (then rethrowing) if it throws.
|
|
165
|
+
*/
|
|
126
166
|
function telemetry(hooks) {
|
|
127
167
|
return async (context, next) => {
|
|
128
168
|
const span = hooks.start?.(context);
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
//#region src/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* A minimal schema-validation contract, compatible with libraries such as Zod
|
|
4
|
+
* that expose a `safeParse` method (e.g. via a thin adapter).
|
|
5
|
+
*/
|
|
2
6
|
interface Validator<T = unknown> {
|
|
3
7
|
safeParse(value: unknown): {
|
|
4
8
|
success: true;
|
|
@@ -8,22 +12,38 @@ interface Validator<T = unknown> {
|
|
|
8
12
|
error: unknown;
|
|
9
13
|
};
|
|
10
14
|
}
|
|
15
|
+
/** Infers the parsed output type `T` from a {@link Validator}. */
|
|
11
16
|
type InferValidator<V> = V extends Validator<infer T> ? T : never;
|
|
17
|
+
/**
|
|
18
|
+
* Describes how a request or response body is serialized/deserialized:
|
|
19
|
+
* which wire format (`kind`), which media types it matches, and optionally
|
|
20
|
+
* a {@link Validator} to parse/validate the decoded value.
|
|
21
|
+
*/
|
|
12
22
|
interface Codec<T = unknown> {
|
|
13
23
|
readonly kind: "json" | "text" | "urlEncoded" | "multipart" | "blob" | "arrayBuffer" | "stream" | "empty" | "content";
|
|
14
24
|
readonly mediaTypes: readonly string[];
|
|
15
25
|
readonly validator?: Validator<T>;
|
|
26
|
+
/** For `kind: "content"`, the per-media-type codec variants. */
|
|
16
27
|
readonly variants?: Readonly<Record<string, Codec<unknown>>>;
|
|
17
28
|
}
|
|
29
|
+
/** Infers the decoded value type `T` from a {@link Codec}. */
|
|
18
30
|
type InferCodec<C> = C extends Codec<infer T> ? T : never;
|
|
31
|
+
/** The set of HTTP methods supported by endpoint descriptors. */
|
|
19
32
|
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
33
|
+
/** OpenAPI-style serialization styles for path, query, and header parameters. */
|
|
20
34
|
type ParameterStyle = "simple" | "label" | "matrix" | "form" | "spaceDelimited" | "pipeDelimited" | "deepObject";
|
|
35
|
+
/** Serialization and validation settings for a single path, query, or header parameter. */
|
|
21
36
|
interface ParameterSpec<T = unknown> {
|
|
22
37
|
readonly validator?: Validator<T>;
|
|
23
38
|
readonly style?: ParameterStyle;
|
|
24
39
|
readonly explode?: boolean;
|
|
25
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Maps parameter names to either a bare {@link Validator} or a full
|
|
43
|
+
* {@link ParameterSpec} describing serialization and validation.
|
|
44
|
+
*/
|
|
26
45
|
type ParameterMap = Readonly<Record<string, Validator<unknown> | ParameterSpec | undefined>>;
|
|
46
|
+
/** Describes a single API endpoint: its method, path, parameters, body, and possible responses. */
|
|
27
47
|
interface EndpointDescriptor<P = undefined, Q = undefined, H = undefined, B = undefined, R extends Record<number, Codec> = Record<number, Codec>, E extends Partial<Record<number | "default", Codec>> = Partial<Record<number | "default", Codec>>> {
|
|
28
48
|
readonly method: HttpMethod;
|
|
29
49
|
readonly path: string;
|
|
@@ -42,7 +62,9 @@ interface EndpointDescriptor<P = undefined, Q = undefined, H = undefined, B = un
|
|
|
42
62
|
body: B;
|
|
43
63
|
};
|
|
44
64
|
}
|
|
65
|
+
/** An {@link EndpointDescriptor} with its type parameters erased, for use in generic contexts. */
|
|
45
66
|
type AnyEndpointDescriptor = EndpointDescriptor<any, any, any, any, any, any>;
|
|
67
|
+
/** API-level (rather than per-endpoint) metadata, such as servers and security schemes. */
|
|
46
68
|
interface ApiMetadata {
|
|
47
69
|
readonly servers?: readonly (string | {
|
|
48
70
|
readonly url: string;
|
|
@@ -50,10 +72,12 @@ interface ApiMetadata {
|
|
|
50
72
|
readonly tags?: readonly unknown[];
|
|
51
73
|
readonly securitySchemes?: Readonly<Record<string, unknown>>;
|
|
52
74
|
}
|
|
75
|
+
/** A named collection of endpoints plus optional API metadata, as produced by {@link defineApi}. */
|
|
53
76
|
interface ApiDefinition<E extends Record<string, AnyEndpointDescriptor> = Record<string, AnyEndpointDescriptor>> {
|
|
54
77
|
readonly endpoints: E;
|
|
55
78
|
readonly metadata?: ApiMetadata;
|
|
56
79
|
}
|
|
80
|
+
/** A successful (2xx) fetch outcome, carrying the decoded response data. */
|
|
57
81
|
type SuccessResult<T = unknown, S extends number = number> = {
|
|
58
82
|
ok: true;
|
|
59
83
|
kind: "success";
|
|
@@ -64,6 +88,7 @@ type SuccessResult<T = unknown, S extends number = number> = {
|
|
|
64
88
|
url: string;
|
|
65
89
|
response: Response;
|
|
66
90
|
};
|
|
91
|
+
/** A non-2xx fetch outcome where the server responded with a decodable error body. */
|
|
67
92
|
type HttpResult<T = unknown, S extends number = number> = {
|
|
68
93
|
ok: false;
|
|
69
94
|
kind: "http";
|
|
@@ -74,7 +99,9 @@ type HttpResult<T = unknown, S extends number = number> = {
|
|
|
74
99
|
url: string;
|
|
75
100
|
response: Response;
|
|
76
101
|
};
|
|
102
|
+
/** Categorizes why a fetch could not produce an {@link HttpResult} or {@link SuccessResult}. */
|
|
77
103
|
type FailureKind = "request" | "network" | "timeout" | "abort" | "decode" | "middleware";
|
|
104
|
+
/** A fetch outcome that failed before or independently of receiving a decodable HTTP response. */
|
|
78
105
|
type FailureResult = {
|
|
79
106
|
ok: false;
|
|
80
107
|
kind: FailureKind;
|
|
@@ -84,23 +111,35 @@ type FailureResult = {
|
|
|
84
111
|
url: string;
|
|
85
112
|
response?: Response;
|
|
86
113
|
};
|
|
114
|
+
/** The outcome of a fetch call: success, an HTTP-level error, or another kind of failure. */
|
|
87
115
|
type FetchResult<T = unknown> = SuccessResult<T> | HttpResult<T> | FailureResult;
|
|
116
|
+
/** The mutable-by-replacement state threaded through the middleware chain for a single request. */
|
|
88
117
|
interface RequestContext {
|
|
89
118
|
readonly request: Request;
|
|
90
119
|
readonly endpoint?: AnyEndpointDescriptor;
|
|
91
120
|
readonly operationId?: string;
|
|
92
121
|
readonly security?: AnyEndpointDescriptor["security"];
|
|
122
|
+
/** 1 on the first attempt, incremented by middleware (e.g. {@link retry}) on subsequent attempts. */
|
|
93
123
|
readonly attempt: number;
|
|
124
|
+
/** Whether a body was encoded from a replayable value; streaming bodies set this to `false`. */
|
|
125
|
+
readonly replayableBody?: boolean;
|
|
126
|
+
/** Absolute timestamp (ms since epoch) by which the request must complete, if a timeout is set. */
|
|
94
127
|
readonly deadline?: number;
|
|
95
128
|
}
|
|
129
|
+
/** Invokes the next middleware in the chain, optionally passing a replacement context. */
|
|
96
130
|
type Next = (context?: RequestContext) => Promise<FetchResult>;
|
|
131
|
+
/** A function that can inspect/replace the request context and/or the result of calling `next`. */
|
|
97
132
|
type Middleware = (context: RequestContext, next: Next) => Promise<FetchResult>;
|
|
133
|
+
/** Options controlling how a {@link createClient} or {@link createFetch} client makes requests. */
|
|
98
134
|
interface ClientOptions {
|
|
99
135
|
baseUrl?: string;
|
|
136
|
+
/** Custom transport, defaulting to the global `fetch`. */
|
|
100
137
|
fetch?: (request: Request) => Promise<Response>;
|
|
101
138
|
headers?: HeadersInit;
|
|
102
139
|
credentials?: RequestCredentials;
|
|
140
|
+
/** Request timeout in milliseconds. */
|
|
103
141
|
timeout?: number;
|
|
142
|
+
/** Middleware chain applied to every request, in order. */
|
|
104
143
|
middleware?: readonly Middleware[];
|
|
105
144
|
}
|
|
106
145
|
//#endregion
|