liaise 0.0.0 → 5.0.0
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/CHANGELOG.md +961 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +925 -0
- package/README.md +1392 -4
- package/dist/built-in-middleware.d.ts +232 -0
- package/dist/built-in-middleware.js +127 -0
- package/dist/create-api.d.ts +120 -0
- package/dist/create-api.js +370 -0
- package/dist/define-request.d.ts +251 -0
- package/dist/define-request.js +4 -0
- package/dist/graphql.d.ts +30 -0
- package/dist/graphql.js +272 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +6 -0
- package/dist/middleware.d.ts +77 -0
- package/dist/middleware.js +12 -0
- package/dist/paginate.d.ts +71 -0
- package/dist/paginate.js +15 -0
- package/dist/request.d.ts +136 -0
- package/dist/request.js +12 -0
- package/dist/result.d.ts +178 -0
- package/dist/result.js +20 -0
- package/dist/testing.d.ts +51 -0
- package/dist/testing.js +135 -0
- package/dist/types.d.ts +751 -0
- package/dist/types.js +1 -0
- package/dist/utils/abort-kind.d.ts +58 -0
- package/dist/utils/abort-kind.js +28 -0
- package/dist/utils/any-signal.d.ts +18 -0
- package/dist/utils/any-signal.js +29 -0
- package/dist/utils/backstop.d.ts +49 -0
- package/dist/utils/backstop.js +80 -0
- package/dist/utils/budget.d.ts +53 -0
- package/dist/utils/budget.js +20 -0
- package/dist/utils/cache.d.ts +54 -0
- package/dist/utils/cache.js +37 -0
- package/dist/utils/dedupe.d.ts +90 -0
- package/dist/utils/dedupe.js +20 -0
- package/dist/utils/headers.d.ts +1 -0
- package/dist/utils/headers.js +19 -0
- package/dist/utils/path-params.d.ts +89 -0
- package/dist/utils/path-params.js +80 -0
- package/dist/utils/serialize.d.ts +48 -0
- package/dist/utils/serialize.js +21 -0
- package/dist/utils/share.d.ts +49 -0
- package/dist/utils/share.js +48 -0
- package/dist/utils/special-body.d.ts +18 -0
- package/dist/utils/special-body.js +7 -0
- package/dist/utils/stable-key.d.ts +55 -0
- package/dist/utils/stable-key.js +111 -0
- package/dist/utils/timeout.d.ts +27 -0
- package/dist/utils/timeout.js +7 -0
- package/dist/utils/validate.d.ts +32 -0
- package/dist/utils/validate.js +6 -0
- package/package.json +67 -5
package/dist/graphql.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { ApiError, createSuccessResult, createErrorResult, createNetworkErrorResult } from './result.js';
|
|
2
|
+
import { composeMiddleware } from './middleware.js';
|
|
3
|
+
import { DedupeTracker } from './utils/dedupe.js';
|
|
4
|
+
import { mergeHeaders } from './utils/headers.js';
|
|
5
|
+
import { abortKind, propagatesReason } from './utils/abort-kind.js';
|
|
6
|
+
import { resolveBudget } from './utils/budget.js';
|
|
7
|
+
import { createBackstop } from './utils/backstop.js';
|
|
8
|
+
import { runSchema } from './utils/validate.js';
|
|
9
|
+
export class Operation {
|
|
10
|
+
constructor(config) {
|
|
11
|
+
this.config = config;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const gql = (strings, ...values) => String.raw({ raw: strings }, ...values);
|
|
15
|
+
export function createGraphQL(config) {
|
|
16
|
+
const { endpoint, middleware: globalMiddleware = [], headers: globalHeaders, onError, } = config;
|
|
17
|
+
const dedupeTracker = new DedupeTracker();
|
|
18
|
+
const fireOnError = (error) => {
|
|
19
|
+
if (error.kind === 'abort')
|
|
20
|
+
return;
|
|
21
|
+
if (!onError)
|
|
22
|
+
return;
|
|
23
|
+
try {
|
|
24
|
+
onError(error);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function buildMethod(name, operation) {
|
|
30
|
+
return (variables = {}, options = {}) => {
|
|
31
|
+
const execute = () => {
|
|
32
|
+
function buildFailedResult(reason, signal, fallbackKind) {
|
|
33
|
+
const isOurCancellation = signal?.aborted === true &&
|
|
34
|
+
(fallbackKind !== 'middleware' || propagatesReason(reason, signal.reason));
|
|
35
|
+
const kind = isOurCancellation ? (abortKind(signal.reason) ?? 'abort') : fallbackKind;
|
|
36
|
+
const error = new ApiError({
|
|
37
|
+
kind,
|
|
38
|
+
status: 0,
|
|
39
|
+
statusText: '',
|
|
40
|
+
body: reason,
|
|
41
|
+
headers: new Headers(),
|
|
42
|
+
request: { method: 'POST', url: endpoint, params: variables },
|
|
43
|
+
});
|
|
44
|
+
return createNetworkErrorResult(error, execute);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const allMiddleware = [
|
|
48
|
+
...globalMiddleware,
|
|
49
|
+
...(operation.config.middleware ?? []),
|
|
50
|
+
...(options.middleware ?? []),
|
|
51
|
+
];
|
|
52
|
+
const budget = resolveBudget(options.timeout, operation.config.timeout, options.signal, false);
|
|
53
|
+
const callerSignal = budget.operation;
|
|
54
|
+
let dedupeController;
|
|
55
|
+
const core = async (ctx) => {
|
|
56
|
+
try {
|
|
57
|
+
if (operation.config.dedupe && !dedupeController) {
|
|
58
|
+
const tracked = dedupeTracker.track(name, ctx.request.signal ?? callerSignal);
|
|
59
|
+
dedupeController = tracked.controller;
|
|
60
|
+
ctx.request.signal = tracked.signal;
|
|
61
|
+
backstop.watch(tracked.controller.signal);
|
|
62
|
+
}
|
|
63
|
+
const response = await fetch(ctx.request.url, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: ctx.request.headers,
|
|
66
|
+
body: ctx.request.body,
|
|
67
|
+
signal: ctx.request.signal,
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
let body;
|
|
71
|
+
try {
|
|
72
|
+
const text = await response.text();
|
|
73
|
+
body = text ? JSON.parse(text) : null;
|
|
74
|
+
}
|
|
75
|
+
catch (parseErr) {
|
|
76
|
+
const signal = ctx.request.signal;
|
|
77
|
+
if (signal?.aborted === true) {
|
|
78
|
+
const error = new ApiError({
|
|
79
|
+
status: 0,
|
|
80
|
+
kind: abortKind(signal.reason) ?? 'abort',
|
|
81
|
+
statusText: '',
|
|
82
|
+
body: parseErr,
|
|
83
|
+
headers: new Headers(),
|
|
84
|
+
request: { method: 'POST', url: ctx.request.url, params: variables },
|
|
85
|
+
});
|
|
86
|
+
return createNetworkErrorResult(error, execute);
|
|
87
|
+
}
|
|
88
|
+
body = null;
|
|
89
|
+
}
|
|
90
|
+
const error = new ApiError({
|
|
91
|
+
status: response.status,
|
|
92
|
+
kind: 'http',
|
|
93
|
+
statusText: response.statusText,
|
|
94
|
+
body,
|
|
95
|
+
headers: response.headers,
|
|
96
|
+
request: { method: 'POST', url: ctx.request.url, params: variables },
|
|
97
|
+
});
|
|
98
|
+
return createErrorResult(error, response, execute);
|
|
99
|
+
}
|
|
100
|
+
const text = await response.text();
|
|
101
|
+
let gqlBody;
|
|
102
|
+
try {
|
|
103
|
+
gqlBody = text
|
|
104
|
+
? JSON.parse(text)
|
|
105
|
+
: null;
|
|
106
|
+
}
|
|
107
|
+
catch (parseErr) {
|
|
108
|
+
const error = new ApiError({
|
|
109
|
+
kind: 'parse',
|
|
110
|
+
status: response.status,
|
|
111
|
+
statusText: response.statusText,
|
|
112
|
+
body: parseErr,
|
|
113
|
+
headers: response.headers,
|
|
114
|
+
request: { method: 'POST', url: ctx.request.url, params: variables },
|
|
115
|
+
});
|
|
116
|
+
return createErrorResult(error, response, execute);
|
|
117
|
+
}
|
|
118
|
+
if (gqlBody?.errors?.length) {
|
|
119
|
+
const error = new ApiError({
|
|
120
|
+
status: response.status,
|
|
121
|
+
kind: 'http',
|
|
122
|
+
statusText: 'GraphQL Error',
|
|
123
|
+
body: gqlBody.errors,
|
|
124
|
+
headers: response.headers,
|
|
125
|
+
request: { method: 'POST', url: ctx.request.url, params: variables },
|
|
126
|
+
partialData: gqlBody.data ?? undefined,
|
|
127
|
+
});
|
|
128
|
+
return createErrorResult(error, response, execute);
|
|
129
|
+
}
|
|
130
|
+
if (gqlBody?.data == null) {
|
|
131
|
+
const error = new ApiError({
|
|
132
|
+
kind: 'parse',
|
|
133
|
+
status: response.status,
|
|
134
|
+
statusText: response.statusText,
|
|
135
|
+
body: text,
|
|
136
|
+
headers: response.headers,
|
|
137
|
+
request: { method: 'POST', url: ctx.request.url, params: variables },
|
|
138
|
+
});
|
|
139
|
+
return createErrorResult(error, response, execute);
|
|
140
|
+
}
|
|
141
|
+
if (operation.config.schema) {
|
|
142
|
+
let outcome;
|
|
143
|
+
try {
|
|
144
|
+
outcome = await runSchema(operation.config.schema, gqlBody.data);
|
|
145
|
+
}
|
|
146
|
+
catch (validatorErr) {
|
|
147
|
+
const error = new ApiError({
|
|
148
|
+
kind: 'parse',
|
|
149
|
+
status: response.status,
|
|
150
|
+
statusText: response.statusText,
|
|
151
|
+
body: validatorErr,
|
|
152
|
+
headers: response.headers,
|
|
153
|
+
request: { method: 'POST', url: ctx.request.url, params: variables }
|
|
154
|
+
});
|
|
155
|
+
return createErrorResult(error, response, execute);
|
|
156
|
+
}
|
|
157
|
+
if (!outcome.ok) {
|
|
158
|
+
const error = new ApiError({
|
|
159
|
+
kind: 'parse',
|
|
160
|
+
status: response.status,
|
|
161
|
+
statusText: response.statusText,
|
|
162
|
+
body: outcome.issues,
|
|
163
|
+
headers: response.headers,
|
|
164
|
+
request: { method: 'POST', url: ctx.request.url, params: variables }
|
|
165
|
+
});
|
|
166
|
+
return createErrorResult(error, response, execute);
|
|
167
|
+
}
|
|
168
|
+
return createSuccessResult(outcome.value, response, execute);
|
|
169
|
+
}
|
|
170
|
+
return createSuccessResult(gqlBody.data, response, execute);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
const signal = ctx.request.signal;
|
|
174
|
+
const kind = signal?.aborted === true
|
|
175
|
+
? (abortKind(signal.reason) ?? 'abort')
|
|
176
|
+
: (abortKind(err) ?? 'network');
|
|
177
|
+
const error = new ApiError({
|
|
178
|
+
status: 0,
|
|
179
|
+
kind,
|
|
180
|
+
statusText: '',
|
|
181
|
+
body: err,
|
|
182
|
+
headers: new Headers(),
|
|
183
|
+
request: { method: 'POST', url: ctx.request.url, params: variables },
|
|
184
|
+
});
|
|
185
|
+
return createNetworkErrorResult(error, execute);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const headers = mergeHeaders(globalHeaders, operation.config.headers, options.headers);
|
|
189
|
+
if (!headers.has('Content-Type')) {
|
|
190
|
+
headers.set('Content-Type', 'application/json');
|
|
191
|
+
}
|
|
192
|
+
const body = JSON.stringify({ query: operation.config.operation, variables });
|
|
193
|
+
const context = {
|
|
194
|
+
request: {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
url: endpoint,
|
|
197
|
+
path: endpoint,
|
|
198
|
+
params: variables,
|
|
199
|
+
headers,
|
|
200
|
+
body,
|
|
201
|
+
signal: callerSignal,
|
|
202
|
+
},
|
|
203
|
+
requestName: name,
|
|
204
|
+
};
|
|
205
|
+
const backstop = createBackstop(signal => buildFailedResult(signal.reason, signal, 'abort'));
|
|
206
|
+
backstop.watch(callerSignal);
|
|
207
|
+
const composed = composeMiddleware(allMiddleware, backstop.guard(core), options.skipMiddleware ?? []);
|
|
208
|
+
let resultPromise;
|
|
209
|
+
try {
|
|
210
|
+
resultPromise = composed(context).catch((err) => buildFailedResult(err, context.request.signal, 'middleware'));
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
resultPromise = Promise.resolve(buildFailedResult(err, context.request.signal, 'middleware'));
|
|
214
|
+
}
|
|
215
|
+
return backstop.follow(resultPromise, (result, preempted) => {
|
|
216
|
+
if (operation.config.dedupe && dedupeController) {
|
|
217
|
+
if (preempted)
|
|
218
|
+
dedupeController.abort();
|
|
219
|
+
dedupeTracker.clear(name, dedupeController);
|
|
220
|
+
}
|
|
221
|
+
if (result.error)
|
|
222
|
+
fireOnError(result.error);
|
|
223
|
+
return result;
|
|
224
|
+
}, (err) => {
|
|
225
|
+
const result = buildFailedResult(err, undefined, 'abort');
|
|
226
|
+
fireOnError(result.error);
|
|
227
|
+
return result;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
const error = new ApiError({
|
|
232
|
+
status: 0,
|
|
233
|
+
kind: 'network',
|
|
234
|
+
statusText: '',
|
|
235
|
+
body: err,
|
|
236
|
+
headers: new Headers(),
|
|
237
|
+
request: { method: 'POST', url: endpoint, params: variables },
|
|
238
|
+
});
|
|
239
|
+
fireOnError(error);
|
|
240
|
+
return Promise.resolve(createNetworkErrorResult(error, execute));
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
return execute();
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const allOperations = {
|
|
247
|
+
...(config.operations ?? {}),
|
|
248
|
+
...(config.queries ?? {}),
|
|
249
|
+
...(config.mutations ?? {}),
|
|
250
|
+
};
|
|
251
|
+
const flatMethods = {};
|
|
252
|
+
for (const [name, operation] of Object.entries(allOperations)) {
|
|
253
|
+
flatMethods[name] = buildMethod(name, operation);
|
|
254
|
+
}
|
|
255
|
+
if (config.operations) {
|
|
256
|
+
return flatMethods;
|
|
257
|
+
}
|
|
258
|
+
const result = {};
|
|
259
|
+
if (config.queries) {
|
|
260
|
+
result.query = {};
|
|
261
|
+
for (const name of Object.keys(config.queries)) {
|
|
262
|
+
result.query[name] = flatMethods[name];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (config.mutations) {
|
|
266
|
+
result.mutation = {};
|
|
267
|
+
for (const name of Object.keys(config.mutations)) {
|
|
268
|
+
result.mutation[name] = flatMethods[name];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return result;
|
|
272
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Factory that wires Request definitions into a typed, callable API object. */
|
|
2
|
+
export { createApi } from './create-api.js';
|
|
3
|
+
/** Typed request definition — one instance per API endpoint. */
|
|
4
|
+
export { Request } from './request.js';
|
|
5
|
+
/** Typed factory — infers path params from the `path` literal. */
|
|
6
|
+
export { defineRequest } from './define-request.js';
|
|
7
|
+
/** Walks a paginated endpoint, yielding one Result per page. */
|
|
8
|
+
export { paginate } from './paginate.js';
|
|
9
|
+
/** Options for `paginate` — `next`, `maxPages`, and any CallOptions. */
|
|
10
|
+
export type { PaginateOptions } from './paginate.js';
|
|
11
|
+
/** Structured error with status, body, headers, and request context. */
|
|
12
|
+
export { ApiError } from './result.js';
|
|
13
|
+
export type { RequestConfig, ApiConfig, CallOptions, Result, SuccessResult, ErrorResult, Middleware, MiddlewareContext, MiddlewareNext, ApiErrorKind, StandardSchemaV1, InferOutput, StandardIssue } from './types.js';
|
|
14
|
+
/** Factory and primitives for building a typed GraphQL client. */
|
|
15
|
+
export { createGraphQL, Operation, gql } from './graphql.js';
|
|
16
|
+
export type { GraphQLError, OperationConfig, GraphQLBaseConfig } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createApi } from './create-api.js';
|
|
2
|
+
export { Request } from './request.js';
|
|
3
|
+
export { defineRequest } from './define-request.js';
|
|
4
|
+
export { paginate } from './paginate.js';
|
|
5
|
+
export { ApiError } from './result.js';
|
|
6
|
+
export { createGraphQL, Operation, gql } from './graphql.js';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Middleware, MiddlewareContext, Result } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Signature for the "core" function — the innermost layer of the onion.
|
|
4
|
+
*
|
|
5
|
+
* This is the function that actually performs the HTTP fetch. It receives
|
|
6
|
+
* the fully-prepared {@link MiddlewareContext} (with all middleware
|
|
7
|
+
* modifications applied) and returns a {@link Result}.
|
|
8
|
+
*
|
|
9
|
+
* The core function is NOT a middleware — it does not receive a `next`
|
|
10
|
+
* parameter because there is nothing after it. It is the terminal point
|
|
11
|
+
* of the middleware chain.
|
|
12
|
+
*
|
|
13
|
+
* @param context - The middleware context containing the prepared request.
|
|
14
|
+
* @returns A promise resolving to the Result of the HTTP call.
|
|
15
|
+
*/
|
|
16
|
+
export type CoreFn = (context: MiddlewareContext) => Promise<Result<unknown>>;
|
|
17
|
+
/**
|
|
18
|
+
* Composes an array of middleware into a single executable function using
|
|
19
|
+
* the onion model.
|
|
20
|
+
*
|
|
21
|
+
* **How it works:**
|
|
22
|
+
*
|
|
23
|
+
* Given middleware `[A, B, C]` and a core function, calling the composed
|
|
24
|
+
* function produces this execution order:
|
|
25
|
+
*
|
|
26
|
+
* ```
|
|
27
|
+
* A "before" logic
|
|
28
|
+
* B "before" logic
|
|
29
|
+
* C "before" logic
|
|
30
|
+
* core(context) ← actual fetch happens here
|
|
31
|
+
* C "after" logic
|
|
32
|
+
* B "after" logic
|
|
33
|
+
* A "after" logic
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* **Skip filtering:**
|
|
37
|
+
*
|
|
38
|
+
* The `skip` parameter accepts an array of middleware references. Before
|
|
39
|
+
* composing, any middleware whose reference (===) matches an entry in `skip`
|
|
40
|
+
* is removed from the chain. This enables per-call overrides like:
|
|
41
|
+
*
|
|
42
|
+
* ```ts
|
|
43
|
+
* api.getUser({ id: '1' }, { skipMiddleware: [cacheMiddleware] })
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* **No double-call guard:**
|
|
47
|
+
*
|
|
48
|
+
* Unlike some middleware engines (e.g., Koa), this implementation does NOT
|
|
49
|
+
* prevent a middleware from calling `next()` more than once. This is
|
|
50
|
+
* intentional — retry middleware needs to call `next()` sequentially in a
|
|
51
|
+
* loop to re-execute the downstream chain. Each `next()` call creates a
|
|
52
|
+
* fresh traversal from the current position onward.
|
|
53
|
+
*
|
|
54
|
+
* @param middleware - Array of middleware functions to compose. Order matters:
|
|
55
|
+
* the first middleware in the array is the outermost layer (runs first on
|
|
56
|
+
* the way "in" and last on the way "out").
|
|
57
|
+
* @param core - The innermost function that performs the actual HTTP fetch.
|
|
58
|
+
* This is called when the last middleware in the chain calls `next()`.
|
|
59
|
+
* @param skip - Optional array of middleware references to exclude from the
|
|
60
|
+
* chain. Comparison is by reference identity (===), not by value.
|
|
61
|
+
* @returns A function that takes a {@link MiddlewareContext} and returns a
|
|
62
|
+
* `Promise<Result<unknown>>`. This is the fully composed pipeline ready
|
|
63
|
+
* to be executed.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* // Compose global + per-request middleware with a fetch core
|
|
68
|
+
* const pipeline = composeMiddleware(
|
|
69
|
+
* [authMiddleware, logMiddleware, cacheMiddleware],
|
|
70
|
+
* fetchCore,
|
|
71
|
+
* [cacheMiddleware], // skip cache for this call
|
|
72
|
+
* )
|
|
73
|
+
*
|
|
74
|
+
* const result = await pipeline(context)
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export declare function composeMiddleware(middleware: Middleware[], core: CoreFn, skip?: Middleware[]): (context: MiddlewareContext) => Promise<Result<unknown>>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function composeMiddleware(middleware, core, skip = []) {
|
|
2
|
+
const active = middleware.filter(mw => !skip.includes(mw));
|
|
3
|
+
return (context) => {
|
|
4
|
+
const dispatch = (i) => {
|
|
5
|
+
if (i >= active.length)
|
|
6
|
+
return core(context);
|
|
7
|
+
const mw = active[i];
|
|
8
|
+
return mw(context, () => dispatch(i + 1));
|
|
9
|
+
};
|
|
10
|
+
return dispatch(0);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CallOptions, Result, SuccessResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* How to continue, and when to stop.
|
|
4
|
+
*
|
|
5
|
+
* @typeParam TParams - The endpoint's params type.
|
|
6
|
+
* @typeParam TResponse - The endpoint's success data type.
|
|
7
|
+
*/
|
|
8
|
+
export interface PaginateOptions<TParams extends object, TResponse> extends CallOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Builds the params for the next page, or returns nullish to stop.
|
|
11
|
+
*
|
|
12
|
+
* Returns **params**, not a cursor. A cursor would leave this function
|
|
13
|
+
* deciding where to put it — `cursor`? `page_token`? `after`? — which is a
|
|
14
|
+
* convention, and a config option per API in existence is what `buildUrl`'s
|
|
15
|
+
* refusal to guess a nested-query-string format already rejected.
|
|
16
|
+
*
|
|
17
|
+
* The previous params arrive as the second argument so the common case is a
|
|
18
|
+
* spread:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* next: (page, prev) => page.data.cursor
|
|
22
|
+
* ? { ...prev, cursor: page.data.cursor }
|
|
23
|
+
* : undefined
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Only ever called with a page that succeeded — an error page has no data to
|
|
27
|
+
* read a cursor from, and ends the walk.
|
|
28
|
+
*
|
|
29
|
+
* It is the caller's own function, invoked inside their own `for await`, so a
|
|
30
|
+
* throw here propagates to them with their own stack rather than becoming a
|
|
31
|
+
* `Result`. That is not a hole in "never throws": that rule is about request
|
|
32
|
+
* failures, and this is a bug in the caller's callback. Converting it would
|
|
33
|
+
* need an error kind that fits nothing, and would hide the stack that
|
|
34
|
+
* identifies it.
|
|
35
|
+
*/
|
|
36
|
+
next: (page: SuccessResult<TResponse>, params: TParams) => TParams | undefined | null;
|
|
37
|
+
/**
|
|
38
|
+
* The most pages to fetch. Omitted means unbounded.
|
|
39
|
+
*
|
|
40
|
+
* There is no default, deliberately: this library cannot justify a number —
|
|
41
|
+
* why 1000 and not 100? — and a silent truncation at an invented ceiling is
|
|
42
|
+
* indistinguishable from "no more pages", which is a worse failure than the
|
|
43
|
+
* runaway it would prevent.
|
|
44
|
+
*
|
|
45
|
+
* `0` means zero pages, not "unset". A caller who needs to know they hit the
|
|
46
|
+
* cap counts pages themselves; they are already writing the loop body.
|
|
47
|
+
*/
|
|
48
|
+
maxPages?: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Walks a paginated endpoint, yielding one `Result` per page.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* for await (const page of paginate(api.listItems, { limit: 50 }, {
|
|
56
|
+
* next: (p, prev) => p.data.cursor ? { ...prev, cursor: p.data.cursor } : undefined,
|
|
57
|
+
* })) {
|
|
58
|
+
* if (page.error) break
|
|
59
|
+
* render(page.data.items)
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* Yields `Result`s rather than unwrapping them, because that is the shape every
|
|
64
|
+
* other entry point returns — a paging loop should not invent a second
|
|
65
|
+
* convention for failure.
|
|
66
|
+
*
|
|
67
|
+
* @param endpoint - Any `createApi` method, or any function of that shape.
|
|
68
|
+
* @param params - The params for the first page.
|
|
69
|
+
* @param options - `next`, plus any `CallOptions` to apply to every request.
|
|
70
|
+
*/
|
|
71
|
+
export declare function paginate<TParams extends object, TResponse>(endpoint: (params: TParams, options?: CallOptions) => Promise<Result<TResponse>>, params: TParams, options: PaginateOptions<TParams, TResponse>): AsyncGenerator<Result<TResponse>, void, undefined>;
|
package/dist/paginate.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export async function* paginate(endpoint, params, options) {
|
|
2
|
+
const { next, maxPages, ...callOptions } = options;
|
|
3
|
+
let current = params;
|
|
4
|
+
let fetched = 0;
|
|
5
|
+
while (current !== undefined && current !== null) {
|
|
6
|
+
if (maxPages !== undefined && fetched >= maxPages)
|
|
7
|
+
return;
|
|
8
|
+
const page = await endpoint(current, callOptions);
|
|
9
|
+
fetched++;
|
|
10
|
+
yield page;
|
|
11
|
+
if (page.error !== null)
|
|
12
|
+
return;
|
|
13
|
+
current = next(page, current);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { RequestConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Typed request definition. One instance per API endpoint.
|
|
4
|
+
*
|
|
5
|
+
* The `Request` class is a **typed config container** — it does **not** execute
|
|
6
|
+
* HTTP requests. Execution is handled by the API client returned from `createApi`.
|
|
7
|
+
* This class stores the "recipe" for how a particular endpoint should be called:
|
|
8
|
+
* which HTTP method, what path template, which middleware to apply, what headers
|
|
9
|
+
* to include, and how to serialize parameters.
|
|
10
|
+
*
|
|
11
|
+
* The two type parameters are the key to the library's type safety:
|
|
12
|
+
* - `TParams` describes what the caller must pass when invoking this endpoint.
|
|
13
|
+
* - `TResponse` describes what the caller receives back on success.
|
|
14
|
+
*
|
|
15
|
+
* When passed to `createApi`, these generics flow through to produce a fully
|
|
16
|
+
* typed API method: `api.getUser({ id: '42' })` → `Promise<Result<User>>`.
|
|
17
|
+
*
|
|
18
|
+
* @typeParam TParams - The shape of the params object the caller must provide.
|
|
19
|
+
* This includes both path parameters (e.g., `:id` segments) and any query
|
|
20
|
+
* string or body parameters. Path params are extracted and substituted
|
|
21
|
+
* automatically; the rest are serialized according to the method or `bodyAs`.
|
|
22
|
+
*
|
|
23
|
+
* @typeParam TResponse - The shape of the successful response data. This
|
|
24
|
+
* becomes the type of `result.data` when the API call succeeds.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* // Define a GET endpoint that takes an `id` path param and returns a User
|
|
29
|
+
* const getUser = new Request<{ id: string }, User>({
|
|
30
|
+
* method: 'GET',
|
|
31
|
+
* path: '/users/:id',
|
|
32
|
+
* })
|
|
33
|
+
*
|
|
34
|
+
* // Define a POST endpoint with middleware and custom headers
|
|
35
|
+
* const createUser = new Request<{ name: string; email: string }, User>({
|
|
36
|
+
* method: 'POST',
|
|
37
|
+
* path: '/users',
|
|
38
|
+
* middleware: [validationMiddleware],
|
|
39
|
+
* headers: { 'X-Idempotency-Key': crypto.randomUUID() },
|
|
40
|
+
* })
|
|
41
|
+
*
|
|
42
|
+
* // Define a DELETE endpoint that sends a JSON body (overriding the default
|
|
43
|
+
* // query string behavior for DELETE)
|
|
44
|
+
* const bulkDelete = new Request<{ ids: string[] }, { deleted: number }>({
|
|
45
|
+
* method: 'DELETE',
|
|
46
|
+
* path: '/items',
|
|
47
|
+
* bodyAs: 'body',
|
|
48
|
+
* })
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export declare class Request<TParams extends object, TResponse> {
|
|
52
|
+
/**
|
|
53
|
+
* The immutable configuration for this endpoint.
|
|
54
|
+
*
|
|
55
|
+
* Contains the HTTP method, URL path template, optional middleware,
|
|
56
|
+
* headers, response parsing strategy, deduplication flag, and body
|
|
57
|
+
* serialization override.
|
|
58
|
+
*
|
|
59
|
+
* This property is `readonly` because request definitions are meant to be
|
|
60
|
+
* created once and reused — they describe an endpoint, not a single call.
|
|
61
|
+
* Per-call customization (extra headers, abort signals, etc.) is handled
|
|
62
|
+
* via `CallOptions` at invocation time, not by mutating this config.
|
|
63
|
+
*
|
|
64
|
+
* @see {@link RequestConfig} for the full shape of the config object.
|
|
65
|
+
*/
|
|
66
|
+
readonly config: RequestConfig;
|
|
67
|
+
/**
|
|
68
|
+
* Phantom fields — never assigned, never read at runtime.
|
|
69
|
+
*
|
|
70
|
+
* Without them the class body never references TParams or TResponse, so
|
|
71
|
+
* TypeScript treats every instantiation as structurally identical and
|
|
72
|
+
* `Request<{ id }, User>` silently accepts a `Request<{ slug }, Post>`.
|
|
73
|
+
*
|
|
74
|
+
* `declare` emits no property, so this costs zero runtime bytes.
|
|
75
|
+
* `Operation` in graphql.ts uses the same technique.
|
|
76
|
+
*/
|
|
77
|
+
readonly _params: TParams;
|
|
78
|
+
readonly _response: TResponse;
|
|
79
|
+
/**
|
|
80
|
+
* Creates a new Request instance with the given endpoint configuration.
|
|
81
|
+
*
|
|
82
|
+
* The constructor simply stores the config — no validation or side effects.
|
|
83
|
+
* The config is used later by `createApi` when the endpoint is actually called.
|
|
84
|
+
*
|
|
85
|
+
* @param config - The endpoint configuration describing the HTTP method,
|
|
86
|
+
* path template, middleware, headers, and serialization behavior.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* const listItems = new Request<{ page: number; limit: number }, Item[]>({
|
|
91
|
+
* method: 'GET',
|
|
92
|
+
* path: '/items',
|
|
93
|
+
* dedupe: true, // auto-cancel previous in-flight request
|
|
94
|
+
* })
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
constructor(config: RequestConfig);
|
|
98
|
+
/**
|
|
99
|
+
* Determines whether this request's params should be serialized as a URL
|
|
100
|
+
* query string (as opposed to a JSON request body).
|
|
101
|
+
*
|
|
102
|
+
* The logic follows standard REST conventions with an escape hatch:
|
|
103
|
+
*
|
|
104
|
+
* 1. If `bodyAs` is explicitly set on the config, that takes precedence:
|
|
105
|
+
* - `bodyAs: 'query'` → always serialize as query string (even for POST)
|
|
106
|
+
* - `bodyAs: 'body'` → always serialize as request body (even for GET/DELETE)
|
|
107
|
+
*
|
|
108
|
+
* 2. If `bodyAs` is not set, fall back to the HTTP method convention:
|
|
109
|
+
* - `GET` / `DELETE` → params go to query string (returns `true`)
|
|
110
|
+
* - `POST` / `PUT` / `PATCH` → params go to request body (returns `false`)
|
|
111
|
+
*
|
|
112
|
+
* This getter is used by the fetch layer in `createApi` to decide how to
|
|
113
|
+
* pass the caller's params to the `buildUrl` utility and whether to set
|
|
114
|
+
* a `body` on the fetch `RequestInit`.
|
|
115
|
+
*
|
|
116
|
+
* @returns `true` if params should be appended to the URL as a query string,
|
|
117
|
+
* `false` if params should be sent as a JSON request body.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* const get = new Request<{ page: number }, unknown>({ method: 'GET', path: '/items' })
|
|
122
|
+
* get.shouldSerializeAsQuery // → true (GET defaults to query)
|
|
123
|
+
*
|
|
124
|
+
* const post = new Request<{ name: string }, unknown>({ method: 'POST', path: '/items' })
|
|
125
|
+
* post.shouldSerializeAsQuery // → false (POST defaults to body)
|
|
126
|
+
*
|
|
127
|
+
* const deleteWithBody = new Request<{ ids: string[] }, unknown>({
|
|
128
|
+
* method: 'DELETE',
|
|
129
|
+
* path: '/items',
|
|
130
|
+
* bodyAs: 'body',
|
|
131
|
+
* })
|
|
132
|
+
* deleteWithBody.shouldSerializeAsQuery // → false (bodyAs overrides DELETE default)
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
get shouldSerializeAsQuery(): boolean;
|
|
136
|
+
}
|
package/dist/request.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const QUERY_METHODS = new Set(['GET', 'DELETE']);
|
|
2
|
+
export class Request {
|
|
3
|
+
constructor(config) {
|
|
4
|
+
this.config = config;
|
|
5
|
+
}
|
|
6
|
+
get shouldSerializeAsQuery() {
|
|
7
|
+
if (this.config.bodyAs) {
|
|
8
|
+
return this.config.bodyAs === 'query';
|
|
9
|
+
}
|
|
10
|
+
return QUERY_METHODS.has(this.config.method);
|
|
11
|
+
}
|
|
12
|
+
}
|