@ontrails/http 1.0.0-beta.17 → 1.0.0-beta.19
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 +45 -0
- package/README.md +48 -13
- package/package.json +21 -2
- package/src/build.ts +93 -9
- package/src/bun.ts +270 -0
- package/src/fetch.ts +602 -0
- package/src/index.ts +6 -0
- package/src/method.ts +11 -0
- package/src/testing.ts +339 -0
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CancelledError,
|
|
3
|
+
isTrailsError,
|
|
4
|
+
NotFoundError,
|
|
5
|
+
projectErrorDiagnostics,
|
|
6
|
+
projectPublicSurfaceError,
|
|
7
|
+
ValidationError,
|
|
8
|
+
} from '@ontrails/core';
|
|
9
|
+
import type { Topo } from '@ontrails/core';
|
|
10
|
+
|
|
11
|
+
import { deriveHttpRoutes } from './build.js';
|
|
12
|
+
import type { DeriveHttpRoutesOptions, HttpRouteDefinition } from './build.js';
|
|
13
|
+
|
|
14
|
+
export interface CreateRouteHandlerOptions {
|
|
15
|
+
/** Maximum JSON request body size in bytes. Defaults to 1 MiB. */
|
|
16
|
+
readonly maxJsonBodyBytes?: number | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CreateFetchHandlerOptions
|
|
20
|
+
extends DeriveHttpRoutesOptions, CreateRouteHandlerOptions {}
|
|
21
|
+
|
|
22
|
+
interface RuntimeOptions {
|
|
23
|
+
readonly maxJsonBodyBytes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface JsonObject {
|
|
27
|
+
readonly [key: string]: JsonValue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type JsonValue =
|
|
31
|
+
| null
|
|
32
|
+
| boolean
|
|
33
|
+
| number
|
|
34
|
+
| string
|
|
35
|
+
| readonly JsonValue[]
|
|
36
|
+
| JsonObject;
|
|
37
|
+
type JsonBodyReadResult =
|
|
38
|
+
| JsonValue
|
|
39
|
+
| typeof JSON_BODY_INVALID_CONTENT_LENGTH
|
|
40
|
+
| typeof JSON_BODY_TOO_LARGE
|
|
41
|
+
| typeof JSON_PARSE_ERROR;
|
|
42
|
+
type JsonBodyTextReadResult = string | typeof JSON_BODY_TOO_LARGE;
|
|
43
|
+
type InputReadResult = Record<string, unknown> | JsonBodyReadResult;
|
|
44
|
+
type ParsedContentLength =
|
|
45
|
+
| number
|
|
46
|
+
| typeof JSON_BODY_INVALID_CONTENT_LENGTH
|
|
47
|
+
| undefined;
|
|
48
|
+
|
|
49
|
+
const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
|
|
50
|
+
const CONTENT_LENGTH_DECIMAL_PATTERN = /^\d+$/;
|
|
51
|
+
|
|
52
|
+
const JSON_PARSE_ERROR = Symbol('JSON_PARSE_ERROR');
|
|
53
|
+
const JSON_BODY_TOO_LARGE = Symbol('JSON_BODY_TOO_LARGE');
|
|
54
|
+
const JSON_BODY_INVALID_CONTENT_LENGTH = Symbol(
|
|
55
|
+
'JSON_BODY_INVALID_CONTENT_LENGTH'
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const LOG_UNSAFE_LABEL_CHARACTERS = /[^\w:.-]/g;
|
|
59
|
+
const MAX_DIAGNOSTIC_LABEL_VALUE_LENGTH = 128;
|
|
60
|
+
|
|
61
|
+
const routeKey = (method: string, path: string): `${string} ${string}` =>
|
|
62
|
+
`${method.toUpperCase()} ${path}`;
|
|
63
|
+
|
|
64
|
+
const parseQueryParams = (request: Request): Record<string, unknown> => {
|
|
65
|
+
const result: Record<string, unknown> = {};
|
|
66
|
+
const url = new URL(request.url);
|
|
67
|
+
const seenKeys = new Set<string>();
|
|
68
|
+
|
|
69
|
+
for (const key of url.searchParams.keys()) {
|
|
70
|
+
if (seenKeys.has(key)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
seenKeys.add(key);
|
|
74
|
+
const all = url.searchParams.getAll(key);
|
|
75
|
+
result[key] = all.length > 1 ? all : all[0];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const parseContentLength = (
|
|
82
|
+
contentLength: string | null | undefined
|
|
83
|
+
): ParsedContentLength => {
|
|
84
|
+
if (contentLength === null || contentLength === undefined) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
if (!CONTENT_LENGTH_DECIMAL_PATTERN.test(contentLength)) {
|
|
88
|
+
return JSON_BODY_INVALID_CONTENT_LENGTH;
|
|
89
|
+
}
|
|
90
|
+
const size = Number(contentLength);
|
|
91
|
+
return Number.isSafeInteger(size) ? size : Number.MAX_SAFE_INTEGER;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const isEmptyBody = (request: Request): boolean => {
|
|
95
|
+
const contentLength = parseContentLength(
|
|
96
|
+
request.headers.get('Content-Length')
|
|
97
|
+
);
|
|
98
|
+
if (contentLength === JSON_BODY_INVALID_CONTENT_LENGTH) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
if (contentLength !== undefined) {
|
|
102
|
+
return contentLength === 0;
|
|
103
|
+
}
|
|
104
|
+
return request.headers.get('Content-Type') === null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const resolveMaxJsonBodyBytes = (value: number | undefined): number => {
|
|
108
|
+
const maxJsonBodyBytes = value ?? DEFAULT_MAX_JSON_BODY_BYTES;
|
|
109
|
+
|
|
110
|
+
if (!Number.isFinite(maxJsonBodyBytes) || maxJsonBodyBytes < 1) {
|
|
111
|
+
throw new ValidationError(
|
|
112
|
+
'maxJsonBodyBytes must be a positive finite number'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return maxJsonBodyBytes;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const hasOversizedContentLength = (
|
|
120
|
+
request: Request,
|
|
121
|
+
maxJsonBodyBytes: number
|
|
122
|
+
): boolean => {
|
|
123
|
+
const contentLength = request.headers.get('Content-Length');
|
|
124
|
+
if (contentLength === null) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const size = parseContentLength(contentLength);
|
|
128
|
+
if (size === JSON_BODY_INVALID_CONTENT_LENGTH) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return size !== undefined && size > maxJsonBodyBytes;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const measureBodyTextBytes = (text: string): number => new Blob([text]).size;
|
|
135
|
+
|
|
136
|
+
const validateBodyText = (
|
|
137
|
+
text: string,
|
|
138
|
+
maxJsonBodyBytes: number
|
|
139
|
+
): JsonBodyTextReadResult =>
|
|
140
|
+
measureBodyTextBytes(text) > maxJsonBodyBytes ? JSON_BODY_TOO_LARGE : text;
|
|
141
|
+
|
|
142
|
+
const cancelBodyReader = async (
|
|
143
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
144
|
+
reason?: unknown
|
|
145
|
+
): Promise<void> => {
|
|
146
|
+
try {
|
|
147
|
+
await reader.cancel(reason);
|
|
148
|
+
} catch {
|
|
149
|
+
// The request is already being cancelled; preserve the surface-level
|
|
150
|
+
// cancelled response instead of replacing it with a reader cleanup error.
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const assertRequestNotAborted = async (
|
|
155
|
+
request: Request,
|
|
156
|
+
reader: ReadableStreamDefaultReader<Uint8Array>
|
|
157
|
+
): Promise<void> => {
|
|
158
|
+
if (!request.signal.aborted) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await cancelBodyReader(reader, request.signal.reason);
|
|
162
|
+
throw new CancelledError('Request aborted');
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const readBodyText = async (
|
|
166
|
+
request: Request,
|
|
167
|
+
maxJsonBodyBytes: number
|
|
168
|
+
): Promise<JsonBodyTextReadResult> => {
|
|
169
|
+
const { body } = request;
|
|
170
|
+
if (body === null) {
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const reader = body.getReader();
|
|
175
|
+
const chunks: Uint8Array[] = [];
|
|
176
|
+
let totalBytes = 0;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
while (true) {
|
|
180
|
+
await assertRequestNotAborted(request, reader);
|
|
181
|
+
let read: Awaited<ReturnType<typeof reader.read>>;
|
|
182
|
+
try {
|
|
183
|
+
read = await reader.read();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
await assertRequestNotAborted(request, reader);
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
await assertRequestNotAborted(request, reader);
|
|
189
|
+
const { done, value } = read;
|
|
190
|
+
if (done) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
if (value === undefined) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
totalBytes += value.byteLength;
|
|
197
|
+
if (totalBytes > maxJsonBodyBytes) {
|
|
198
|
+
await cancelBodyReader(reader);
|
|
199
|
+
return JSON_BODY_TOO_LARGE;
|
|
200
|
+
}
|
|
201
|
+
chunks.push(value);
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
reader.releaseLock();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const bytes = new Uint8Array(totalBytes);
|
|
208
|
+
let offset = 0;
|
|
209
|
+
for (const chunk of chunks) {
|
|
210
|
+
bytes.set(chunk, offset);
|
|
211
|
+
offset += chunk.byteLength;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return new TextDecoder().decode(bytes);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const readJsonBody = async (
|
|
218
|
+
request: Request,
|
|
219
|
+
maxJsonBodyBytes: number
|
|
220
|
+
): Promise<JsonBodyReadResult> => {
|
|
221
|
+
if (
|
|
222
|
+
parseContentLength(request.headers.get('Content-Length')) ===
|
|
223
|
+
JSON_BODY_INVALID_CONTENT_LENGTH
|
|
224
|
+
) {
|
|
225
|
+
return JSON_BODY_INVALID_CONTENT_LENGTH;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (hasOversizedContentLength(request, maxJsonBodyBytes)) {
|
|
229
|
+
return JSON_BODY_TOO_LARGE;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const text = await readBodyText(request, maxJsonBodyBytes);
|
|
233
|
+
if (text === JSON_BODY_TOO_LARGE) {
|
|
234
|
+
return JSON_BODY_TOO_LARGE;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const validated = validateBodyText(text, maxJsonBodyBytes);
|
|
238
|
+
if (validated === JSON_BODY_TOO_LARGE) {
|
|
239
|
+
return JSON_BODY_TOO_LARGE;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
return JSON.parse(validated) as JsonValue;
|
|
244
|
+
} catch {
|
|
245
|
+
return JSON_PARSE_ERROR;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const parseJsonBodyText = (text: string): JsonBodyReadResult => {
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(text) as JsonValue;
|
|
252
|
+
} catch {
|
|
253
|
+
return JSON_PARSE_ERROR;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const parseWebhookBodyText = (
|
|
258
|
+
request: Request,
|
|
259
|
+
text: string
|
|
260
|
+
): JsonBodyReadResult =>
|
|
261
|
+
isEmptyBody(request) || text.length === 0 ? {} : parseJsonBodyText(text);
|
|
262
|
+
|
|
263
|
+
const readWebhookBodyText = async (
|
|
264
|
+
request: Request,
|
|
265
|
+
maxJsonBodyBytes: number
|
|
266
|
+
): Promise<
|
|
267
|
+
string | typeof JSON_BODY_INVALID_CONTENT_LENGTH | typeof JSON_BODY_TOO_LARGE
|
|
268
|
+
> => {
|
|
269
|
+
if (
|
|
270
|
+
parseContentLength(request.headers.get('Content-Length')) ===
|
|
271
|
+
JSON_BODY_INVALID_CONTENT_LENGTH
|
|
272
|
+
) {
|
|
273
|
+
return JSON_BODY_INVALID_CONTENT_LENGTH;
|
|
274
|
+
}
|
|
275
|
+
if (hasOversizedContentLength(request, maxJsonBodyBytes)) {
|
|
276
|
+
return JSON_BODY_TOO_LARGE;
|
|
277
|
+
}
|
|
278
|
+
return await readBodyText(request, maxJsonBodyBytes);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const readInput = async (
|
|
282
|
+
request: Request,
|
|
283
|
+
inputSource: 'body' | 'query',
|
|
284
|
+
options: RuntimeOptions
|
|
285
|
+
): Promise<InputReadResult> => {
|
|
286
|
+
if (inputSource === 'query') {
|
|
287
|
+
return parseQueryParams(request);
|
|
288
|
+
}
|
|
289
|
+
if (isEmptyBody(request)) {
|
|
290
|
+
return {};
|
|
291
|
+
}
|
|
292
|
+
return await readJsonBody(request, options.maxJsonBodyBytes);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const json = (body: Record<string, unknown>, status: number): Response =>
|
|
296
|
+
Response.json(body, { status });
|
|
297
|
+
|
|
298
|
+
const mapErrorResponse = (error: Error): Response => {
|
|
299
|
+
const projection = projectPublicSurfaceError('http', error);
|
|
300
|
+
return json(
|
|
301
|
+
{
|
|
302
|
+
error: {
|
|
303
|
+
category: projection.category,
|
|
304
|
+
code: projection.name,
|
|
305
|
+
message: projection.message,
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
projection.code
|
|
309
|
+
);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const sanitizeDiagnosticLabelValue = (value: string): string =>
|
|
313
|
+
value
|
|
314
|
+
.replace(LOG_UNSAFE_LABEL_CHARACTERS, '_')
|
|
315
|
+
.slice(0, MAX_DIAGNOSTIC_LABEL_VALUE_LENGTH);
|
|
316
|
+
|
|
317
|
+
const reportInternalDiagnostics = (error: Error, request: Request): void => {
|
|
318
|
+
if (isTrailsError(error)) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const requestId = request.headers.get('X-Request-ID') ?? undefined;
|
|
323
|
+
const safeRequestId =
|
|
324
|
+
requestId === undefined
|
|
325
|
+
? undefined
|
|
326
|
+
: sanitizeDiagnosticLabelValue(requestId);
|
|
327
|
+
const label =
|
|
328
|
+
safeRequestId === undefined
|
|
329
|
+
? '[ontrails:http/fetch] Internal error'
|
|
330
|
+
: `[ontrails:http/fetch] Internal error (${safeRequestId})`;
|
|
331
|
+
console.error(label, projectErrorDiagnostics(error));
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
interface ResultLike {
|
|
335
|
+
readonly error?: Error | undefined;
|
|
336
|
+
isOk(): boolean;
|
|
337
|
+
readonly value?: unknown;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const mapResultToResponse = (
|
|
341
|
+
result: ResultLike,
|
|
342
|
+
request: Request
|
|
343
|
+
): Response => {
|
|
344
|
+
if (result.isOk()) {
|
|
345
|
+
return json({ data: result.value }, 200);
|
|
346
|
+
}
|
|
347
|
+
const error = result.error ?? new Error('Unknown error');
|
|
348
|
+
reportInternalDiagnostics(error, request);
|
|
349
|
+
return mapErrorResponse(error);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const handleCaughtError = (error: unknown, request: Request): Response => {
|
|
353
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
354
|
+
reportInternalDiagnostics(err, request);
|
|
355
|
+
return mapErrorResponse(err);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const invalidJsonResponse = (): Response =>
|
|
359
|
+
json(
|
|
360
|
+
{
|
|
361
|
+
error: {
|
|
362
|
+
category: 'validation',
|
|
363
|
+
code: 'ValidationError',
|
|
364
|
+
message: 'Invalid JSON in request body',
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
400
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const invalidContentLengthResponse = (): Response =>
|
|
371
|
+
json(
|
|
372
|
+
{
|
|
373
|
+
error: {
|
|
374
|
+
category: 'validation',
|
|
375
|
+
code: 'ValidationError',
|
|
376
|
+
message: 'Invalid Content-Length header',
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
400
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const oversizedJsonBodyResponse = (options: RuntimeOptions): Response =>
|
|
383
|
+
json(
|
|
384
|
+
{
|
|
385
|
+
error: {
|
|
386
|
+
category: 'validation',
|
|
387
|
+
code: 'ValidationError',
|
|
388
|
+
message: `JSON request body exceeds ${options.maxJsonBodyBytes} bytes`,
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
413
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const notFoundResponse = (request: Request): Response => {
|
|
395
|
+
const path = new URL(request.url).pathname;
|
|
396
|
+
return mapErrorResponse(new NotFoundError(`HTTP route not found: ${path}`));
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const collectHeaders = (request: Request): Record<string, string> => {
|
|
400
|
+
const headers: Record<string, string> = {};
|
|
401
|
+
for (const [key, value] of request.headers) {
|
|
402
|
+
headers[key] = value;
|
|
403
|
+
}
|
|
404
|
+
return headers;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const createWebhookVerifyRequest = (
|
|
408
|
+
request: Request,
|
|
409
|
+
body: string
|
|
410
|
+
): {
|
|
411
|
+
readonly body: string;
|
|
412
|
+
readonly headers: Record<string, string>;
|
|
413
|
+
readonly method: string;
|
|
414
|
+
readonly path: string;
|
|
415
|
+
} => ({
|
|
416
|
+
body,
|
|
417
|
+
headers: collectHeaders(request),
|
|
418
|
+
method: request.method,
|
|
419
|
+
path: new URL(request.url).pathname,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const recordInvalidWebhook = async (
|
|
423
|
+
route: HttpRouteDefinition,
|
|
424
|
+
errorCategory = 'validation'
|
|
425
|
+
): Promise<void> => {
|
|
426
|
+
await route.recordWebhookInvalid?.(errorCategory);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const errorCategoryForWebhookFailure = (error: Error | undefined): string =>
|
|
430
|
+
error !== undefined && isTrailsError(error) ? error.category : 'internal';
|
|
431
|
+
|
|
432
|
+
const handleWebhookRoute = async (
|
|
433
|
+
route: HttpRouteDefinition,
|
|
434
|
+
options: RuntimeOptions,
|
|
435
|
+
request: Request
|
|
436
|
+
): Promise<Response> => {
|
|
437
|
+
const rawBody = await readWebhookBodyText(request, options.maxJsonBodyBytes);
|
|
438
|
+
|
|
439
|
+
if (rawBody === JSON_BODY_INVALID_CONTENT_LENGTH) {
|
|
440
|
+
await recordInvalidWebhook(route);
|
|
441
|
+
return invalidContentLengthResponse();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (rawBody === JSON_BODY_TOO_LARGE) {
|
|
445
|
+
await recordInvalidWebhook(route);
|
|
446
|
+
return oversizedJsonBodyResponse(options);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const verified = await route.verifyWebhook?.(
|
|
450
|
+
createWebhookVerifyRequest(request, rawBody)
|
|
451
|
+
);
|
|
452
|
+
if (verified?.isErr()) {
|
|
453
|
+
await recordInvalidWebhook(
|
|
454
|
+
route,
|
|
455
|
+
errorCategoryForWebhookFailure(verified.error)
|
|
456
|
+
);
|
|
457
|
+
return mapResultToResponse(verified, request);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const jsonBody = parseWebhookBodyText(request, rawBody);
|
|
461
|
+
if (jsonBody === JSON_PARSE_ERROR) {
|
|
462
|
+
await recordInvalidWebhook(route);
|
|
463
|
+
return invalidJsonResponse();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const parsed = route.parseWebhookInput?.(jsonBody);
|
|
467
|
+
if (parsed === undefined) {
|
|
468
|
+
await recordInvalidWebhook(route, 'internal');
|
|
469
|
+
return mapResultToResponse(
|
|
470
|
+
{
|
|
471
|
+
error: new Error('Webhook route is missing parse handler'),
|
|
472
|
+
isOk: () => false,
|
|
473
|
+
},
|
|
474
|
+
request
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
if (parsed.isErr()) {
|
|
478
|
+
await recordInvalidWebhook(route);
|
|
479
|
+
return mapResultToResponse(parsed, request);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const requestId = request.headers.get('X-Request-ID') ?? undefined;
|
|
483
|
+
const result = await route.execute(parsed.value, requestId, request.signal, {
|
|
484
|
+
headers: request.headers,
|
|
485
|
+
});
|
|
486
|
+
return mapResultToResponse(result, request);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Build a Web Fetch handler for one framework-agnostic HTTP route.
|
|
491
|
+
*
|
|
492
|
+
* @example
|
|
493
|
+
* ```ts
|
|
494
|
+
* import { deriveHttpRoutes } from '@ontrails/http';
|
|
495
|
+
* import { createRouteHandler } from '@ontrails/http/fetch';
|
|
496
|
+
*
|
|
497
|
+
* const routes = deriveHttpRoutes(graph, { basePath: '/api' });
|
|
498
|
+
* if (routes.isErr()) throw routes.error;
|
|
499
|
+
*
|
|
500
|
+
* const route = routes.value[0];
|
|
501
|
+
* if (!route) throw new Error('No routes derived');
|
|
502
|
+
*
|
|
503
|
+
* const handle = createRouteHandler(route);
|
|
504
|
+
* const response = await handle(new Request('https://example.test/api/hello'));
|
|
505
|
+
* ```
|
|
506
|
+
*/
|
|
507
|
+
export const createRouteHandler = (
|
|
508
|
+
route: HttpRouteDefinition,
|
|
509
|
+
options: CreateRouteHandlerOptions = {}
|
|
510
|
+
): ((request: Request) => Promise<Response>) => {
|
|
511
|
+
const runtimeOptions = {
|
|
512
|
+
maxJsonBodyBytes: resolveMaxJsonBodyBytes(options.maxJsonBodyBytes),
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
return async (request) => {
|
|
516
|
+
try {
|
|
517
|
+
if (route.inputSource === 'webhook') {
|
|
518
|
+
return await handleWebhookRoute(route, runtimeOptions, request);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const rawInput = await readInput(
|
|
522
|
+
request,
|
|
523
|
+
route.inputSource,
|
|
524
|
+
runtimeOptions
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
if (rawInput === JSON_PARSE_ERROR) {
|
|
528
|
+
return invalidJsonResponse();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (rawInput === JSON_BODY_INVALID_CONTENT_LENGTH) {
|
|
532
|
+
return invalidContentLengthResponse();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (rawInput === JSON_BODY_TOO_LARGE) {
|
|
536
|
+
return oversizedJsonBodyResponse(runtimeOptions);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const requestId = request.headers.get('X-Request-ID') ?? undefined;
|
|
540
|
+
const result = await route.execute(rawInput, requestId, request.signal, {
|
|
541
|
+
headers: request.headers,
|
|
542
|
+
});
|
|
543
|
+
return mapResultToResponse(result, request);
|
|
544
|
+
} catch (error: unknown) {
|
|
545
|
+
return handleCaughtError(error, request);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Build a Web Fetch dispatcher for all HTTP routes in a topo.
|
|
552
|
+
*
|
|
553
|
+
* @example
|
|
554
|
+
* ```ts
|
|
555
|
+
* import { createFetchHandler } from '@ontrails/http/fetch';
|
|
556
|
+
*
|
|
557
|
+
* const fetch = createFetchHandler(graph, { basePath: '/api' });
|
|
558
|
+
* const response = await fetch(
|
|
559
|
+
* new Request('https://example.test/api/hello?name=Matt')
|
|
560
|
+
* );
|
|
561
|
+
* ```
|
|
562
|
+
*/
|
|
563
|
+
export const createFetchHandler = (
|
|
564
|
+
graph: Topo,
|
|
565
|
+
options: CreateFetchHandlerOptions = {}
|
|
566
|
+
): ((request: Request) => Promise<Response>) => {
|
|
567
|
+
const routesResult = deriveHttpRoutes(graph, {
|
|
568
|
+
basePath: options.basePath,
|
|
569
|
+
configValues: options.configValues,
|
|
570
|
+
createContext: options.createContext,
|
|
571
|
+
exclude: options.exclude,
|
|
572
|
+
include: options.include,
|
|
573
|
+
intent: options.intent,
|
|
574
|
+
layers: options.layers,
|
|
575
|
+
resolvePermit: options.resolvePermit,
|
|
576
|
+
resources: options.resources,
|
|
577
|
+
validate: options.validate,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
if (routesResult.isErr()) {
|
|
581
|
+
throw routesResult.error;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const routeHandlers = new Map<
|
|
585
|
+
string,
|
|
586
|
+
(request: Request) => Promise<Response>
|
|
587
|
+
>();
|
|
588
|
+
for (const route of routesResult.value) {
|
|
589
|
+
routeHandlers.set(
|
|
590
|
+
routeKey(route.method, route.path),
|
|
591
|
+
createRouteHandler(route, {
|
|
592
|
+
maxJsonBodyBytes: options.maxJsonBodyBytes,
|
|
593
|
+
})
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return async (request) => {
|
|
598
|
+
const path = new URL(request.url).pathname;
|
|
599
|
+
const handler = routeHandlers.get(routeKey(request.method, path));
|
|
600
|
+
return handler === undefined ? notFoundResponse(request) : handler(request);
|
|
601
|
+
};
|
|
602
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,12 @@ export {
|
|
|
16
16
|
httpMethodByIntent,
|
|
17
17
|
} from './method.js';
|
|
18
18
|
export type { HttpMethod, HttpOperationMethod, InputSource } from './method.js';
|
|
19
|
+
export {
|
|
20
|
+
createFetchHandler,
|
|
21
|
+
createRouteHandler,
|
|
22
|
+
type CreateFetchHandlerOptions,
|
|
23
|
+
type CreateRouteHandlerOptions,
|
|
24
|
+
} from './fetch.js';
|
|
19
25
|
|
|
20
26
|
// OpenAPI
|
|
21
27
|
export { deriveOpenApiSpec } from './openapi.js';
|
package/src/method.ts
CHANGED
|
@@ -6,6 +6,17 @@ export type HttpOperationMethod = Lowercase<HttpMethod>;
|
|
|
6
6
|
|
|
7
7
|
export type InputSource = 'query' | 'body' | 'webhook';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Owner table for projecting trail intent onto HTTP methods.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { httpMethodByIntent } from '@ontrails/http';
|
|
15
|
+
*
|
|
16
|
+
* const readMethod = httpMethodByIntent.read;
|
|
17
|
+
* // readMethod === 'GET'
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
9
20
|
export const httpMethodByIntent = {
|
|
10
21
|
destroy: 'DELETE',
|
|
11
22
|
read: 'GET',
|