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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { ApiError, createSuccessResult, createErrorResult, createNetworkErrorResult } from './result.js';
|
|
2
|
+
import { composeMiddleware } from './middleware.js';
|
|
3
|
+
import { buildUrl, joinUrl, FragmentError } from './utils/path-params.js';
|
|
4
|
+
import { serializeBody } from './utils/serialize.js';
|
|
5
|
+
import { DedupeTracker } from './utils/dedupe.js';
|
|
6
|
+
import { ShareTracker, isAbandoned } from './utils/share.js';
|
|
7
|
+
import { mergeHeaders } from './utils/headers.js';
|
|
8
|
+
import { abortKind, propagatesReason } from './utils/abort-kind.js';
|
|
9
|
+
import { anySignal } from './utils/any-signal.js';
|
|
10
|
+
import { operationBudget, perCallerBudget } from './utils/budget.js';
|
|
11
|
+
import { stableKey } from './utils/stable-key.js';
|
|
12
|
+
import { createBackstop } from './utils/backstop.js';
|
|
13
|
+
import { isSpecialBody } from './utils/special-body.js';
|
|
14
|
+
import { runSchema } from './utils/validate.js';
|
|
15
|
+
const EMPTY_JSON_BODY = Symbol('liaise.emptyJsonBody');
|
|
16
|
+
async function parseResponse(response, responseType = 'json') {
|
|
17
|
+
switch (responseType) {
|
|
18
|
+
case 'text':
|
|
19
|
+
return response.text();
|
|
20
|
+
case 'blob':
|
|
21
|
+
return response.blob();
|
|
22
|
+
case 'arrayBuffer':
|
|
23
|
+
return response.arrayBuffer();
|
|
24
|
+
case 'formData':
|
|
25
|
+
return response.formData();
|
|
26
|
+
case 'none':
|
|
27
|
+
try {
|
|
28
|
+
await response.body?.cancel();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
case 'json':
|
|
34
|
+
default: {
|
|
35
|
+
const text = await response.text();
|
|
36
|
+
return text ? JSON.parse(text) : EMPTY_JSON_BODY;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function resolveRequestUrl(baseUrl, request, params) {
|
|
41
|
+
const asQuery = request.shouldSerializeAsQuery;
|
|
42
|
+
const paramsIsSpecialBody = isSpecialBody(params);
|
|
43
|
+
const { url, remaining } = buildUrl(baseUrl, request.config.path, paramsIsSpecialBody ? {} : params, asQuery);
|
|
44
|
+
return { url, remaining, asQuery, paramsIsSpecialBody };
|
|
45
|
+
}
|
|
46
|
+
function urlForError(baseUrl, request, params) {
|
|
47
|
+
try {
|
|
48
|
+
return resolveRequestUrl(baseUrl, request, params).url;
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err instanceof FragmentError)
|
|
52
|
+
return err.resolvedUrl;
|
|
53
|
+
return joinUrl(baseUrl, request.config.path);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function syntheticResult(reason, signal, fallbackKind, method, url, params, retry) {
|
|
57
|
+
const isOurCancellation = signal?.aborted === true &&
|
|
58
|
+
(fallbackKind !== 'middleware' || propagatesReason(reason, signal.reason));
|
|
59
|
+
const kind = isOurCancellation ? (abortKind(signal.reason) ?? 'abort') : fallbackKind;
|
|
60
|
+
const error = new ApiError({
|
|
61
|
+
kind,
|
|
62
|
+
status: 0,
|
|
63
|
+
statusText: '',
|
|
64
|
+
body: reason,
|
|
65
|
+
headers: new Headers(),
|
|
66
|
+
request: { method, url, params }
|
|
67
|
+
});
|
|
68
|
+
return createNetworkErrorResult(error, retry);
|
|
69
|
+
}
|
|
70
|
+
function isEmptyHeaders(init) {
|
|
71
|
+
if (init === undefined)
|
|
72
|
+
return true;
|
|
73
|
+
if (init instanceof Headers) {
|
|
74
|
+
let empty = true;
|
|
75
|
+
init.forEach(() => { empty = false; });
|
|
76
|
+
return empty;
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(init))
|
|
79
|
+
return init.length === 0;
|
|
80
|
+
return Object.keys(init).length === 0;
|
|
81
|
+
}
|
|
82
|
+
export function createApi(config) {
|
|
83
|
+
const { baseUrl, requests, middleware: globalMiddleware = [], headers: globalHeaders, onError } = config;
|
|
84
|
+
const fireOnError = (error) => {
|
|
85
|
+
if (error.kind === 'abort')
|
|
86
|
+
return;
|
|
87
|
+
if (!onError)
|
|
88
|
+
return;
|
|
89
|
+
try {
|
|
90
|
+
onError(error);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const dedupeTracker = new DedupeTracker();
|
|
96
|
+
const shareTracker = new ShareTracker();
|
|
97
|
+
for (const [name, request] of Object.entries(requests)) {
|
|
98
|
+
if (request.config.share && request.config.dedupe) {
|
|
99
|
+
throw new Error(`Request "${name}" sets both share and dedupe. They are opposites — ` +
|
|
100
|
+
`dedupe cancels the previous call, share joins it. Pick one.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const api = {};
|
|
104
|
+
for (const [name, request] of Object.entries(requests)) {
|
|
105
|
+
api[name] = (params = {}, options = {}) => {
|
|
106
|
+
function buildFailedResult(reason, signal, fallbackKind, url) {
|
|
107
|
+
return syntheticResult(reason, signal, fallbackKind, request.config.method, url ?? urlForError(baseUrl, request, params), params, retry);
|
|
108
|
+
}
|
|
109
|
+
function failedResult(reason, signal, fallbackKind) {
|
|
110
|
+
const result = buildFailedResult(reason, signal, fallbackKind);
|
|
111
|
+
fireOnError(result.error);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
const execute = (sharedSignal, onSettled) => {
|
|
115
|
+
try {
|
|
116
|
+
const allMiddleware = [
|
|
117
|
+
...globalMiddleware,
|
|
118
|
+
...(request.config.middleware ?? []),
|
|
119
|
+
...(options.middleware ?? [])
|
|
120
|
+
];
|
|
121
|
+
const operation = operationBudget(options.timeout, request.config.timeout, options.signal, sharedSignal !== undefined);
|
|
122
|
+
const callerSignal = sharedSignal
|
|
123
|
+
? anySignal([sharedSignal, operation])
|
|
124
|
+
: operation;
|
|
125
|
+
let dedupeController;
|
|
126
|
+
const core = async (ctx) => {
|
|
127
|
+
try {
|
|
128
|
+
if (sharedSignal && ctx.request.signal !== sharedSignal) {
|
|
129
|
+
ctx.request.signal = anySignal([ctx.request.signal, sharedSignal]);
|
|
130
|
+
}
|
|
131
|
+
if (request.config.dedupe && !dedupeController) {
|
|
132
|
+
const tracked = dedupeTracker.track(name, ctx.request.signal ?? callerSignal);
|
|
133
|
+
dedupeController = tracked.controller;
|
|
134
|
+
ctx.request.signal = tracked.signal;
|
|
135
|
+
backstop.watch(tracked.controller.signal);
|
|
136
|
+
}
|
|
137
|
+
const fetchInit = {
|
|
138
|
+
method: ctx.request.method,
|
|
139
|
+
headers: ctx.request.headers,
|
|
140
|
+
signal: ctx.request.signal
|
|
141
|
+
};
|
|
142
|
+
if (ctx.request.body !== null && ctx.request.body !== undefined) {
|
|
143
|
+
fetchInit.body = ctx.request.body;
|
|
144
|
+
}
|
|
145
|
+
const response = await fetch(ctx.request.url, fetchInit);
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
let body;
|
|
148
|
+
try {
|
|
149
|
+
const errorResponseType = request.config.responseType === 'none' ? 'json' : request.config.responseType;
|
|
150
|
+
body = await parseResponse(response, errorResponseType);
|
|
151
|
+
if (body === EMPTY_JSON_BODY)
|
|
152
|
+
body = null;
|
|
153
|
+
}
|
|
154
|
+
catch (parseErr) {
|
|
155
|
+
const signal = ctx.request.signal;
|
|
156
|
+
if (signal?.aborted === true) {
|
|
157
|
+
const error = new ApiError({
|
|
158
|
+
status: 0,
|
|
159
|
+
kind: abortKind(signal.reason) ?? 'abort',
|
|
160
|
+
statusText: '',
|
|
161
|
+
body: parseErr,
|
|
162
|
+
headers: new Headers(),
|
|
163
|
+
request: { method: ctx.request.method, url: ctx.request.url, params }
|
|
164
|
+
});
|
|
165
|
+
return createNetworkErrorResult(error, retry);
|
|
166
|
+
}
|
|
167
|
+
body = null;
|
|
168
|
+
}
|
|
169
|
+
const error = new ApiError({
|
|
170
|
+
status: response.status,
|
|
171
|
+
kind: 'http',
|
|
172
|
+
statusText: response.statusText,
|
|
173
|
+
body,
|
|
174
|
+
headers: response.headers,
|
|
175
|
+
request: { method: ctx.request.method, url: ctx.request.url, params }
|
|
176
|
+
});
|
|
177
|
+
return createErrorResult(error, response, retry);
|
|
178
|
+
}
|
|
179
|
+
let data;
|
|
180
|
+
try {
|
|
181
|
+
data = await parseResponse(response, request.config.responseType);
|
|
182
|
+
}
|
|
183
|
+
catch (parseErr) {
|
|
184
|
+
const signal = ctx.request.signal;
|
|
185
|
+
if (signal?.aborted === true) {
|
|
186
|
+
const error = new ApiError({
|
|
187
|
+
status: 0,
|
|
188
|
+
kind: abortKind(signal.reason) ?? 'abort',
|
|
189
|
+
statusText: '',
|
|
190
|
+
body: parseErr,
|
|
191
|
+
headers: new Headers(),
|
|
192
|
+
request: { method: ctx.request.method, url: ctx.request.url, params }
|
|
193
|
+
});
|
|
194
|
+
return createNetworkErrorResult(error, retry);
|
|
195
|
+
}
|
|
196
|
+
const error = new ApiError({
|
|
197
|
+
kind: 'parse',
|
|
198
|
+
status: response.status,
|
|
199
|
+
statusText: response.statusText,
|
|
200
|
+
body: parseErr,
|
|
201
|
+
headers: response.headers,
|
|
202
|
+
request: { method: ctx.request.method, url: ctx.request.url, params }
|
|
203
|
+
});
|
|
204
|
+
return createErrorResult(error, response, retry);
|
|
205
|
+
}
|
|
206
|
+
if (data === EMPTY_JSON_BODY) {
|
|
207
|
+
const error = new ApiError({
|
|
208
|
+
kind: 'parse',
|
|
209
|
+
status: response.status,
|
|
210
|
+
statusText: response.statusText,
|
|
211
|
+
body: '',
|
|
212
|
+
headers: response.headers,
|
|
213
|
+
request: { method: ctx.request.method, url: ctx.request.url, params }
|
|
214
|
+
});
|
|
215
|
+
return createErrorResult(error, response, retry);
|
|
216
|
+
}
|
|
217
|
+
if (request.config.schema) {
|
|
218
|
+
let outcome;
|
|
219
|
+
try {
|
|
220
|
+
outcome = await runSchema(request.config.schema, data);
|
|
221
|
+
}
|
|
222
|
+
catch (validatorErr) {
|
|
223
|
+
const error = new ApiError({
|
|
224
|
+
kind: 'parse',
|
|
225
|
+
status: response.status,
|
|
226
|
+
statusText: response.statusText,
|
|
227
|
+
body: validatorErr,
|
|
228
|
+
headers: response.headers,
|
|
229
|
+
request: { method: ctx.request.method, url: ctx.request.url, params }
|
|
230
|
+
});
|
|
231
|
+
return createErrorResult(error, response, retry);
|
|
232
|
+
}
|
|
233
|
+
if (!outcome.ok) {
|
|
234
|
+
const error = new ApiError({
|
|
235
|
+
kind: 'parse',
|
|
236
|
+
status: response.status,
|
|
237
|
+
statusText: response.statusText,
|
|
238
|
+
body: outcome.issues,
|
|
239
|
+
headers: response.headers,
|
|
240
|
+
request: { method: ctx.request.method, url: ctx.request.url, params }
|
|
241
|
+
});
|
|
242
|
+
return createErrorResult(error, response, retry);
|
|
243
|
+
}
|
|
244
|
+
data = outcome.value;
|
|
245
|
+
}
|
|
246
|
+
return createSuccessResult(data, response, retry);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
const signal = ctx.request.signal;
|
|
250
|
+
const kind = signal?.aborted === true
|
|
251
|
+
? (abortKind(signal.reason) ?? 'abort')
|
|
252
|
+
: (abortKind(err) ?? 'network');
|
|
253
|
+
const error = new ApiError({
|
|
254
|
+
status: 0,
|
|
255
|
+
kind,
|
|
256
|
+
statusText: '',
|
|
257
|
+
body: err,
|
|
258
|
+
headers: new Headers(),
|
|
259
|
+
request: { method: ctx.request.method, url: ctx.request.url, params }
|
|
260
|
+
});
|
|
261
|
+
return createNetworkErrorResult(error, retry);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const { url, remaining, asQuery, paramsIsSpecialBody } = resolveRequestUrl(baseUrl, request, params);
|
|
265
|
+
const headers = mergeHeaders(globalHeaders, request.config.headers, options.headers);
|
|
266
|
+
let body = null;
|
|
267
|
+
if (!asQuery) {
|
|
268
|
+
const toSerialize = paramsIsSpecialBody ? params : remaining;
|
|
269
|
+
if (paramsIsSpecialBody || Object.keys(remaining).length > 0) {
|
|
270
|
+
const serialized = serializeBody(toSerialize);
|
|
271
|
+
body = serialized.body;
|
|
272
|
+
if (serialized.contentType && !headers.has('Content-Type')) {
|
|
273
|
+
headers.set('Content-Type', serialized.contentType);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const context = {
|
|
278
|
+
request: {
|
|
279
|
+
method: request.config.method,
|
|
280
|
+
url,
|
|
281
|
+
path: request.config.path,
|
|
282
|
+
params,
|
|
283
|
+
headers,
|
|
284
|
+
body,
|
|
285
|
+
signal: callerSignal
|
|
286
|
+
},
|
|
287
|
+
requestName: name
|
|
288
|
+
};
|
|
289
|
+
const backstop = createBackstop(signal => buildFailedResult(signal.reason, signal, 'abort', context.request.url));
|
|
290
|
+
backstop.watch(callerSignal);
|
|
291
|
+
const composed = composeMiddleware(allMiddleware, backstop.guard(core), options.skipMiddleware ?? []);
|
|
292
|
+
let resultPromise;
|
|
293
|
+
try {
|
|
294
|
+
resultPromise = composed(context).catch((err) => buildFailedResult(err, context.request.signal, 'middleware', context.request.url));
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
resultPromise = Promise.resolve(buildFailedResult(err, context.request.signal, 'middleware', context.request.url));
|
|
298
|
+
}
|
|
299
|
+
return backstop.follow(resultPromise, (result, preempted) => {
|
|
300
|
+
if (typeof onSettled === 'function') {
|
|
301
|
+
try {
|
|
302
|
+
onSettled();
|
|
303
|
+
}
|
|
304
|
+
catch { }
|
|
305
|
+
}
|
|
306
|
+
if (request.config.dedupe && dedupeController) {
|
|
307
|
+
if (preempted)
|
|
308
|
+
dedupeController.abort();
|
|
309
|
+
dedupeTracker.clear(name, dedupeController);
|
|
310
|
+
}
|
|
311
|
+
const abandoned = sharedSignal?.aborted === true && isAbandoned(sharedSignal.reason);
|
|
312
|
+
if (result.error && !abandoned)
|
|
313
|
+
fireOnError(result.error);
|
|
314
|
+
return result;
|
|
315
|
+
}, (err) => failedResult(err, undefined, 'abort'));
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
return Promise.resolve(failedResult(err, undefined, 'network'));
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
function retry() {
|
|
322
|
+
return execute();
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
const key = request.config.share === true &&
|
|
326
|
+
isEmptyHeaders(options.headers) &&
|
|
327
|
+
(options.middleware === undefined || options.middleware.length === 0)
|
|
328
|
+
? stableKey(params)
|
|
329
|
+
: null;
|
|
330
|
+
if (key === null)
|
|
331
|
+
return execute();
|
|
332
|
+
const shareKey = `${name}|${key}`;
|
|
333
|
+
const perCaller = perCallerBudget(options.timeout, options.signal);
|
|
334
|
+
const { promise, release, hasSettled } = shareTracker.acquire(shareKey, (signal, markSettled) => execute(signal, markSettled));
|
|
335
|
+
if (!perCaller)
|
|
336
|
+
return promise.then(r => r, (err) => failedResult(err, undefined, 'network'));
|
|
337
|
+
return new Promise(resolve => {
|
|
338
|
+
let done = false;
|
|
339
|
+
const finish = (r) => {
|
|
340
|
+
if (done)
|
|
341
|
+
return;
|
|
342
|
+
done = true;
|
|
343
|
+
perCaller.removeEventListener('abort', onAbort);
|
|
344
|
+
resolve(r);
|
|
345
|
+
};
|
|
346
|
+
promise.then(r => finish(r), (err) => {
|
|
347
|
+
if (done)
|
|
348
|
+
return;
|
|
349
|
+
finish(failedResult(err, undefined, 'network'));
|
|
350
|
+
});
|
|
351
|
+
const onAbort = () => {
|
|
352
|
+
release();
|
|
353
|
+
const result = buildFailedResult(perCaller.reason, perCaller, 'abort');
|
|
354
|
+
if (!hasSettled())
|
|
355
|
+
fireOnError(result.error);
|
|
356
|
+
finish(result);
|
|
357
|
+
};
|
|
358
|
+
if (perCaller.aborted)
|
|
359
|
+
onAbort();
|
|
360
|
+
else
|
|
361
|
+
perCaller.addEventListener('abort', onAbort, { once: true });
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
return Promise.resolve(failedResult(err, undefined, 'network'));
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return api;
|
|
370
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Request } from './request.js';
|
|
2
|
+
import type { RequestConfig, ResponseType, StandardSchemaV1, InferOutput } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The characters a path token name may contain.
|
|
5
|
+
*
|
|
6
|
+
* This list is not arbitrary and must not be "simplified": it mirrors
|
|
7
|
+
* `buildUrl`'s substitution pattern, `:${key}(?=[^a-zA-Z0-9_]|$)`
|
|
8
|
+
* (src/utils/path-params.ts). If the two ever disagree, the types describe a
|
|
9
|
+
* URL the runtime does not build.
|
|
10
|
+
*/
|
|
11
|
+
type WordChar = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z' | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '_';
|
|
12
|
+
/**
|
|
13
|
+
* Consumes the leading word characters of `S` — a token name — and stops at the
|
|
14
|
+
* first character that is not one, exactly where `buildUrl`'s lookahead stops.
|
|
15
|
+
*
|
|
16
|
+
* Character-at-a-time rather than splitting on '/', because a token does not
|
|
17
|
+
* have to end at a slash: `/users/:id.json` substitutes `:id` and leaves
|
|
18
|
+
* `.json`, and `/search/:q?x=1` substitutes `:q`.
|
|
19
|
+
*/
|
|
20
|
+
type TakeName<S extends string, Acc extends string = ''> = S extends `${infer C}${infer Rest}` ? C extends WordChar ? TakeName<Rest, `${Acc}${C}`> : Acc : Acc;
|
|
21
|
+
/**
|
|
22
|
+
* The params a path template requires, as `{ token: string | number }`.
|
|
23
|
+
*
|
|
24
|
+
* `string | number` because `buildUrl` does `encodeURIComponent(String(value))`
|
|
25
|
+
* — a numeric id is as valid as a string one. An object is rejected, which is
|
|
26
|
+
* right: `String({})` is `"[object Object]"`, a silently wrong URL.
|
|
27
|
+
*
|
|
28
|
+
* A path with no tokens gives `{}`, and `Record<string, never> extends {}`
|
|
29
|
+
* holds, so `ApiMethod`'s optional-params branch still fires and `api.health()`
|
|
30
|
+
* stays callable with no arguments.
|
|
31
|
+
*
|
|
32
|
+
* A token must BEGIN a path segment, mirroring `buildUrl`'s Phase 1b
|
|
33
|
+
* (src/utils/path-params.ts, search "A token must BEGIN a path segment"):
|
|
34
|
+
* matching is anchored to `/:` rather than a bare `:`, so a colon that appears
|
|
35
|
+
* mid-segment — a Google-style custom method like `/v1/documents:batchGet`,
|
|
36
|
+
* or a time like `/events/at/12:30` — is never mistaken for a token. Both of
|
|
37
|
+
* those paths parse to `{}`, not `{ batchGet: ... }` or `{ '30': ... }`.
|
|
38
|
+
*
|
|
39
|
+
* A missing leading slash is normalised before anchoring. `buildUrl` requires a
|
|
40
|
+
* token to sit at index 0 of a `split('/')` segment, which means "preceded by
|
|
41
|
+
* `/`, **or at the very start of the string**" — and segment 0 always starts at
|
|
42
|
+
* index 0. Prepending `/` when it is absent makes this type's `/:` requirement
|
|
43
|
+
* exactly equivalent to that condition, and is a no-op for a path that already
|
|
44
|
+
* has one.
|
|
45
|
+
*
|
|
46
|
+
* 4.1.0 shipped the anchor without the normalisation and so under-inferred a
|
|
47
|
+
* path whose token *starts the string*: `':id'` inferred nothing while the
|
|
48
|
+
* request still demanded `id`, failing at runtime. A path like `'users/:id'`
|
|
49
|
+
* was never affected — it contains a literal `/:`.
|
|
50
|
+
*
|
|
51
|
+
* Exported for `tests/types.test-d.ts`. Not re-exported from `src/index.ts`, so
|
|
52
|
+
* it is not public API — see index.ts's note on what is deliberately withheld.
|
|
53
|
+
*/
|
|
54
|
+
type Anchored<P extends string> = P extends `${string}/:${infer Tail}` ? TakeName<Tail> extends infer Name extends string ? Name extends '' ? Anchored<Tail> : {
|
|
55
|
+
[K in Name]: string | number;
|
|
56
|
+
} & (Tail extends `${Name}${infer Rest}` ? Anchored<Rest> : {}) : {} : {};
|
|
57
|
+
export type PathParams<P extends string> = Anchored<P extends `/${string}` ? P : `/${P}`>;
|
|
58
|
+
/**
|
|
59
|
+
* Flattens an intersection into a single object type.
|
|
60
|
+
*
|
|
61
|
+
* Presentational, but load-bearing for the feature's value: without it, hovering
|
|
62
|
+
* an endpoint in an editor shows `PathParams<'/orgs/:org/repos'> & { page?:
|
|
63
|
+
* number }` instead of `{ org: string | number; page?: number }`.
|
|
64
|
+
*/
|
|
65
|
+
export type Id<T> = {
|
|
66
|
+
[K in keyof T]: T[K];
|
|
67
|
+
} & {};
|
|
68
|
+
/**
|
|
69
|
+
* Rejects `responseType: 'none'` when `TResponse` is a real type.
|
|
70
|
+
*
|
|
71
|
+
* `'none'` means the endpoint sends no body on success and `data` is
|
|
72
|
+
* `undefined` at runtime. Declaring a response type alongside it is a
|
|
73
|
+
* contradiction that was, until now, only a documented convention.
|
|
74
|
+
*
|
|
75
|
+
* Fires only on the LITERAL `'none'`. A config whose `responseType` is the
|
|
76
|
+
* widened `ResponseType | undefined` — a `RequestConfig`-typed variable, or a
|
|
77
|
+
* spread of one — infers as the whole union, which is not `'none'`, and passes
|
|
78
|
+
* through untouched. That matters: constraining the config parameter directly
|
|
79
|
+
* rejects both of those legitimate forms, which is exactly the permissiveness
|
|
80
|
+
* problem recorded in `ResponseType`'s JSDoc in types.ts.
|
|
81
|
+
*
|
|
82
|
+
* The other side of "literal only" is `TResponse`, not `TRT`: the check is
|
|
83
|
+
* `undefined extends TResponse`, so any `TResponse` that ADMITS `undefined` —
|
|
84
|
+
* `User | undefined`, `any`, `unknown` — satisfies the guard and is not
|
|
85
|
+
* rejected, same as the exact type `undefined`. Only a `TResponse` that
|
|
86
|
+
* excludes `undefined` entirely trips it.
|
|
87
|
+
*
|
|
88
|
+
* The failure branch uses a non-colliding marker property, `__emptyBodyMismatch`,
|
|
89
|
+
* rather than reusing `responseType`. Reusing `responseType` collapses that
|
|
90
|
+
* property's type to `never` via intersection with the existing `responseType?:
|
|
91
|
+
* TRT`, which TypeScript then reports as a per-property mismatch on unrelated
|
|
92
|
+
* fields like `method`, never mentioning the message. `__emptyBodyMismatch`
|
|
93
|
+
* collides with nothing a real config has, so TypeScript instead reports it as
|
|
94
|
+
* a missing required property, and the sentence rides along in that message.
|
|
95
|
+
*/
|
|
96
|
+
type EmptyBodyGuard<TRT, TResponse> = [
|
|
97
|
+
TRT
|
|
98
|
+
] extends ['none'] ? undefined extends TResponse ? unknown : {
|
|
99
|
+
__emptyBodyMismatch: 'declare TResponse as undefined when responseType is none';
|
|
100
|
+
} : unknown;
|
|
101
|
+
/**
|
|
102
|
+
* Rejects a `path` literal containing a URL fragment.
|
|
103
|
+
*
|
|
104
|
+
* `fetch` never transmits a fragment, so one in a request path cannot do
|
|
105
|
+
* anything the caller intended — and before 4.2.1 it silently ate the query
|
|
106
|
+
* string as well. 4.2.1 made `buildUrl` refuse it at runtime, which left the
|
|
107
|
+
* type accepting what the runtime rejects: `defineRequest()({ path:
|
|
108
|
+
* '/docs#section' })` compiled, and then failed on every call. That divergence
|
|
109
|
+
* is the defect this library exists to prevent (§2.4 closed the same gap in the
|
|
110
|
+
* other direction), so the guard closes it at the call site.
|
|
111
|
+
*
|
|
112
|
+
* **Fires only on a literal.** `string extends `${string}#${string}`` is false,
|
|
113
|
+
* so a `RequestConfig`-typed variable, a spread of one, or any path computed at
|
|
114
|
+
* runtime still compiles. That permissiveness is not an oversight — it is the
|
|
115
|
+
* constraint that sank the 3.1.0 guard attempt, and `EmptyBodyGuard` carries it
|
|
116
|
+
* for the same reason. A config assembled at runtime cannot be checked at
|
|
117
|
+
* compile time, and refusing it would make the factory unusable for that shape.
|
|
118
|
+
*
|
|
119
|
+
* `new Request` gets no equivalent: it takes no path literal, so there is
|
|
120
|
+
* nothing for a guard to read. That is the asymmetry §3.4 already accepted, and
|
|
121
|
+
* it is why the factory exists at all.
|
|
122
|
+
*
|
|
123
|
+
* The marker property follows `EmptyBodyGuard`'s reasoning exactly — reusing
|
|
124
|
+
* `path` would intersect to `never` and make TypeScript report a mismatch on
|
|
125
|
+
* unrelated fields, never showing the sentence. `__fragmentInPath` collides
|
|
126
|
+
* with nothing a real config has, so it is reported as a missing required
|
|
127
|
+
* property and the message rides along.
|
|
128
|
+
*/
|
|
129
|
+
type FragmentGuard<TPath> = TPath extends `${string}#${string}` ? {
|
|
130
|
+
__fragmentInPath: 'a URL fragment is never sent to the server — remove the # and everything after it';
|
|
131
|
+
} : unknown;
|
|
132
|
+
/**
|
|
133
|
+
* The response type a schema supplies, or `unknown` when it supplies none.
|
|
134
|
+
*
|
|
135
|
+
* "Supplies none" covers two cases that must be treated identically: `TSchema`
|
|
136
|
+
* is literally `undefined` (no `schema` was written), and `TSchema` is present
|
|
137
|
+
* but its OUTPUT is `unknown` or `any` (a widened `StandardSchemaV1<unknown>`,
|
|
138
|
+
* which is what a bare `RequestConfig`-typed variable's `schema` field infers
|
|
139
|
+
* as — see `SchemaConflictGuard`). Both collapse to `unknown` here, which is
|
|
140
|
+
* what lets `SchemaConflictGuard` and `defineRequest`'s return type ask the
|
|
141
|
+
* SAME question ("did a schema meaningfully narrow the response?") and get the
|
|
142
|
+
* same answer. They used to ask it separately and disagreed: the guard read
|
|
143
|
+
* the schema's OUTPUT (`unknown extends InferOutput<TSchema>`) while the
|
|
144
|
+
* return type read `TSchema`'s presence directly (`[TSchema] extends
|
|
145
|
+
* [undefined]`), so a widened config passed the guard (its output read as "not
|
|
146
|
+
* given") but the return type's presence check still saw `TSchema` and took
|
|
147
|
+
* the `InferOutput` branch, producing `unknown` — silently discarding an
|
|
148
|
+
* explicit `TResponse`. One shared rule, asked once.
|
|
149
|
+
*/
|
|
150
|
+
type SchemaOut<TSchema> = [TSchema] extends [undefined] ? unknown : InferOutput<TSchema>;
|
|
151
|
+
/**
|
|
152
|
+
* Refuses a schema and an explicit response type together.
|
|
153
|
+
*
|
|
154
|
+
* Two sources of truth that can disagree is a mistake, and this project makes a
|
|
155
|
+
* contradiction unrepresentable rather than picking a winner silently — the same
|
|
156
|
+
* reasoning as the `share` + `dedupe` throw and the empty-body guard.
|
|
157
|
+
*
|
|
158
|
+
* It fires even when the two AGREE, because the failure it prevents is temporal:
|
|
159
|
+
* the schema changes, the explicit type is now wrong, and nothing reports it
|
|
160
|
+
* because it was never being read.
|
|
161
|
+
*
|
|
162
|
+
* `unknown extends TResponse` is true only when no explicit argument was given,
|
|
163
|
+
* which is what makes "was one supplied?" answerable at all. `any` satisfies it
|
|
164
|
+
* too and counts as "not given" — acceptable, since `defineRequest<any>()` is
|
|
165
|
+
* already opting out of everything.
|
|
166
|
+
*
|
|
167
|
+
* The "was a schema given?" side reads `SchemaOut<TSchema>` — the schema's
|
|
168
|
+
* OUTPUT — rather than asking whether `TSchema` itself is `undefined`, and the
|
|
169
|
+
* config field feeding it is `Omit<RequestConfig, 'schema'> & { schema?:
|
|
170
|
+
* TSchema }` rather than `RequestConfig & { schema?: TSchema }`. Both changes
|
|
171
|
+
* exist for the same reason: `RequestConfig` already declares `schema?:
|
|
172
|
+
* StandardSchemaV1<unknown>` (for `new Request`), so a config typed as plain
|
|
173
|
+
* `RequestConfig` — or a spread of one — structurally carries that field
|
|
174
|
+
* whether or not a schema was actually written. Left in the intersection, that
|
|
175
|
+
* field becomes a second, competing inference source for `TSchema`, and —
|
|
176
|
+
* only when `TSchema` also appears inside a conditional in the SAME parameter,
|
|
177
|
+
* which is what a "was one given?" guard requires — TypeScript stops
|
|
178
|
+
* decomposing a non-literal argument (a bare `RequestConfig`-typed variable)
|
|
179
|
+
* property by property and falls back to `TSchema`'s default instead, which
|
|
180
|
+
* then disagrees with the very shape it just failed to read. `Omit` removes
|
|
181
|
+
* the competing source; reading the OUTPUT instead of `TSchema` itself
|
|
182
|
+
* survives whatever that source infers anyway, because an unknown-output
|
|
183
|
+
* schema (the widened `StandardSchemaV1<unknown>`, same as a plain
|
|
184
|
+
* `undefined`) reads as "not given" right alongside the real absence.
|
|
185
|
+
*
|
|
186
|
+
* A schema carrying no type information — an OUTPUT of `unknown` or `any` —
|
|
187
|
+
* is treated as no schema at all: the explicit response type is HONOURED, and
|
|
188
|
+
* no conflict is raised. `defineRequest<User>()({ ..., schema:
|
|
189
|
+
* looselyTypedSchema })` compiles and its `data` is `User`, not `unknown`.
|
|
190
|
+
* This is not a gap being tolerated; it is what makes a widened
|
|
191
|
+
* `RequestConfig`-typed config work at all — a spread of one carries exactly
|
|
192
|
+
* `schema?: StandardSchemaV1<unknown>`, inherited from `RequestConfig` itself,
|
|
193
|
+
* and a schema written directly can be just as loosely typed. A schema that
|
|
194
|
+
* DOES carry type information — a concrete `Output` — supplies the response
|
|
195
|
+
* type instead (see `defineRequest`'s return-type expression), and an
|
|
196
|
+
* explicit type alongside THAT is the conflict this guard exists to catch.
|
|
197
|
+
*
|
|
198
|
+
* This falls out of `SchemaOut` for free, and only BECAUSE the guard and
|
|
199
|
+
* `defineRequest`'s return type read it identically. That took two attempts:
|
|
200
|
+
* moving the guard onto `SchemaOut` (the OUTPUT paragraph above) fixed the
|
|
201
|
+
* guard's own over-rejection of widened configs, but the return type was left
|
|
202
|
+
* reading `[TSchema] extends [undefined]` directly — the schema's PRESENCE,
|
|
203
|
+
* not its output — so a widened config now passed the guard while the return
|
|
204
|
+
* type still discarded its `TResponse`. Same mistake as the original
|
|
205
|
+
* over-rejection, just relocated: two formulas answering one question. Both
|
|
206
|
+
* disappeared once both sites read `SchemaOut`. Do not let them drift apart
|
|
207
|
+
* again: change `SchemaConflictGuard` and `defineRequest`'s return-type
|
|
208
|
+
* expression together, and reverify against `tests/types.test-d.ts`'s
|
|
209
|
+
* widened-config assertions, which check the resulting `data` type via
|
|
210
|
+
* `createApi` rather than merely that the call compiles — editing one site
|
|
211
|
+
* without the other, unverified, is exactly how this regressed.
|
|
212
|
+
*/
|
|
213
|
+
type SchemaConflictGuard<TSchema, TResponse> = unknown extends SchemaOut<TSchema> ? unknown : unknown extends TResponse ? unknown : {
|
|
214
|
+
__schemaSuppliesTheType: 'omit the explicit response type when passing a schema';
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Declares an endpoint, inferring its path parameters from the `path` literal.
|
|
218
|
+
*
|
|
219
|
+
* Curried because TypeScript has no partial type-argument inference: supplying
|
|
220
|
+
* `TResponse` and the config in ONE call makes `TPath` fall back to its
|
|
221
|
+
* constraint, `PathParams<string>` resolve to `{}`, and `{}` accepts anything —
|
|
222
|
+
* the feature disappears with no error anywhere, which is precisely how the
|
|
223
|
+
* 3.1.0 guard attempt failed. Two calls keep the response type explicit and the
|
|
224
|
+
* path inferred.
|
|
225
|
+
*
|
|
226
|
+
* `TExtra` is for params the path does not name — query or body fields.
|
|
227
|
+
*
|
|
228
|
+
* Returns an ordinary `Request`, so `createApi` needs no knowledge of this
|
|
229
|
+
* function: its `ExtractParams`/`ExtractResponse` already match structurally.
|
|
230
|
+
* `new Request(...)` remains and is not deprecated; this is a second way to
|
|
231
|
+
* construct, which is what makes it additive.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```ts
|
|
235
|
+
* const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
|
|
236
|
+
* const listRepos = defineRequest<Repo[], { page?: number }>()({
|
|
237
|
+
* method: 'GET',
|
|
238
|
+
* path: '/orgs/:org/repos',
|
|
239
|
+
* })
|
|
240
|
+
*
|
|
241
|
+
* api.getUser({ id: '42' }) // ✓
|
|
242
|
+
* api.getUser({ userId: '42' }) // ✗ compile error
|
|
243
|
+
* api.listRepos({ org: 'acme', page: 2 }) // ✓
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
export declare function defineRequest<TResponse = unknown, TExtra extends object = {}>(): <TPath extends string, TRT extends ResponseType | undefined, TSchema extends StandardSchemaV1<any> | undefined = undefined>(config: Omit<RequestConfig, "schema"> & {
|
|
247
|
+
path: TPath;
|
|
248
|
+
responseType?: TRT;
|
|
249
|
+
schema?: TSchema;
|
|
250
|
+
} & EmptyBodyGuard<TRT, TResponse> & SchemaConflictGuard<TSchema, TResponse> & FragmentGuard<TPath>) => Request<Id<PathParams<TPath> & TExtra>, unknown extends SchemaOut<TSchema> ? TResponse : SchemaOut<TSchema>>;
|
|
251
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CallOptions, Result, GraphQLBaseConfig, OperationConfig } from './types.js';
|
|
2
|
+
export declare class Operation<TVariables extends object, TData> {
|
|
3
|
+
readonly config: OperationConfig;
|
|
4
|
+
readonly _variables: TVariables;
|
|
5
|
+
readonly _data: TData;
|
|
6
|
+
constructor(config: OperationConfig);
|
|
7
|
+
}
|
|
8
|
+
export declare const gql: (strings: TemplateStringsArray, ...values: unknown[]) => string;
|
|
9
|
+
type GraphQLMethod<TVariables extends object, TData> = Record<string, never> extends TVariables ? (variables?: TVariables, options?: CallOptions) => Promise<Result<TData>> : (variables: TVariables, options?: CallOptions) => Promise<Result<TData>>;
|
|
10
|
+
type FlatClient<TOperations> = {
|
|
11
|
+
[K in keyof TOperations]: TOperations[K] extends Operation<infer V, infer D> ? GraphQLMethod<V, D> : never;
|
|
12
|
+
};
|
|
13
|
+
type SplitClient<TQ, TM> = (TQ extends Record<string, Operation<any, any>> ? {
|
|
14
|
+
query: FlatClient<TQ>;
|
|
15
|
+
} : {}) & (TM extends Record<string, Operation<any, any>> ? {
|
|
16
|
+
mutation: FlatClient<TM>;
|
|
17
|
+
} : {});
|
|
18
|
+
type WithOperations<T> = GraphQLBaseConfig & {
|
|
19
|
+
operations: T;
|
|
20
|
+
queries?: never;
|
|
21
|
+
mutations?: never;
|
|
22
|
+
};
|
|
23
|
+
type WithSplit<TQ, TM> = GraphQLBaseConfig & {
|
|
24
|
+
operations?: never;
|
|
25
|
+
queries?: TQ;
|
|
26
|
+
mutations?: TM;
|
|
27
|
+
};
|
|
28
|
+
export declare function createGraphQL<T extends Record<string, Operation<any, any>>>(config: WithOperations<T>): FlatClient<T>;
|
|
29
|
+
export declare function createGraphQL<TQ extends Record<string, Operation<any, any>>, TM extends Record<string, Operation<any, any>>>(config: WithSplit<TQ, TM>): SplitClient<TQ, TM>;
|
|
30
|
+
export {};
|