@ontrails/http 1.0.0-beta.32 → 1.0.0-beta.39
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 +20 -0
- package/package.json +2 -2
- package/src/blob-output.ts +31 -0
- package/src/build.ts +63 -1
- package/src/fetch.ts +154 -16
- package/src/openapi.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @ontrails/http
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.39
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`6517f67`](https://github.com/outfitter-dev/trails/commit/6517f67b9dcc9d0ab64b1c7201b5d326905498e4): The OpenAPI projection now documents BlobRef routes as binary responses (`content: { '*/*': { schema: { type: 'string', format: 'binary' } } }`) instead of a JSON data envelope, matching the raw bytes the runtime serves with the blob's declared mimeType. Error responses keep the JSON error envelope. Both the fetch handler and the OpenAPI projection now share one BlobRef output recognition helper.
|
|
8
|
+
- [`ab5c767`](https://github.com/outfitter-dev/trails/commit/ab5c7670446baa89cc10241686d51c16d0215e04): Serve `BlobRef` trail outputs as bytes on HTTP (TRL-1192). When a trail's output schema is `blobRefSchema`, the route handler streams the blob's data with `Content-Type` from its `mimeType` and a `Content-Length` from its `size` — for both `Uint8Array` and `ReadableStream` payloads — instead of wrapping the value in the JSON envelope. Error results keep the JSON error envelope, and non-blob trails are unchanged. Apps no longer need to hand-mount raw-byte routes beside the derived surface.
|
|
9
|
+
- [`b9e82a3`](https://github.com/outfitter-dev/trails/commit/b9e82a33546356c93fbc302fb934a83f19f1c2c5): Webhook ingress v2 (TRL-1194, absorbing TRL-1174 and TRL-1175): store-verified, per-endpoint webhook ingress becomes framework-expressible. `webhook()` accepts dynamic path segments (`path: '/hooks/:endpoint'`) whose values are delivered as envelope fields, opt-in `rawBody: true` delivery (a non-JSON body is no longer a surface-level failure — the trail owns payload interpretation), an allowlisted `headers` list delivered lowercased, and `resources` that make `verify` resource-capable: the HTTP surface resolves the declared resources into a context for the verifier and releases them afterwards, so signature checks can reach stores holding per-endpoint secrets. Envelope-mode ingress responds 202 Accepted; classic static webhooks keep their exact-match, JSON-gated, 200 behavior. Core exports `parseWebhookPathParams`, `matchWebhookPath`, `webhookPathPatternsOverlap`, and `createResources`. The `webhook-route-collision` Warden rule now also flags dynamic patterns that overlap other webhook or derived routes, not just exact method/path duplicates.
|
|
10
|
+
|
|
11
|
+
## 1.0.0-beta.38
|
|
12
|
+
|
|
13
|
+
## 1.0.0-beta.37
|
|
14
|
+
|
|
15
|
+
## 1.0.0-beta.36
|
|
16
|
+
|
|
17
|
+
## 1.0.0-beta.35
|
|
18
|
+
|
|
19
|
+
## 1.0.0-beta.34
|
|
20
|
+
|
|
21
|
+
## 1.0.0-beta.33
|
|
22
|
+
|
|
3
23
|
## 1.0.0-beta.32
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ontrails/http",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.39",
|
|
4
4
|
"files": [
|
|
5
5
|
"src/**/*.ts",
|
|
6
6
|
"!src/**/__tests__/**",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@ontrails/core": "^1.0.0-beta.
|
|
28
|
+
"@ontrails/core": "^1.0.0-beta.39"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"zod": "^4.3.5"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared recognition for BlobRef trail output schemas.
|
|
3
|
+
*
|
|
4
|
+
* The runtime handler (`fetch.ts`) and the OpenAPI projection
|
|
5
|
+
* (`openapi.ts`) must agree on which routes serve raw bytes, so both
|
|
6
|
+
* read the same authored fact: the BlobRef marker meta on the trail's
|
|
7
|
+
* output schema.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { BLOB_REF_SCHEMA_META_KEY } from '@ontrails/core';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* True when a trail output schema carries the BlobRef marker meta — the
|
|
14
|
+
* authored fact that selects byte streaming over the JSON envelope on
|
|
15
|
+
* the HTTP surface and a binary response body in the OpenAPI projection.
|
|
16
|
+
*/
|
|
17
|
+
export const isBlobOutputSchema = (output: unknown): boolean => {
|
|
18
|
+
if (typeof output !== 'object' || output === null) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const maybeMeta = (output as { meta?: () => unknown }).meta;
|
|
22
|
+
if (typeof maybeMeta !== 'function') {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const meta = maybeMeta.call(output);
|
|
26
|
+
return (
|
|
27
|
+
typeof meta === 'object' &&
|
|
28
|
+
meta !== null &&
|
|
29
|
+
(meta as Record<string, unknown>)[BLOB_REF_SCHEMA_META_KEY] === true
|
|
30
|
+
);
|
|
31
|
+
};
|
package/src/build.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
ValidationError,
|
|
13
13
|
buildActivationProvenanceTraceAttrs,
|
|
14
14
|
collectAttachedTypedLayers,
|
|
15
|
+
createResources,
|
|
16
|
+
createTrailContext,
|
|
15
17
|
deriveSurfaceTrailVersionProjections,
|
|
16
18
|
executeTrail,
|
|
17
19
|
filterSurfaceTrails,
|
|
@@ -1038,6 +1040,44 @@ type MergeableWebhookRoute = HttpRouteDefinition & {
|
|
|
1038
1040
|
readonly [WEBHOOK_INVALID_RECORDERS]?: readonly WebhookInvalidConsumerRecorder[];
|
|
1039
1041
|
};
|
|
1040
1042
|
|
|
1043
|
+
/**
|
|
1044
|
+
* Wrap a webhook source's `verify` for the route boundary.
|
|
1045
|
+
*
|
|
1046
|
+
* Sources that declare `resources` get a resource-capable context: the
|
|
1047
|
+
* declared resources are resolved (honoring surface overrides and config
|
|
1048
|
+
* values) for the duration of the verification and released afterwards,
|
|
1049
|
+
* so signature checks can reach stores holding per-endpoint secrets.
|
|
1050
|
+
*/
|
|
1051
|
+
const createWebhookVerifier =
|
|
1052
|
+
(
|
|
1053
|
+
source: WebhookSource,
|
|
1054
|
+
options: DeriveHttpRoutesOptions
|
|
1055
|
+
): ((request: WebhookVerifyRequest) => Promise<Result<void, Error>>) =>
|
|
1056
|
+
async (request) => {
|
|
1057
|
+
const declared = source.resources ?? [];
|
|
1058
|
+
if (source.verify === undefined || declared.length === 0) {
|
|
1059
|
+
return await verifyWebhookRequest(source, request);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const seed = options.createContext
|
|
1063
|
+
? await options.createContext()
|
|
1064
|
+
: undefined;
|
|
1065
|
+
const scope = await createResources(
|
|
1066
|
+
{ resources: declared },
|
|
1067
|
+
createTrailContext(seed),
|
|
1068
|
+
options.resources,
|
|
1069
|
+
options.configValues
|
|
1070
|
+
);
|
|
1071
|
+
if (scope.isErr()) {
|
|
1072
|
+
return scope;
|
|
1073
|
+
}
|
|
1074
|
+
try {
|
|
1075
|
+
return await verifyWebhookRequest(source, request, scope.value.ctx);
|
|
1076
|
+
} finally {
|
|
1077
|
+
scope.value.release();
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1041
1081
|
const buildWebhookRoute = (
|
|
1042
1082
|
graph: Topo,
|
|
1043
1083
|
trail: Trail<unknown, unknown, unknown>,
|
|
@@ -1089,7 +1129,7 @@ const buildWebhookRoute = (
|
|
|
1089
1129
|
),
|
|
1090
1130
|
trail,
|
|
1091
1131
|
trailId: trail.id,
|
|
1092
|
-
verifyWebhook: (
|
|
1132
|
+
verifyWebhook: createWebhookVerifier(source.value, options),
|
|
1093
1133
|
...(versions === undefined ? {} : { versions }),
|
|
1094
1134
|
webhookSource: source.value,
|
|
1095
1135
|
};
|
|
@@ -1142,6 +1182,20 @@ const hasMatchingWebhookParse = (
|
|
|
1142
1182
|
right: HttpRouteDefinition
|
|
1143
1183
|
): boolean => left.webhookSource?.parse === right.webhookSource?.parse;
|
|
1144
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* Envelope facts must agree before merging: the merged route delivers one
|
|
1187
|
+
* envelope shape, so diverging `rawBody`/`headers` declarations would
|
|
1188
|
+
* silently drop a consumer's declared fields.
|
|
1189
|
+
*/
|
|
1190
|
+
const hasMatchingWebhookEnvelope = (
|
|
1191
|
+
left: HttpRouteDefinition,
|
|
1192
|
+
right: HttpRouteDefinition
|
|
1193
|
+
): boolean =>
|
|
1194
|
+
(left.webhookSource?.rawBody === true) ===
|
|
1195
|
+
(right.webhookSource?.rawBody === true) &&
|
|
1196
|
+
JSON.stringify(left.webhookSource?.headers ?? null) ===
|
|
1197
|
+
JSON.stringify(right.webhookSource?.headers ?? null);
|
|
1198
|
+
|
|
1145
1199
|
const webhookConsumers = (
|
|
1146
1200
|
route: MergeableWebhookRoute
|
|
1147
1201
|
): readonly WebhookConsumerExecute[] | undefined => route[WEBHOOK_CONSUMERS];
|
|
@@ -1180,6 +1234,14 @@ const mergeWebhookRoutes = (
|
|
|
1180
1234
|
kind: 'parse-mismatch',
|
|
1181
1235
|
};
|
|
1182
1236
|
}
|
|
1237
|
+
if (!hasMatchingWebhookEnvelope(existing, route)) {
|
|
1238
|
+
return {
|
|
1239
|
+
error: new ValidationError(
|
|
1240
|
+
`HTTP route collision: trails "${existing.trailId}" and "${route.trailId}" share webhook source "${existing.webhookSource?.id}" on ${route.method} ${route.path} but declare mismatched rawBody/headers envelope facts. Reuse the same WebhookSource object so both consumers receive one envelope shape.`
|
|
1241
|
+
),
|
|
1242
|
+
kind: 'parse-mismatch',
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1183
1245
|
|
|
1184
1246
|
const existingConsumers = webhookConsumers(existing as MergeableWebhookRoute);
|
|
1185
1247
|
const incomingConsumers = webhookConsumers(route as MergeableWebhookRoute);
|
package/src/fetch.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CancelledError,
|
|
3
|
+
isBlobRef,
|
|
3
4
|
isTrailsError,
|
|
5
|
+
matchWebhookPath,
|
|
4
6
|
NotFoundError,
|
|
7
|
+
parseWebhookPathParams,
|
|
5
8
|
projectErrorDiagnostics,
|
|
6
9
|
projectPublicSurfaceError,
|
|
7
10
|
ValidationError,
|
|
8
11
|
} from '@ontrails/core';
|
|
9
|
-
import type { Topo } from '@ontrails/core';
|
|
12
|
+
import type { BlobRef, Topo } from '@ontrails/core';
|
|
10
13
|
|
|
14
|
+
import { isBlobOutputSchema } from './blob-output.js';
|
|
11
15
|
import { deriveHttpRoutes } from './build.js';
|
|
12
16
|
import type { DeriveHttpRoutesOptions, HttpRouteDefinition } from './build.js';
|
|
13
17
|
|
|
@@ -337,11 +341,41 @@ interface ResultLike {
|
|
|
337
341
|
readonly value?: unknown;
|
|
338
342
|
}
|
|
339
343
|
|
|
344
|
+
/**
|
|
345
|
+
* True when the route's trail declares a BlobRef output schema — the
|
|
346
|
+
* authored fact that selects byte streaming over the JSON envelope.
|
|
347
|
+
*/
|
|
348
|
+
const rendersBlobOutput = (route: HttpRouteDefinition): boolean =>
|
|
349
|
+
isBlobOutputSchema(route.trail.output);
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Narrow blob bytes for `Response`. `BlobRef.data` is typed `Uint8Array`
|
|
353
|
+
* (ArrayBufferLike backing) while `BodyInit` wants a plain-ArrayBuffer
|
|
354
|
+
* view; blob producers construct views over plain buffers, so narrowing
|
|
355
|
+
* here avoids copying the bytes.
|
|
356
|
+
*/
|
|
357
|
+
const blobBody = (data: BlobRef['data']): BodyInit =>
|
|
358
|
+
data instanceof ReadableStream ? data : (data as Uint8Array<ArrayBuffer>);
|
|
359
|
+
|
|
360
|
+
/** Stream a BlobRef's bytes with its declared content type and length. */
|
|
361
|
+
const blobResponse = (blob: BlobRef): Response =>
|
|
362
|
+
new Response(blobBody(blob.data), {
|
|
363
|
+
headers: {
|
|
364
|
+
'Content-Length': String(blob.size),
|
|
365
|
+
'Content-Type': blob.mimeType,
|
|
366
|
+
},
|
|
367
|
+
status: 200,
|
|
368
|
+
});
|
|
369
|
+
|
|
340
370
|
const mapResultToResponse = (
|
|
341
371
|
result: ResultLike,
|
|
342
|
-
request: Request
|
|
372
|
+
request: Request,
|
|
373
|
+
options?: { readonly rendersBlob?: boolean }
|
|
343
374
|
): Response => {
|
|
344
375
|
if (result.isOk()) {
|
|
376
|
+
if (options?.rendersBlob === true && isBlobRef(result.value)) {
|
|
377
|
+
return blobResponse(result.value);
|
|
378
|
+
}
|
|
345
379
|
return json({ data: result.value }, 200);
|
|
346
380
|
}
|
|
347
381
|
const error = result.error ?? new Error('Unknown error');
|
|
@@ -429,11 +463,76 @@ const recordInvalidWebhook = async (
|
|
|
429
463
|
const errorCategoryForWebhookFailure = (error: Error | undefined): string =>
|
|
430
464
|
error !== undefined && isTrailsError(error) ? error.category : 'internal';
|
|
431
465
|
|
|
466
|
+
/**
|
|
467
|
+
* True when the route's webhook source opts into ingress-envelope
|
|
468
|
+
* delivery: dynamic path segments, raw body, or allowlisted headers.
|
|
469
|
+
*/
|
|
470
|
+
const usesWebhookEnvelope = (route: HttpRouteDefinition): boolean => {
|
|
471
|
+
const source = route.webhookSource;
|
|
472
|
+
return (
|
|
473
|
+
source !== undefined &&
|
|
474
|
+
(source.rawBody === true ||
|
|
475
|
+
source.headers !== undefined ||
|
|
476
|
+
parseWebhookPathParams(source.path).length > 0)
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const pickAllowlistedHeaders = (
|
|
481
|
+
headers: Headers,
|
|
482
|
+
allowlist: readonly string[]
|
|
483
|
+
): Record<string, string> => {
|
|
484
|
+
const allowed = new Set(allowlist.map((name) => name.toLowerCase()));
|
|
485
|
+
const kept: Record<string, string> = {};
|
|
486
|
+
for (const [name, value] of headers) {
|
|
487
|
+
const normalized = name.toLowerCase();
|
|
488
|
+
if (allowed.has(normalized)) {
|
|
489
|
+
kept[normalized] = value;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return kept;
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Assemble the delivered webhook value.
|
|
497
|
+
*
|
|
498
|
+
* Classic webhooks deliver the parsed JSON body directly. Envelope-mode
|
|
499
|
+
* webhooks deliver `{ ...pathParams, body?, headers?, rawBody? }` — the
|
|
500
|
+
* schema-declared boundary shape TRL-1194 lifts from the hand-mounted
|
|
501
|
+
* ingress routes.
|
|
502
|
+
*/
|
|
503
|
+
const buildWebhookDeliveredValue = (
|
|
504
|
+
route: HttpRouteDefinition,
|
|
505
|
+
request: Request,
|
|
506
|
+
rawBody: string,
|
|
507
|
+
jsonBody: unknown,
|
|
508
|
+
pathParams: Readonly<Record<string, string>> | undefined
|
|
509
|
+
): unknown => {
|
|
510
|
+
const source = route.webhookSource;
|
|
511
|
+
if (source === undefined || !usesWebhookEnvelope(route)) {
|
|
512
|
+
return jsonBody;
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
...(jsonBody === undefined ? {} : { body: jsonBody }),
|
|
516
|
+
...(source.headers === undefined
|
|
517
|
+
? {}
|
|
518
|
+
: { headers: pickAllowlistedHeaders(request.headers, source.headers) }),
|
|
519
|
+
...(source.rawBody === true ? { rawBody } : {}),
|
|
520
|
+
...pathParams,
|
|
521
|
+
};
|
|
522
|
+
};
|
|
523
|
+
|
|
432
524
|
const handleWebhookRoute = async (
|
|
433
525
|
route: HttpRouteDefinition,
|
|
434
526
|
options: RuntimeOptions,
|
|
435
527
|
request: Request
|
|
436
528
|
): Promise<Response> => {
|
|
529
|
+
const envelope = usesWebhookEnvelope(route);
|
|
530
|
+
// Self-derive dynamic segment values from the route's own pattern so
|
|
531
|
+
// every adapter (fetch dispatcher, Bun routes, Hono) gets pattern
|
|
532
|
+
// support without threading params through handler signatures.
|
|
533
|
+
const pathParams = envelope
|
|
534
|
+
? matchWebhookPath(route.path, new URL(request.url).pathname)
|
|
535
|
+
: undefined;
|
|
437
536
|
const rawBody = await readWebhookBodyText(request, options.maxJsonBodyBytes);
|
|
438
537
|
|
|
439
538
|
if (rawBody === JSON_BODY_INVALID_CONTENT_LENGTH) {
|
|
@@ -458,12 +557,22 @@ const handleWebhookRoute = async (
|
|
|
458
557
|
}
|
|
459
558
|
|
|
460
559
|
const jsonBody = parseWebhookBodyText(request, rawBody);
|
|
461
|
-
|
|
560
|
+
// With rawBody delivery the trail owns payload interpretation, so a
|
|
561
|
+
// non-JSON body is not a surface-level failure.
|
|
562
|
+
if (jsonBody === JSON_PARSE_ERROR && route.webhookSource?.rawBody !== true) {
|
|
462
563
|
await recordInvalidWebhook(route);
|
|
463
564
|
return invalidJsonResponse();
|
|
464
565
|
}
|
|
465
566
|
|
|
466
|
-
const
|
|
567
|
+
const delivered = buildWebhookDeliveredValue(
|
|
568
|
+
route,
|
|
569
|
+
request,
|
|
570
|
+
rawBody,
|
|
571
|
+
jsonBody === JSON_PARSE_ERROR ? undefined : jsonBody,
|
|
572
|
+
pathParams
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
const parsed = route.parseWebhookInput?.(delivered);
|
|
467
576
|
if (parsed === undefined) {
|
|
468
577
|
await recordInvalidWebhook(route, 'internal');
|
|
469
578
|
return mapResultToResponse(
|
|
@@ -483,6 +592,11 @@ const handleWebhookRoute = async (
|
|
|
483
592
|
const result = await route.execute(parsed.value, requestId, request.signal, {
|
|
484
593
|
headers: request.headers,
|
|
485
594
|
});
|
|
595
|
+
// Envelope-mode ingress acknowledges accepted work with 202, matching
|
|
596
|
+
// the accepted-for-processing semantics of webhook receivers.
|
|
597
|
+
if (envelope && result.isOk()) {
|
|
598
|
+
return json({ data: result.value }, 202);
|
|
599
|
+
}
|
|
486
600
|
return mapResultToResponse(result, request);
|
|
487
601
|
};
|
|
488
602
|
|
|
@@ -511,6 +625,7 @@ export const createRouteHandler = (
|
|
|
511
625
|
const runtimeOptions = {
|
|
512
626
|
maxJsonBodyBytes: resolveMaxJsonBodyBytes(options.maxJsonBodyBytes),
|
|
513
627
|
};
|
|
628
|
+
const rendersBlob = rendersBlobOutput(route);
|
|
514
629
|
|
|
515
630
|
return async (request) => {
|
|
516
631
|
try {
|
|
@@ -540,7 +655,7 @@ export const createRouteHandler = (
|
|
|
540
655
|
const result = await route.execute(rawInput, requestId, request.signal, {
|
|
541
656
|
headers: request.headers,
|
|
542
657
|
});
|
|
543
|
-
return mapResultToResponse(result, request);
|
|
658
|
+
return mapResultToResponse(result, request, { rendersBlob });
|
|
544
659
|
} catch (error: unknown) {
|
|
545
660
|
return handleCaughtError(error, request);
|
|
546
661
|
}
|
|
@@ -581,22 +696,45 @@ export const createFetchHandler = (
|
|
|
581
696
|
throw routesResult.error;
|
|
582
697
|
}
|
|
583
698
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
699
|
+
type BoundRouteHandler = (request: Request) => Promise<Response>;
|
|
700
|
+
|
|
701
|
+
const routeHandlers = new Map<string, BoundRouteHandler>();
|
|
702
|
+
const patternRoutes: {
|
|
703
|
+
readonly handler: BoundRouteHandler;
|
|
704
|
+
readonly method: string;
|
|
705
|
+
readonly pattern: string;
|
|
706
|
+
}[] = [];
|
|
588
707
|
for (const route of routesResult.value) {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
708
|
+
const handler = createRouteHandler(route, {
|
|
709
|
+
maxJsonBodyBytes: options.maxJsonBodyBytes,
|
|
710
|
+
});
|
|
711
|
+
if (parseWebhookPathParams(route.path).length > 0) {
|
|
712
|
+
patternRoutes.push({
|
|
713
|
+
handler,
|
|
714
|
+
method: route.method.toUpperCase(),
|
|
715
|
+
pattern: route.path,
|
|
716
|
+
});
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
routeHandlers.set(routeKey(route.method, route.path), handler);
|
|
595
720
|
}
|
|
596
721
|
|
|
597
722
|
return async (request) => {
|
|
598
723
|
const path = new URL(request.url).pathname;
|
|
599
724
|
const handler = routeHandlers.get(routeKey(request.method, path));
|
|
600
|
-
|
|
725
|
+
if (handler !== undefined) {
|
|
726
|
+
return handler(request);
|
|
727
|
+
}
|
|
728
|
+
// Exact routes win; dynamic-segment routes match in registration order.
|
|
729
|
+
const method = request.method.toUpperCase();
|
|
730
|
+
for (const candidate of patternRoutes) {
|
|
731
|
+
if (candidate.method !== method) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (matchWebhookPath(candidate.pattern, path) !== undefined) {
|
|
735
|
+
return candidate.handler(request);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return notFoundResponse(request);
|
|
601
739
|
};
|
|
602
740
|
};
|
package/src/openapi.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
Trail,
|
|
20
20
|
} from '@ontrails/core';
|
|
21
21
|
|
|
22
|
+
import { isBlobOutputSchema } from './blob-output.js';
|
|
22
23
|
import { deriveHttpOperationMethod } from './method.js';
|
|
23
24
|
|
|
24
25
|
type JsonSchema = Readonly<Record<string, unknown>>;
|
|
@@ -214,6 +215,21 @@ const buildSuccessResponse = (
|
|
|
214
215
|
if (!t.output) {
|
|
215
216
|
return { '200': { description: 'Success' } };
|
|
216
217
|
}
|
|
218
|
+
if (isBlobOutputSchema(t.output)) {
|
|
219
|
+
// BlobRef routes stream raw bytes at runtime (`fetch.ts` serves the
|
|
220
|
+
// blob's declared mimeType and Content-Length), so the spec documents
|
|
221
|
+
// a binary body instead of the JSON data envelope. The concrete
|
|
222
|
+
// Content-Type is per-blob runtime data, hence the `*/*` range.
|
|
223
|
+
return {
|
|
224
|
+
'200': {
|
|
225
|
+
content: {
|
|
226
|
+
'*/*': { schema: { format: 'binary', type: 'string' } },
|
|
227
|
+
},
|
|
228
|
+
description:
|
|
229
|
+
"Binary content: raw blob bytes served with the blob's declared mimeType",
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
217
233
|
const outputSchema = toJsonSchema(t.output);
|
|
218
234
|
return {
|
|
219
235
|
'200': {
|