@ontrails/http 0.2.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 +570 -0
- package/README.md +169 -0
- package/package.json +59 -0
- package/src/blob-output.ts +31 -0
- package/src/build.ts +1552 -0
- package/src/bun.ts +270 -0
- package/src/fetch.ts +1047 -0
- package/src/index.ts +28 -0
- package/src/method.ts +68 -0
- package/src/openapi.ts +383 -0
- package/src/query-coercion.ts +150 -0
- package/src/testing.ts +378 -0
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CancelledError,
|
|
3
|
+
isBlobRef,
|
|
4
|
+
isTrailsError,
|
|
5
|
+
matchWebhookPath,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
parseWebhookPathParams,
|
|
8
|
+
renderErrorDiagnostics,
|
|
9
|
+
renderPublicSurfaceError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
} from '@ontrails/core';
|
|
12
|
+
import type { BlobRef, Topo } from '@ontrails/core';
|
|
13
|
+
|
|
14
|
+
import { isBlobOutputSchema } from './blob-output.js';
|
|
15
|
+
import { deriveHttpRoutes, resolveHttpQueryInput } from './build.js';
|
|
16
|
+
import type { DeriveHttpRoutesOptions, HttpRouteDefinition } from './build.js';
|
|
17
|
+
|
|
18
|
+
export interface CreateRouteHandlerOptions {
|
|
19
|
+
/** Maximum JSON request body size in bytes. Defaults to 1 MiB. */
|
|
20
|
+
readonly maxJsonBodyBytes?: number | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CreateFetchHandlerOptions
|
|
24
|
+
extends DeriveHttpRoutesOptions, CreateRouteHandlerOptions {}
|
|
25
|
+
|
|
26
|
+
interface RuntimeOptions {
|
|
27
|
+
readonly maxJsonBodyBytes: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface JsonObject {
|
|
31
|
+
readonly [key: string]: JsonValue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type JsonValue =
|
|
35
|
+
| null
|
|
36
|
+
| boolean
|
|
37
|
+
| number
|
|
38
|
+
| string
|
|
39
|
+
| readonly JsonValue[]
|
|
40
|
+
| JsonObject;
|
|
41
|
+
type JsonBodyReadResult =
|
|
42
|
+
| JsonValue
|
|
43
|
+
| typeof JSON_BODY_INVALID_CONTENT_LENGTH
|
|
44
|
+
| typeof JSON_BODY_TOO_LARGE
|
|
45
|
+
| typeof JSON_PARSE_ERROR;
|
|
46
|
+
type JsonBodyTextReadResult = string | typeof JSON_BODY_TOO_LARGE;
|
|
47
|
+
type InputReadResult = Record<string, unknown> | JsonBodyReadResult;
|
|
48
|
+
type ParsedContentLength =
|
|
49
|
+
| number
|
|
50
|
+
| typeof JSON_BODY_INVALID_CONTENT_LENGTH
|
|
51
|
+
| undefined;
|
|
52
|
+
|
|
53
|
+
const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
|
|
54
|
+
const CONTENT_LENGTH_DECIMAL_PATTERN = /^\d+$/;
|
|
55
|
+
const JSON_NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
56
|
+
|
|
57
|
+
const JSON_PARSE_ERROR = Symbol('JSON_PARSE_ERROR');
|
|
58
|
+
const JSON_BODY_TOO_LARGE = Symbol('JSON_BODY_TOO_LARGE');
|
|
59
|
+
const JSON_BODY_INVALID_CONTENT_LENGTH = Symbol(
|
|
60
|
+
'JSON_BODY_INVALID_CONTENT_LENGTH'
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const LOG_UNSAFE_LABEL_CHARACTERS = /[^\w:.-]/g;
|
|
64
|
+
const MAX_DIAGNOSTIC_LABEL_VALUE_LENGTH = 128;
|
|
65
|
+
|
|
66
|
+
const routeKey = (method: string, path: string): `${string} ${string}` =>
|
|
67
|
+
`${method.toUpperCase()} ${path}`;
|
|
68
|
+
|
|
69
|
+
const isJsonSchemaObject = (
|
|
70
|
+
value: unknown
|
|
71
|
+
): value is Readonly<Record<string, unknown>> =>
|
|
72
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
73
|
+
|
|
74
|
+
const parseQueryNumber = (value: string): number | string => {
|
|
75
|
+
if (!JSON_NUMBER_PATTERN.test(value)) {
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
const parsed = Number(value);
|
|
79
|
+
return Number.isFinite(parsed) ? parsed : value;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type QueryScalarKind = 'boolean' | 'null' | 'number' | 'string';
|
|
83
|
+
|
|
84
|
+
type QueryValueConversion =
|
|
85
|
+
| { readonly kind: 'array'; readonly itemKind: QueryScalarKind }
|
|
86
|
+
| { readonly kind: 'scalar'; readonly scalarKind: QueryScalarKind };
|
|
87
|
+
|
|
88
|
+
const queryScalarKindFromConst = (
|
|
89
|
+
value: unknown
|
|
90
|
+
): QueryScalarKind | undefined => {
|
|
91
|
+
if (value === null) {
|
|
92
|
+
return 'null';
|
|
93
|
+
}
|
|
94
|
+
if (typeof value === 'number') {
|
|
95
|
+
return Number.isFinite(value) ? 'number' : undefined;
|
|
96
|
+
}
|
|
97
|
+
if (typeof value === 'boolean') {
|
|
98
|
+
return 'boolean';
|
|
99
|
+
}
|
|
100
|
+
return typeof value === 'string' ? 'string' : undefined;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const queryScalarKind = (
|
|
104
|
+
schema: Readonly<Record<string, unknown>>
|
|
105
|
+
): QueryScalarKind | undefined => {
|
|
106
|
+
const { anyOf, type } = schema;
|
|
107
|
+
if (type === 'number' || type === 'integer') {
|
|
108
|
+
return 'number';
|
|
109
|
+
}
|
|
110
|
+
if (type === 'boolean' || type === 'string' || type === 'null') {
|
|
111
|
+
return type;
|
|
112
|
+
}
|
|
113
|
+
if (Object.hasOwn(schema, 'const')) {
|
|
114
|
+
return queryScalarKindFromConst(schema['const']);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!Array.isArray(anyOf) || anyOf.length === 0) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const kinds = anyOf.map((branch) =>
|
|
121
|
+
isJsonSchemaObject(branch) ? queryScalarKind(branch) : undefined
|
|
122
|
+
);
|
|
123
|
+
if (kinds.some((kind) => kind === undefined)) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const nonNullKinds = kinds.filter((kind) => kind !== 'null');
|
|
127
|
+
const [first] = nonNullKinds;
|
|
128
|
+
return first !== undefined && nonNullKinds.every((kind) => kind === first)
|
|
129
|
+
? first
|
|
130
|
+
: undefined;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const convertQueryScalar = (
|
|
134
|
+
value: string,
|
|
135
|
+
kind: QueryScalarKind
|
|
136
|
+
): boolean | number | string => {
|
|
137
|
+
if (kind === 'number') {
|
|
138
|
+
return parseQueryNumber(value);
|
|
139
|
+
}
|
|
140
|
+
if (kind === 'boolean') {
|
|
141
|
+
if (value === 'true') {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (value === 'false') {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const queryArrayItemKind = (
|
|
152
|
+
schema: Readonly<Record<string, unknown>>
|
|
153
|
+
): QueryScalarKind | undefined => {
|
|
154
|
+
const { anyOf, items, type } = schema;
|
|
155
|
+
if (type === 'array') {
|
|
156
|
+
return isJsonSchemaObject(items) ? queryScalarKind(items) : undefined;
|
|
157
|
+
}
|
|
158
|
+
if (!Array.isArray(anyOf) || anyOf.length === 0) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
const kinds: (QueryScalarKind | undefined)[] = [];
|
|
162
|
+
for (const branch of anyOf) {
|
|
163
|
+
if (!isJsonSchemaObject(branch)) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
kinds.push(
|
|
167
|
+
queryScalarKind(branch) === 'null' ? 'null' : queryArrayItemKind(branch)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (kinds.some((kind) => kind === undefined)) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const nonNullKinds = kinds.filter((kind) => kind !== 'null');
|
|
174
|
+
const [first] = nonNullKinds;
|
|
175
|
+
return first !== undefined && nonNullKinds.every((kind) => kind === first)
|
|
176
|
+
? first
|
|
177
|
+
: undefined;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const queryValueConversion = (
|
|
181
|
+
schema: unknown
|
|
182
|
+
): QueryValueConversion | undefined => {
|
|
183
|
+
if (!isJsonSchemaObject(schema)) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
const scalarKind = queryScalarKind(schema);
|
|
187
|
+
if (scalarKind !== undefined) {
|
|
188
|
+
return { kind: 'scalar', scalarKind };
|
|
189
|
+
}
|
|
190
|
+
const itemKind = queryArrayItemKind(schema);
|
|
191
|
+
return itemKind === undefined ? undefined : { itemKind, kind: 'array' };
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const queryValueConversionsAgree = (
|
|
195
|
+
left: QueryValueConversion | undefined,
|
|
196
|
+
right: QueryValueConversion | undefined
|
|
197
|
+
): left is QueryValueConversion => {
|
|
198
|
+
if (left === undefined || right === undefined || left.kind !== right.kind) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (left.kind === 'array') {
|
|
202
|
+
return right.kind === 'array' && left.itemKind === right.itemKind;
|
|
203
|
+
}
|
|
204
|
+
return right.kind === 'scalar' && left.scalarKind === right.scalarKind;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const convertQueryValue = (
|
|
208
|
+
value: string | string[],
|
|
209
|
+
conversion: QueryValueConversion | undefined
|
|
210
|
+
): unknown => {
|
|
211
|
+
if (conversion === undefined) {
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
if (!Array.isArray(value)) {
|
|
215
|
+
return conversion.kind === 'scalar'
|
|
216
|
+
? convertQueryScalar(value, conversion.scalarKind)
|
|
217
|
+
: value;
|
|
218
|
+
}
|
|
219
|
+
if (conversion.kind !== 'array') {
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
return value.map((item) => convertQueryScalar(item, conversion.itemKind));
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const queryRequiredFields = (
|
|
226
|
+
schema: Readonly<Record<string, unknown>>
|
|
227
|
+
): readonly string[] | undefined => {
|
|
228
|
+
const { required } = schema;
|
|
229
|
+
if (required === undefined) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
return Array.isArray(required) &&
|
|
233
|
+
required.every((key) => typeof key === 'string')
|
|
234
|
+
? required
|
|
235
|
+
: undefined;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const queryObjectBranches = (
|
|
239
|
+
schema: Readonly<Record<string, unknown>>
|
|
240
|
+
): readonly Readonly<Record<string, unknown>>[] | undefined => {
|
|
241
|
+
const { anyOf, properties, type } = schema;
|
|
242
|
+
if (!Array.isArray(anyOf) || anyOf.length === 0) {
|
|
243
|
+
if (type !== 'object') {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
return isJsonSchemaObject(properties) ? [schema] : undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let sharedProperties: Readonly<Record<string, unknown>> = {};
|
|
250
|
+
if (properties !== undefined) {
|
|
251
|
+
if (!isJsonSchemaObject(properties)) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
sharedProperties = properties;
|
|
255
|
+
}
|
|
256
|
+
const sharedRequired = queryRequiredFields(schema);
|
|
257
|
+
if (sharedRequired === undefined) {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const branches: Readonly<Record<string, unknown>>[] = [];
|
|
262
|
+
for (const branch of anyOf) {
|
|
263
|
+
if (!isJsonSchemaObject(branch)) {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
if (queryScalarKind(branch) === 'null') {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const nested = queryObjectBranches(branch);
|
|
270
|
+
if (nested === undefined) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
for (const nestedBranch of nested) {
|
|
274
|
+
const nestedProperties = nestedBranch['properties'];
|
|
275
|
+
const nestedRequired = queryRequiredFields(nestedBranch);
|
|
276
|
+
if (!isJsonSchemaObject(nestedProperties) || !nestedRequired) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
const required = [...new Set([...nestedRequired, ...sharedRequired])];
|
|
280
|
+
const mergedProperties = { ...nestedProperties };
|
|
281
|
+
for (const [key, sharedProperty] of Object.entries(sharedProperties)) {
|
|
282
|
+
if (!Object.hasOwn(nestedProperties, key)) {
|
|
283
|
+
mergedProperties[key] = sharedProperty;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const nestedProperty = nestedProperties[key];
|
|
287
|
+
mergedProperties[key] = queryValueConversionsAgree(
|
|
288
|
+
queryValueConversion(nestedProperty),
|
|
289
|
+
queryValueConversion(sharedProperty)
|
|
290
|
+
)
|
|
291
|
+
? nestedProperty
|
|
292
|
+
: {};
|
|
293
|
+
}
|
|
294
|
+
branches.push({
|
|
295
|
+
...nestedBranch,
|
|
296
|
+
properties: mergedProperties,
|
|
297
|
+
...(required.length > 0 ? { required } : {}),
|
|
298
|
+
type: 'object',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return branches.length > 0 ? branches : undefined;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const branchCanAcceptPresentKeys = (
|
|
306
|
+
branch: Readonly<Record<string, unknown>>,
|
|
307
|
+
presentKeys: ReadonlySet<string>
|
|
308
|
+
): boolean => {
|
|
309
|
+
const { required } = branch;
|
|
310
|
+
return (
|
|
311
|
+
required === undefined ||
|
|
312
|
+
(Array.isArray(required) &&
|
|
313
|
+
required.every((key) => typeof key === 'string' && presentKeys.has(key)))
|
|
314
|
+
);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const queryFieldConversion = (
|
|
318
|
+
inputSchema: Readonly<Record<string, unknown>> | undefined,
|
|
319
|
+
key: string,
|
|
320
|
+
presentKeys: ReadonlySet<string>
|
|
321
|
+
): QueryValueConversion | undefined => {
|
|
322
|
+
if (inputSchema === undefined) {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
const branches = queryObjectBranches(inputSchema)?.filter((branch) =>
|
|
326
|
+
branchCanAcceptPresentKeys(branch, presentKeys)
|
|
327
|
+
);
|
|
328
|
+
if (branches === undefined || branches.length === 0) {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const conversions = branches.map((branch) => {
|
|
333
|
+
const { properties } = branch;
|
|
334
|
+
return isJsonSchemaObject(properties)
|
|
335
|
+
? queryValueConversion(properties[key])
|
|
336
|
+
: undefined;
|
|
337
|
+
});
|
|
338
|
+
const [first] = conversions;
|
|
339
|
+
return conversions.every((conversion) =>
|
|
340
|
+
queryValueConversionsAgree(first, conversion)
|
|
341
|
+
)
|
|
342
|
+
? first
|
|
343
|
+
: undefined;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const convertQueryParams = (
|
|
347
|
+
input: Readonly<Record<string, string | string[]>>,
|
|
348
|
+
inputSchema: Readonly<Record<string, unknown>> | undefined,
|
|
349
|
+
preserveRawFields: ReadonlySet<string>
|
|
350
|
+
): Record<string, unknown> => {
|
|
351
|
+
const result: Record<string, unknown> = {};
|
|
352
|
+
const presentKeys = new Set(Object.keys(input));
|
|
353
|
+
|
|
354
|
+
for (const [key, value] of Object.entries(input)) {
|
|
355
|
+
result[key] = preserveRawFields.has(key)
|
|
356
|
+
? value
|
|
357
|
+
: convertQueryValue(
|
|
358
|
+
value,
|
|
359
|
+
queryFieldConversion(inputSchema, key, presentKeys)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return result;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const parseQueryParams = (
|
|
367
|
+
request: Request
|
|
368
|
+
): Record<string, string | string[]> => {
|
|
369
|
+
const result: Record<string, string | string[]> = {};
|
|
370
|
+
const url = new URL(request.url);
|
|
371
|
+
const seenKeys = new Set<string>();
|
|
372
|
+
|
|
373
|
+
for (const key of url.searchParams.keys()) {
|
|
374
|
+
if (seenKeys.has(key)) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
seenKeys.add(key);
|
|
378
|
+
const all = url.searchParams.getAll(key);
|
|
379
|
+
const value = all.length > 1 ? all : all[0];
|
|
380
|
+
if (value !== undefined) {
|
|
381
|
+
result[key] = value;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return result;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const parseContentLength = (
|
|
389
|
+
contentLength: string | null | undefined
|
|
390
|
+
): ParsedContentLength => {
|
|
391
|
+
if (contentLength === null || contentLength === undefined) {
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
if (!CONTENT_LENGTH_DECIMAL_PATTERN.test(contentLength)) {
|
|
395
|
+
return JSON_BODY_INVALID_CONTENT_LENGTH;
|
|
396
|
+
}
|
|
397
|
+
const size = Number(contentLength);
|
|
398
|
+
return Number.isSafeInteger(size) ? size : Number.MAX_SAFE_INTEGER;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const isEmptyBody = (request: Request): boolean => {
|
|
402
|
+
const contentLength = parseContentLength(
|
|
403
|
+
request.headers.get('Content-Length')
|
|
404
|
+
);
|
|
405
|
+
if (contentLength === JSON_BODY_INVALID_CONTENT_LENGTH) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
if (contentLength !== undefined) {
|
|
409
|
+
return contentLength === 0;
|
|
410
|
+
}
|
|
411
|
+
return request.headers.get('Content-Type') === null;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const resolveMaxJsonBodyBytes = (value: number | undefined): number => {
|
|
415
|
+
const maxJsonBodyBytes = value ?? DEFAULT_MAX_JSON_BODY_BYTES;
|
|
416
|
+
|
|
417
|
+
if (!Number.isFinite(maxJsonBodyBytes) || maxJsonBodyBytes < 1) {
|
|
418
|
+
throw new ValidationError(
|
|
419
|
+
'maxJsonBodyBytes must be a positive finite number'
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return maxJsonBodyBytes;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const hasOversizedContentLength = (
|
|
427
|
+
request: Request,
|
|
428
|
+
maxJsonBodyBytes: number
|
|
429
|
+
): boolean => {
|
|
430
|
+
const contentLength = request.headers.get('Content-Length');
|
|
431
|
+
if (contentLength === null) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
const size = parseContentLength(contentLength);
|
|
435
|
+
if (size === JSON_BODY_INVALID_CONTENT_LENGTH) {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
return size !== undefined && size > maxJsonBodyBytes;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const measureBodyTextBytes = (text: string): number => new Blob([text]).size;
|
|
442
|
+
|
|
443
|
+
const validateBodyText = (
|
|
444
|
+
text: string,
|
|
445
|
+
maxJsonBodyBytes: number
|
|
446
|
+
): JsonBodyTextReadResult =>
|
|
447
|
+
measureBodyTextBytes(text) > maxJsonBodyBytes ? JSON_BODY_TOO_LARGE : text;
|
|
448
|
+
|
|
449
|
+
const cancelBodyReader = async (
|
|
450
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
451
|
+
reason?: unknown
|
|
452
|
+
): Promise<void> => {
|
|
453
|
+
try {
|
|
454
|
+
await reader.cancel(reason);
|
|
455
|
+
} catch {
|
|
456
|
+
// The request is already being cancelled; preserve the surface-level
|
|
457
|
+
// cancelled response instead of replacing it with a reader cleanup error.
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const assertRequestNotAborted = async (
|
|
462
|
+
request: Request,
|
|
463
|
+
reader: ReadableStreamDefaultReader<Uint8Array>
|
|
464
|
+
): Promise<void> => {
|
|
465
|
+
if (!request.signal.aborted) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
await cancelBodyReader(reader, request.signal.reason);
|
|
469
|
+
throw new CancelledError('Request aborted');
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const readBodyText = async (
|
|
473
|
+
request: Request,
|
|
474
|
+
maxJsonBodyBytes: number
|
|
475
|
+
): Promise<JsonBodyTextReadResult> => {
|
|
476
|
+
const { body } = request;
|
|
477
|
+
if (body === null) {
|
|
478
|
+
return '';
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const reader = body.getReader();
|
|
482
|
+
const chunks: Uint8Array[] = [];
|
|
483
|
+
let totalBytes = 0;
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
while (true) {
|
|
487
|
+
await assertRequestNotAborted(request, reader);
|
|
488
|
+
let read: Awaited<ReturnType<typeof reader.read>>;
|
|
489
|
+
try {
|
|
490
|
+
read = await reader.read();
|
|
491
|
+
} catch (error) {
|
|
492
|
+
await assertRequestNotAborted(request, reader);
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
await assertRequestNotAborted(request, reader);
|
|
496
|
+
const { done, value } = read;
|
|
497
|
+
if (done) {
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
if (value === undefined) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
totalBytes += value.byteLength;
|
|
504
|
+
if (totalBytes > maxJsonBodyBytes) {
|
|
505
|
+
await cancelBodyReader(reader);
|
|
506
|
+
return JSON_BODY_TOO_LARGE;
|
|
507
|
+
}
|
|
508
|
+
chunks.push(value);
|
|
509
|
+
}
|
|
510
|
+
} finally {
|
|
511
|
+
reader.releaseLock();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const bytes = new Uint8Array(totalBytes);
|
|
515
|
+
let offset = 0;
|
|
516
|
+
for (const chunk of chunks) {
|
|
517
|
+
bytes.set(chunk, offset);
|
|
518
|
+
offset += chunk.byteLength;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return new TextDecoder().decode(bytes);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const readJsonBody = async (
|
|
525
|
+
request: Request,
|
|
526
|
+
maxJsonBodyBytes: number
|
|
527
|
+
): Promise<JsonBodyReadResult> => {
|
|
528
|
+
if (
|
|
529
|
+
parseContentLength(request.headers.get('Content-Length')) ===
|
|
530
|
+
JSON_BODY_INVALID_CONTENT_LENGTH
|
|
531
|
+
) {
|
|
532
|
+
return JSON_BODY_INVALID_CONTENT_LENGTH;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (hasOversizedContentLength(request, maxJsonBodyBytes)) {
|
|
536
|
+
return JSON_BODY_TOO_LARGE;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const text = await readBodyText(request, maxJsonBodyBytes);
|
|
540
|
+
if (text === JSON_BODY_TOO_LARGE) {
|
|
541
|
+
return JSON_BODY_TOO_LARGE;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const validated = validateBodyText(text, maxJsonBodyBytes);
|
|
545
|
+
if (validated === JSON_BODY_TOO_LARGE) {
|
|
546
|
+
return JSON_BODY_TOO_LARGE;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
return JSON.parse(validated) as JsonValue;
|
|
551
|
+
} catch {
|
|
552
|
+
return JSON_PARSE_ERROR;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const parseJsonBodyText = (text: string): JsonBodyReadResult => {
|
|
557
|
+
try {
|
|
558
|
+
return JSON.parse(text) as JsonValue;
|
|
559
|
+
} catch {
|
|
560
|
+
return JSON_PARSE_ERROR;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const parseWebhookBodyText = (
|
|
565
|
+
request: Request,
|
|
566
|
+
text: string
|
|
567
|
+
): JsonBodyReadResult =>
|
|
568
|
+
isEmptyBody(request) || text.length === 0 ? {} : parseJsonBodyText(text);
|
|
569
|
+
|
|
570
|
+
const readWebhookBodyText = async (
|
|
571
|
+
request: Request,
|
|
572
|
+
maxJsonBodyBytes: number
|
|
573
|
+
): Promise<
|
|
574
|
+
string | typeof JSON_BODY_INVALID_CONTENT_LENGTH | typeof JSON_BODY_TOO_LARGE
|
|
575
|
+
> => {
|
|
576
|
+
if (
|
|
577
|
+
parseContentLength(request.headers.get('Content-Length')) ===
|
|
578
|
+
JSON_BODY_INVALID_CONTENT_LENGTH
|
|
579
|
+
) {
|
|
580
|
+
return JSON_BODY_INVALID_CONTENT_LENGTH;
|
|
581
|
+
}
|
|
582
|
+
if (hasOversizedContentLength(request, maxJsonBodyBytes)) {
|
|
583
|
+
return JSON_BODY_TOO_LARGE;
|
|
584
|
+
}
|
|
585
|
+
return await readBodyText(request, maxJsonBodyBytes);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const readInput = async (
|
|
589
|
+
request: Request,
|
|
590
|
+
route: HttpRouteDefinition,
|
|
591
|
+
options: RuntimeOptions
|
|
592
|
+
): Promise<InputReadResult> => {
|
|
593
|
+
if (route.inputSource === 'query') {
|
|
594
|
+
const raw = parseQueryParams(request);
|
|
595
|
+
const { inputSchema, preserveRawFields } = resolveHttpQueryInput(
|
|
596
|
+
route,
|
|
597
|
+
raw,
|
|
598
|
+
{
|
|
599
|
+
headers: request.headers,
|
|
600
|
+
}
|
|
601
|
+
);
|
|
602
|
+
return convertQueryParams(raw, inputSchema, preserveRawFields);
|
|
603
|
+
}
|
|
604
|
+
if (isEmptyBody(request)) {
|
|
605
|
+
return {};
|
|
606
|
+
}
|
|
607
|
+
return await readJsonBody(request, options.maxJsonBodyBytes);
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const json = (body: Record<string, unknown>, status: number): Response =>
|
|
611
|
+
Response.json(body, { status });
|
|
612
|
+
|
|
613
|
+
const mapErrorResponse = (error: Error): Response => {
|
|
614
|
+
const rendering = renderPublicSurfaceError('http', error);
|
|
615
|
+
return json(
|
|
616
|
+
{
|
|
617
|
+
error: {
|
|
618
|
+
category: rendering.category,
|
|
619
|
+
code: rendering.name,
|
|
620
|
+
message: rendering.message,
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
rendering.code
|
|
624
|
+
);
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const sanitizeDiagnosticLabelValue = (value: string): string =>
|
|
628
|
+
value
|
|
629
|
+
.replace(LOG_UNSAFE_LABEL_CHARACTERS, '_')
|
|
630
|
+
.slice(0, MAX_DIAGNOSTIC_LABEL_VALUE_LENGTH);
|
|
631
|
+
|
|
632
|
+
const reportInternalDiagnostics = (error: Error, request: Request): void => {
|
|
633
|
+
if (isTrailsError(error)) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const requestId = request.headers.get('X-Request-ID') ?? undefined;
|
|
638
|
+
const safeRequestId =
|
|
639
|
+
requestId === undefined
|
|
640
|
+
? undefined
|
|
641
|
+
: sanitizeDiagnosticLabelValue(requestId);
|
|
642
|
+
const label =
|
|
643
|
+
safeRequestId === undefined
|
|
644
|
+
? '[ontrails:http/fetch] Internal error'
|
|
645
|
+
: `[ontrails:http/fetch] Internal error (${safeRequestId})`;
|
|
646
|
+
console.error(label, renderErrorDiagnostics(error));
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
interface ResultLike {
|
|
650
|
+
readonly error?: Error | undefined;
|
|
651
|
+
isOk(): boolean;
|
|
652
|
+
readonly value?: unknown;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* True when the route's trail declares a BlobRef output schema — the
|
|
657
|
+
* authored fact that selects byte streaming over the JSON envelope.
|
|
658
|
+
*/
|
|
659
|
+
const rendersBlobOutput = (route: HttpRouteDefinition): boolean =>
|
|
660
|
+
isBlobOutputSchema(route.trail.output);
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Narrow blob bytes for `Response`. `BlobRef.data` is typed `Uint8Array`
|
|
664
|
+
* (ArrayBufferLike backing) while `BodyInit` wants a plain-ArrayBuffer
|
|
665
|
+
* view; blob producers construct views over plain buffers, so narrowing
|
|
666
|
+
* here avoids copying the bytes.
|
|
667
|
+
*/
|
|
668
|
+
const blobBody = (data: BlobRef['data']): BodyInit =>
|
|
669
|
+
data instanceof ReadableStream ? data : (data as Uint8Array<ArrayBuffer>);
|
|
670
|
+
|
|
671
|
+
/** Stream a BlobRef's bytes with its declared content type and length. */
|
|
672
|
+
const blobResponse = (blob: BlobRef): Response =>
|
|
673
|
+
new Response(blobBody(blob.data), {
|
|
674
|
+
headers: {
|
|
675
|
+
'Content-Length': String(blob.size),
|
|
676
|
+
'Content-Type': blob.mimeType,
|
|
677
|
+
},
|
|
678
|
+
status: 200,
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const mapResultToResponse = (
|
|
682
|
+
result: ResultLike,
|
|
683
|
+
request: Request,
|
|
684
|
+
options?: { readonly rendersBlob?: boolean }
|
|
685
|
+
): Response => {
|
|
686
|
+
if (result.isOk()) {
|
|
687
|
+
if (options?.rendersBlob === true && isBlobRef(result.value)) {
|
|
688
|
+
return blobResponse(result.value);
|
|
689
|
+
}
|
|
690
|
+
return json({ data: result.value }, 200);
|
|
691
|
+
}
|
|
692
|
+
const error = result.error ?? new Error('Unknown error');
|
|
693
|
+
reportInternalDiagnostics(error, request);
|
|
694
|
+
return mapErrorResponse(error);
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const handleCaughtError = (error: unknown, request: Request): Response => {
|
|
698
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
699
|
+
reportInternalDiagnostics(err, request);
|
|
700
|
+
return mapErrorResponse(err);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const invalidJsonResponse = (): Response =>
|
|
704
|
+
json(
|
|
705
|
+
{
|
|
706
|
+
error: {
|
|
707
|
+
category: 'validation',
|
|
708
|
+
code: 'ValidationError',
|
|
709
|
+
message: 'Invalid JSON in request body',
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
400
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
const invalidContentLengthResponse = (): Response =>
|
|
716
|
+
json(
|
|
717
|
+
{
|
|
718
|
+
error: {
|
|
719
|
+
category: 'validation',
|
|
720
|
+
code: 'ValidationError',
|
|
721
|
+
message: 'Invalid Content-Length header',
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
400
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
const oversizedJsonBodyResponse = (options: RuntimeOptions): Response =>
|
|
728
|
+
json(
|
|
729
|
+
{
|
|
730
|
+
error: {
|
|
731
|
+
category: 'validation',
|
|
732
|
+
code: 'ValidationError',
|
|
733
|
+
message: `JSON request body exceeds ${options.maxJsonBodyBytes} bytes`,
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
413
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
const notFoundResponse = (request: Request): Response => {
|
|
740
|
+
const path = new URL(request.url).pathname;
|
|
741
|
+
return mapErrorResponse(new NotFoundError(`HTTP route not found: ${path}`));
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
const collectHeaders = (request: Request): Record<string, string> => {
|
|
745
|
+
const headers: Record<string, string> = {};
|
|
746
|
+
for (const [key, value] of request.headers) {
|
|
747
|
+
headers[key] = value;
|
|
748
|
+
}
|
|
749
|
+
return headers;
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
const createWebhookVerifyRequest = (
|
|
753
|
+
request: Request,
|
|
754
|
+
body: string
|
|
755
|
+
): {
|
|
756
|
+
readonly body: string;
|
|
757
|
+
readonly headers: Record<string, string>;
|
|
758
|
+
readonly method: string;
|
|
759
|
+
readonly path: string;
|
|
760
|
+
} => ({
|
|
761
|
+
body,
|
|
762
|
+
headers: collectHeaders(request),
|
|
763
|
+
method: request.method,
|
|
764
|
+
path: new URL(request.url).pathname,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const recordInvalidWebhook = async (
|
|
768
|
+
route: HttpRouteDefinition,
|
|
769
|
+
errorCategory = 'validation'
|
|
770
|
+
): Promise<void> => {
|
|
771
|
+
await route.recordWebhookInvalid?.(errorCategory);
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const errorCategoryForWebhookFailure = (error: Error | undefined): string =>
|
|
775
|
+
error !== undefined && isTrailsError(error) ? error.category : 'internal';
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* True when the route's webhook source opts into ingress-envelope
|
|
779
|
+
* delivery: dynamic path segments, raw body, or allowlisted headers.
|
|
780
|
+
*/
|
|
781
|
+
const usesWebhookEnvelope = (route: HttpRouteDefinition): boolean => {
|
|
782
|
+
const source = route.webhookSource;
|
|
783
|
+
return (
|
|
784
|
+
source !== undefined &&
|
|
785
|
+
(source.rawBody === true ||
|
|
786
|
+
source.headers !== undefined ||
|
|
787
|
+
parseWebhookPathParams(source.path).length > 0)
|
|
788
|
+
);
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
const pickAllowlistedHeaders = (
|
|
792
|
+
headers: Headers,
|
|
793
|
+
allowlist: readonly string[]
|
|
794
|
+
): Record<string, string> => {
|
|
795
|
+
const allowed = new Set(allowlist.map((name) => name.toLowerCase()));
|
|
796
|
+
const kept: Record<string, string> = {};
|
|
797
|
+
for (const [name, value] of headers) {
|
|
798
|
+
const normalized = name.toLowerCase();
|
|
799
|
+
if (allowed.has(normalized)) {
|
|
800
|
+
kept[normalized] = value;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return kept;
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Assemble the delivered webhook value.
|
|
808
|
+
*
|
|
809
|
+
* Classic webhooks deliver the parsed JSON body directly. Envelope-mode
|
|
810
|
+
* webhooks deliver `{ ...pathParams, body?, headers?, rawBody? }` — the
|
|
811
|
+
* schema-declared boundary shape TRL-1194 lifts from the hand-mounted
|
|
812
|
+
* ingress routes.
|
|
813
|
+
*/
|
|
814
|
+
const buildWebhookDeliveredValue = (
|
|
815
|
+
route: HttpRouteDefinition,
|
|
816
|
+
request: Request,
|
|
817
|
+
rawBody: string,
|
|
818
|
+
jsonBody: unknown,
|
|
819
|
+
pathParams: Readonly<Record<string, string>> | undefined
|
|
820
|
+
): unknown => {
|
|
821
|
+
const source = route.webhookSource;
|
|
822
|
+
if (source === undefined || !usesWebhookEnvelope(route)) {
|
|
823
|
+
return jsonBody;
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
...(jsonBody === undefined ? {} : { body: jsonBody }),
|
|
827
|
+
...(source.headers === undefined
|
|
828
|
+
? {}
|
|
829
|
+
: { headers: pickAllowlistedHeaders(request.headers, source.headers) }),
|
|
830
|
+
...(source.rawBody === true ? { rawBody } : {}),
|
|
831
|
+
...pathParams,
|
|
832
|
+
};
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
const handleWebhookRoute = async (
|
|
836
|
+
route: HttpRouteDefinition,
|
|
837
|
+
options: RuntimeOptions,
|
|
838
|
+
request: Request
|
|
839
|
+
): Promise<Response> => {
|
|
840
|
+
const envelope = usesWebhookEnvelope(route);
|
|
841
|
+
// Self-derive dynamic segment values from the route's own pattern so
|
|
842
|
+
// every adapter (fetch dispatcher, Bun routes, Hono) gets pattern
|
|
843
|
+
// support without threading params through handler signatures.
|
|
844
|
+
const pathParams = envelope
|
|
845
|
+
? matchWebhookPath(route.path, new URL(request.url).pathname)
|
|
846
|
+
: undefined;
|
|
847
|
+
const rawBody = await readWebhookBodyText(request, options.maxJsonBodyBytes);
|
|
848
|
+
|
|
849
|
+
if (rawBody === JSON_BODY_INVALID_CONTENT_LENGTH) {
|
|
850
|
+
await recordInvalidWebhook(route);
|
|
851
|
+
return invalidContentLengthResponse();
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (rawBody === JSON_BODY_TOO_LARGE) {
|
|
855
|
+
await recordInvalidWebhook(route);
|
|
856
|
+
return oversizedJsonBodyResponse(options);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const verified = await route.verifyWebhook?.(
|
|
860
|
+
createWebhookVerifyRequest(request, rawBody)
|
|
861
|
+
);
|
|
862
|
+
if (verified?.isErr()) {
|
|
863
|
+
await recordInvalidWebhook(
|
|
864
|
+
route,
|
|
865
|
+
errorCategoryForWebhookFailure(verified.error)
|
|
866
|
+
);
|
|
867
|
+
return mapResultToResponse(verified, request);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const jsonBody = parseWebhookBodyText(request, rawBody);
|
|
871
|
+
// With rawBody delivery the trail owns payload interpretation, so a
|
|
872
|
+
// non-JSON body is not a surface-level failure.
|
|
873
|
+
if (jsonBody === JSON_PARSE_ERROR && route.webhookSource?.rawBody !== true) {
|
|
874
|
+
await recordInvalidWebhook(route);
|
|
875
|
+
return invalidJsonResponse();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const delivered = buildWebhookDeliveredValue(
|
|
879
|
+
route,
|
|
880
|
+
request,
|
|
881
|
+
rawBody,
|
|
882
|
+
jsonBody === JSON_PARSE_ERROR ? undefined : jsonBody,
|
|
883
|
+
pathParams
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
const parsed = route.parseWebhookInput?.(delivered);
|
|
887
|
+
if (parsed === undefined) {
|
|
888
|
+
await recordInvalidWebhook(route, 'internal');
|
|
889
|
+
return mapResultToResponse(
|
|
890
|
+
{
|
|
891
|
+
error: new Error('Webhook route is missing parse handler'),
|
|
892
|
+
isOk: () => false,
|
|
893
|
+
},
|
|
894
|
+
request
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
if (parsed.isErr()) {
|
|
898
|
+
await recordInvalidWebhook(route);
|
|
899
|
+
return mapResultToResponse(parsed, request);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const requestId = request.headers.get('X-Request-ID') ?? undefined;
|
|
903
|
+
const result = await route.execute(parsed.value, requestId, request.signal, {
|
|
904
|
+
headers: request.headers,
|
|
905
|
+
});
|
|
906
|
+
// Envelope-mode ingress acknowledges accepted work with 202, matching
|
|
907
|
+
// the accepted-for-processing semantics of webhook receivers.
|
|
908
|
+
if (envelope && result.isOk()) {
|
|
909
|
+
return json({ data: result.value }, 202);
|
|
910
|
+
}
|
|
911
|
+
return mapResultToResponse(result, request);
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Build a Web Fetch handler for one framework-agnostic HTTP route.
|
|
916
|
+
*
|
|
917
|
+
* @example
|
|
918
|
+
* ```ts
|
|
919
|
+
* import { deriveHttpRoutes } from '@ontrails/http';
|
|
920
|
+
* import { createRouteHandler } from '@ontrails/http/fetch';
|
|
921
|
+
*
|
|
922
|
+
* const routes = deriveHttpRoutes(graph, { basePath: '/api' });
|
|
923
|
+
* if (routes.isErr()) throw routes.error;
|
|
924
|
+
*
|
|
925
|
+
* const route = routes.value[0];
|
|
926
|
+
* if (!route) throw new Error('No routes derived');
|
|
927
|
+
*
|
|
928
|
+
* const handle = createRouteHandler(route);
|
|
929
|
+
* const response = await handle(new Request('https://example.test/api/hello'));
|
|
930
|
+
* ```
|
|
931
|
+
*/
|
|
932
|
+
export const createRouteHandler = (
|
|
933
|
+
route: HttpRouteDefinition,
|
|
934
|
+
options: CreateRouteHandlerOptions = {}
|
|
935
|
+
): ((request: Request) => Promise<Response>) => {
|
|
936
|
+
const runtimeOptions = {
|
|
937
|
+
maxJsonBodyBytes: resolveMaxJsonBodyBytes(options.maxJsonBodyBytes),
|
|
938
|
+
};
|
|
939
|
+
const rendersBlob = rendersBlobOutput(route);
|
|
940
|
+
|
|
941
|
+
return async (request) => {
|
|
942
|
+
try {
|
|
943
|
+
if (route.inputSource === 'webhook') {
|
|
944
|
+
return await handleWebhookRoute(route, runtimeOptions, request);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const rawInput = await readInput(request, route, runtimeOptions);
|
|
948
|
+
|
|
949
|
+
if (rawInput === JSON_PARSE_ERROR) {
|
|
950
|
+
return invalidJsonResponse();
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (rawInput === JSON_BODY_INVALID_CONTENT_LENGTH) {
|
|
954
|
+
return invalidContentLengthResponse();
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (rawInput === JSON_BODY_TOO_LARGE) {
|
|
958
|
+
return oversizedJsonBodyResponse(runtimeOptions);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const requestId = request.headers.get('X-Request-ID') ?? undefined;
|
|
962
|
+
const result = await route.execute(rawInput, requestId, request.signal, {
|
|
963
|
+
headers: request.headers,
|
|
964
|
+
});
|
|
965
|
+
return mapResultToResponse(result, request, { rendersBlob });
|
|
966
|
+
} catch (error: unknown) {
|
|
967
|
+
return handleCaughtError(error, request);
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Build a Web Fetch dispatcher for all HTTP routes in a topo.
|
|
974
|
+
*
|
|
975
|
+
* @example
|
|
976
|
+
* ```ts
|
|
977
|
+
* import { createFetchHandler } from '@ontrails/http/fetch';
|
|
978
|
+
*
|
|
979
|
+
* const fetch = createFetchHandler(graph, { basePath: '/api' });
|
|
980
|
+
* const response = await fetch(
|
|
981
|
+
* new Request('https://example.test/api/hello?name=Matt')
|
|
982
|
+
* );
|
|
983
|
+
* ```
|
|
984
|
+
*/
|
|
985
|
+
export const createFetchHandler = (
|
|
986
|
+
graph: Topo,
|
|
987
|
+
options: CreateFetchHandlerOptions = {}
|
|
988
|
+
): ((request: Request) => Promise<Response>) => {
|
|
989
|
+
const routesResult = deriveHttpRoutes(graph, {
|
|
990
|
+
basePath: options.basePath,
|
|
991
|
+
configValues: options.configValues,
|
|
992
|
+
createContext: options.createContext,
|
|
993
|
+
exclude: options.exclude,
|
|
994
|
+
include: options.include,
|
|
995
|
+
intent: options.intent,
|
|
996
|
+
layers: options.layers,
|
|
997
|
+
resolvePermit: options.resolvePermit,
|
|
998
|
+
resources: options.resources,
|
|
999
|
+
validate: options.validate,
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
if (routesResult.isErr()) {
|
|
1003
|
+
throw routesResult.error;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
type BoundRouteHandler = (request: Request) => Promise<Response>;
|
|
1007
|
+
|
|
1008
|
+
const routeHandlers = new Map<string, BoundRouteHandler>();
|
|
1009
|
+
const patternRoutes: {
|
|
1010
|
+
readonly handler: BoundRouteHandler;
|
|
1011
|
+
readonly method: string;
|
|
1012
|
+
readonly pattern: string;
|
|
1013
|
+
}[] = [];
|
|
1014
|
+
for (const route of routesResult.value) {
|
|
1015
|
+
const handler = createRouteHandler(route, {
|
|
1016
|
+
maxJsonBodyBytes: options.maxJsonBodyBytes,
|
|
1017
|
+
});
|
|
1018
|
+
if (parseWebhookPathParams(route.path).length > 0) {
|
|
1019
|
+
patternRoutes.push({
|
|
1020
|
+
handler,
|
|
1021
|
+
method: route.method.toUpperCase(),
|
|
1022
|
+
pattern: route.path,
|
|
1023
|
+
});
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
routeHandlers.set(routeKey(route.method, route.path), handler);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return async (request) => {
|
|
1030
|
+
const path = new URL(request.url).pathname;
|
|
1031
|
+
const handler = routeHandlers.get(routeKey(request.method, path));
|
|
1032
|
+
if (handler !== undefined) {
|
|
1033
|
+
return handler(request);
|
|
1034
|
+
}
|
|
1035
|
+
// Exact routes win; dynamic-segment routes match in registration order.
|
|
1036
|
+
const method = request.method.toUpperCase();
|
|
1037
|
+
for (const candidate of patternRoutes) {
|
|
1038
|
+
if (candidate.method !== method) {
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
if (matchWebhookPath(candidate.pattern, path) !== undefined) {
|
|
1042
|
+
return candidate.handler(request);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return notFoundResponse(request);
|
|
1046
|
+
};
|
|
1047
|
+
};
|