@ontrails/http 1.0.0-beta.18 → 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 +31 -0
- package/README.md +29 -28
- package/package.json +19 -2
- package/src/build.ts +93 -9
- package/src/fetch.ts +25 -0
- package/src/method.ts +11 -0
- package/src/testing.ts +339 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @ontrails/http
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.19
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e41c382: Document beta-channel install guidance in package and adapter README install snippets so consumers use explicit `@beta` (or pinned `1.0.0-beta.N`) tags instead of accidental `latest` resolution during the prerelease line. Adds the policy doc at `docs/releases/beta-channel-policy.md`, prints both `latest` and `beta` dist-tags in `bun run publish:registry-check`, and aligns plugin/skill install snippets.
|
|
8
|
+
- 1eb5bdc: Rename first-class trail composition from the `cross` API family to the `compose` family across core contracts, testing helpers, topo projections, Warden rules, CLI scaffolds, and docs. `composes`, `ctx.compose`, `composeInput`, and `Compose*` type names are now the public authoring vocabulary; topo persistence migrates legacy composition rows and graph keys forward.
|
|
9
|
+
- 94a8380: Add public API examples for the shared Web Fetch route and topo handlers.
|
|
10
|
+
- 94a8380: Add a public API example for the HTTP intent method table.
|
|
11
|
+
- 84f56a5: Project live trail-version metadata on CLI, HTTP, and MCP surfaces and thread explicit surface version selection into shared trail execution.
|
|
12
|
+
- 5d88104: Polish Trails blaze terminology across package docs and Warden guidance.
|
|
13
|
+
- fc00aeb: Add adapter target conformance metadata and scaffold extracted HTTP adapters through `trails create adapter`.
|
|
14
|
+
- ab1c77c: Advertise first-party adapter target metadata for catalog derivation.
|
|
15
|
+
- 8ca5b85: Expose owner-owned HTTP adapter conformance cases from `@ontrails/http/testing`.
|
|
16
|
+
- Updated dependencies [e41c382]
|
|
17
|
+
- Updated dependencies [1eb5bdc]
|
|
18
|
+
- Updated dependencies [f8d80b9]
|
|
19
|
+
- Updated dependencies [846a597]
|
|
20
|
+
- Updated dependencies [223aaad]
|
|
21
|
+
- Updated dependencies [3125f4d]
|
|
22
|
+
- Updated dependencies [2494dc6]
|
|
23
|
+
- Updated dependencies [2d53717]
|
|
24
|
+
- Updated dependencies [16cb740]
|
|
25
|
+
- Updated dependencies [8894ecb]
|
|
26
|
+
- Updated dependencies [fdf7ec9]
|
|
27
|
+
- Updated dependencies [d76be13]
|
|
28
|
+
- Updated dependencies [84f56a5]
|
|
29
|
+
- Updated dependencies [431b04c]
|
|
30
|
+
- Updated dependencies [5d88104]
|
|
31
|
+
- Updated dependencies [f04a9ef]
|
|
32
|
+
- @ontrails/core@1.0.0-beta.19
|
|
33
|
+
|
|
3
34
|
## 1.0.0-beta.18
|
|
4
35
|
|
|
5
36
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -30,27 +30,17 @@ import { surface } from '@ontrails/http/bun';
|
|
|
30
30
|
await surface(graph, { port: 3000 });
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
`@ontrails/http/bun` uses Bun's native `Bun.serve({ routes })` fast path and
|
|
34
|
-
keeps the shared Web Fetch handler as the fallback. It requires Bun `>=1.2.3`
|
|
35
|
-
and does not add a third-party runtime dependency.
|
|
33
|
+
`@ontrails/http/bun` uses Bun's native `Bun.serve({ routes })` fast path and keeps the shared Web Fetch handler as the fallback. It requires Bun `>=1.2.3` and does not add a third-party runtime dependency.
|
|
36
34
|
|
|
37
35
|
## Projection and materialization
|
|
38
36
|
|
|
39
37
|
The HTTP package follows the surface API naming split:
|
|
40
38
|
|
|
41
|
-
- `derive*` exports are pure projections from the topo. Use
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
- `create*` exports materialize runtime objects without opening a network
|
|
45
|
-
boundary. `@ontrails/http/fetch` exports `createRouteHandler()` for one
|
|
46
|
-
route and `createFetchHandler()` for a full topo dispatcher.
|
|
47
|
-
- `surface()` opens the runtime boundary. `@ontrails/hono` opens a Hono server;
|
|
48
|
-
`@ontrails/http/bun` opens Bun's native HTTP server.
|
|
39
|
+
- `derive*` exports are pure projections from the topo. Use `deriveHttpRoutes()` for route definitions and `deriveOpenApiSpec()` for the OpenAPI contract.
|
|
40
|
+
- `create*` exports materialize runtime objects without opening a network boundary. `@ontrails/http/fetch` exports `createRouteHandler()` for one route and `createFetchHandler()` for a full topo dispatcher.
|
|
41
|
+
- `surface()` opens the runtime boundary. `@ontrails/hono` opens a Hono server; `@ontrails/http/bun` opens Bun's native HTTP server.
|
|
49
42
|
|
|
50
|
-
The shared `@ontrails/http/fetch` kernel owns query/body parsing,
|
|
51
|
-
content-length validation, public error projection, diagnostics, request IDs,
|
|
52
|
-
headers, abort propagation, and webhook verification/parsing behavior. Hono and
|
|
53
|
-
Bun both consume that kernel so route semantics stay aligned.
|
|
43
|
+
The shared `@ontrails/http/fetch` kernel owns query/body parsing, content-length validation, public error projection, diagnostics, request IDs, headers, abort propagation, and webhook verification/parsing behavior. Hono and Bun both consume that kernel so route semantics stay aligned.
|
|
54
44
|
|
|
55
45
|
For more control, build the routes yourself:
|
|
56
46
|
|
|
@@ -74,8 +64,7 @@ import { deriveOpenApiSpec } from '@ontrails/http';
|
|
|
74
64
|
const spec = deriveOpenApiSpec(graph, { basePath: '/api' });
|
|
75
65
|
```
|
|
76
66
|
|
|
77
|
-
`deriveOpenApiSpec()` emits an OpenAPI 3.1 document from the same trail
|
|
78
|
-
contracts used by `deriveHttpRoutes()`.
|
|
67
|
+
`deriveOpenApiSpec()` emits an OpenAPI 3.1 document from the same trail contracts used by `deriveHttpRoutes()`.
|
|
79
68
|
|
|
80
69
|
## API
|
|
81
70
|
|
|
@@ -85,6 +74,23 @@ contracts used by `deriveHttpRoutes()`.
|
|
|
85
74
|
| `deriveOpenApiSpec(graph, options?)` | Generate an OpenAPI 3.1 document for the HTTP surface |
|
|
86
75
|
| `@ontrails/http/fetch` | Shared Web Fetch `createRouteHandler()` and `createFetchHandler()` kernel |
|
|
87
76
|
| `@ontrails/http/bun` | Bun-native `createApp()` and `surface()` materializer |
|
|
77
|
+
| `@ontrails/http/testing` | Owner-owned adapter conformance factory for HTTP adapter authors |
|
|
78
|
+
|
|
79
|
+
## Adapter authoring
|
|
80
|
+
|
|
81
|
+
HTTP adapter authors should validate adapters through the owner-owned testing subpath instead of copying conformance behavior into each adapter:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import {
|
|
85
|
+
createHttpAdapterConformanceCases,
|
|
86
|
+
runConformance,
|
|
87
|
+
} from '@ontrails/http/testing';
|
|
88
|
+
import { myHttpAdapter } from './adapter.js';
|
|
89
|
+
|
|
90
|
+
runConformance(myHttpAdapter, createHttpAdapterConformanceCases());
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The adapter under test provides a `name` and `createApp(graph, options)` method that returns an object with a Web Fetch-compatible `fetch(request)` handler. The conformance cases cover query and body input projection, validation envelopes, public error redaction, request context, abort propagation, and webhook verification/parsing behavior.
|
|
88
94
|
|
|
89
95
|
## Route derivation
|
|
90
96
|
|
|
@@ -105,7 +111,7 @@ Trail IDs map to paths: `entity.show` becomes `/entity/show`. Dots become slashe
|
|
|
105
111
|
|
|
106
112
|
## Resource resolution
|
|
107
113
|
|
|
108
|
-
Declared resources on each trail are resolved into the context before the
|
|
114
|
+
Declared resources on each trail are resolved into the context before the blaze receives input.
|
|
109
115
|
|
|
110
116
|
## Filtering
|
|
111
117
|
|
|
@@ -116,9 +122,7 @@ const result = deriveHttpRoutes(graph, {
|
|
|
116
122
|
});
|
|
117
123
|
```
|
|
118
124
|
|
|
119
|
-
`*` matches one dotted segment and `**` matches any depth. Trails declared
|
|
120
|
-
with `visibility: 'internal'` stay hidden unless you include their exact trail
|
|
121
|
-
ID intentionally.
|
|
125
|
+
`*` matches one dotted segment and `**` matches any depth. Trails declared with `visibility: 'internal'` stay hidden unless you include their exact trail ID intentionally.
|
|
122
126
|
|
|
123
127
|
## Request context and abort propagation
|
|
124
128
|
|
|
@@ -135,19 +139,16 @@ Each route definition produced by `deriveHttpRoutes` includes:
|
|
|
135
139
|
| `trailId` | `string` | The trail ID this route was derived from |
|
|
136
140
|
| `inputSource` | `'query' \| 'body'` | Where to read input |
|
|
137
141
|
| `trail` | `Trail` | The original trail definition |
|
|
138
|
-
| `execute` | `(input, requestId?, abortSignal?, context?) => Promise<Result>` | Validates, layers, resolves request auth when configured, and runs the
|
|
142
|
+
| `execute` | `(input, requestId?, abortSignal?, context?) => Promise<Result>` | Validates, layers, resolves request auth when configured, and runs the blazed trail |
|
|
139
143
|
|
|
140
|
-
For GET routes on the Hono surface, repeated query keys are passed through as
|
|
141
|
-
arrays (`?tag=one&tag=two` -> `{ tag: ['one', 'two'] }`) while a single
|
|
142
|
-
occurrence stays a scalar string. The adapter does not coerce singleton query
|
|
143
|
-
values into arrays.
|
|
144
|
+
For GET routes on the Hono surface, repeated query keys are passed through as arrays (`?tag=one&tag=two` -> `{ tag: ['one', 'two'] }`) while a single occurrence stays a scalar string. The adapter does not coerce singleton query values into arrays.
|
|
144
145
|
|
|
145
146
|
## Installation
|
|
146
147
|
|
|
147
148
|
```bash
|
|
148
|
-
bun add @ontrails/http @ontrails/hono
|
|
149
|
+
bun add @ontrails/http@beta @ontrails/hono@beta
|
|
149
150
|
# or, for Bun-native serving:
|
|
150
|
-
bun add @ontrails/http
|
|
151
|
+
bun add @ontrails/http@beta
|
|
151
152
|
```
|
|
152
153
|
|
|
153
154
|
## Migration
|
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.19",
|
|
4
4
|
"files": [
|
|
5
5
|
"src/**/*.ts",
|
|
6
6
|
"!src/**/__tests__/**",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
".": "./src/index.ts",
|
|
15
15
|
"./bun": "./src/bun.ts",
|
|
16
16
|
"./fetch": "./src/fetch.ts",
|
|
17
|
+
"./testing": "./src/testing.ts",
|
|
17
18
|
"./package.json": "./package.json"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
@@ -24,9 +25,25 @@
|
|
|
24
25
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
|
-
"@ontrails/core": "^1.0.0-beta.
|
|
28
|
+
"@ontrails/core": "^1.0.0-beta.19"
|
|
28
29
|
},
|
|
29
30
|
"peerDependencies": {
|
|
30
31
|
"zod": "^4.3.5"
|
|
32
|
+
},
|
|
33
|
+
"trails": {
|
|
34
|
+
"adapterTargets": {
|
|
35
|
+
"http": {
|
|
36
|
+
"conformance": {
|
|
37
|
+
"adapterType": "HttpAdapterConformanceAdapter",
|
|
38
|
+
"casesFactory": "createHttpAdapterConformanceCases",
|
|
39
|
+
"runner": "runConformance"
|
|
40
|
+
},
|
|
41
|
+
"placements": [
|
|
42
|
+
"extracted",
|
|
43
|
+
"subpath"
|
|
44
|
+
],
|
|
45
|
+
"testingImport": "@ontrails/http/testing"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
31
48
|
}
|
|
32
49
|
}
|
package/src/build.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
ValidationError,
|
|
13
13
|
buildActivationProvenanceTraceAttrs,
|
|
14
14
|
collectAttachedTypedLayers,
|
|
15
|
+
deriveSurfaceTrailVersionProjections,
|
|
15
16
|
executeTrail,
|
|
16
17
|
filterSurfaceTrails,
|
|
17
18
|
getActivationWherePredicate,
|
|
@@ -39,9 +40,11 @@ import type {
|
|
|
39
40
|
BaseSurfaceOptions,
|
|
40
41
|
Layer,
|
|
41
42
|
ResourceOverrideMap,
|
|
43
|
+
SurfaceTrailVersionProjection,
|
|
42
44
|
TraceContext,
|
|
43
45
|
Topo,
|
|
44
46
|
Trail,
|
|
47
|
+
TrailVersionReference,
|
|
45
48
|
TrailContextInit,
|
|
46
49
|
WebhookSource,
|
|
47
50
|
WebhookVerifyRequest,
|
|
@@ -72,6 +75,7 @@ export type HttpHeaderSource =
|
|
|
72
75
|
|
|
73
76
|
export interface HttpExecutionContext {
|
|
74
77
|
readonly headers?: HttpHeaderSource | undefined;
|
|
78
|
+
readonly version?: TrailVersionReference | undefined;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
export interface ResolveHttpPermitInput {
|
|
@@ -93,6 +97,7 @@ export interface HttpRouteDefinition {
|
|
|
93
97
|
readonly trailId: string;
|
|
94
98
|
readonly inputSource: InputSource;
|
|
95
99
|
readonly trail: Trail<unknown, unknown, unknown>;
|
|
100
|
+
readonly versions?: readonly SurfaceTrailVersionProjection[] | undefined;
|
|
96
101
|
/**
|
|
97
102
|
* JSON Schema for the merged request input (trail input + projected layer
|
|
98
103
|
* input fields). Empty/undefined when the trail declares no input and no
|
|
@@ -126,7 +131,7 @@ export interface HttpRouteDefinition {
|
|
|
126
131
|
| undefined;
|
|
127
132
|
readonly webhookSource?: WebhookSource | undefined;
|
|
128
133
|
/**
|
|
129
|
-
* Validate input, compose layers, and
|
|
134
|
+
* Validate input, compose layers, and run the blazed trail.
|
|
130
135
|
*
|
|
131
136
|
* The caller is responsible for parsing raw input from the request and
|
|
132
137
|
* mapping the Result to an HTTP response. This function is framework-agnostic.
|
|
@@ -572,6 +577,67 @@ const mergeHttpInputSchemas = (
|
|
|
572
577
|
return merged;
|
|
573
578
|
};
|
|
574
579
|
|
|
580
|
+
const TRAIL_VERSION_INPUT_FIELD = 'trailVersion';
|
|
581
|
+
const TRAILS_VERSION_HEADERS = ['x-trails-version', 'x-trail-version'];
|
|
582
|
+
|
|
583
|
+
const versionInputSchema = (): Record<string, unknown> => ({
|
|
584
|
+
properties: {
|
|
585
|
+
[TRAIL_VERSION_INPUT_FIELD]: {
|
|
586
|
+
description: 'Live trail version number or marker prefix',
|
|
587
|
+
type: 'string',
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
type: 'object',
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const addVersionInputSchema = (
|
|
594
|
+
trail: Trail<unknown, unknown, unknown>,
|
|
595
|
+
schema: Record<string, unknown> | undefined
|
|
596
|
+
): Record<string, unknown> | undefined =>
|
|
597
|
+
trail.version === undefined
|
|
598
|
+
? schema
|
|
599
|
+
: mergeHttpInputSchemas(schema, versionInputSchema());
|
|
600
|
+
|
|
601
|
+
const readVersionFromHeaders = (
|
|
602
|
+
headers: HttpHeaderSource | undefined
|
|
603
|
+
): TrailVersionReference | undefined => {
|
|
604
|
+
for (const name of TRAILS_VERSION_HEADERS) {
|
|
605
|
+
const value = readHeader(headers, name);
|
|
606
|
+
if (value !== undefined && value.length > 0) {
|
|
607
|
+
return value;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return undefined;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const splitHttpSurfaceVersion = (
|
|
614
|
+
input: unknown,
|
|
615
|
+
context: HttpExecutionContext | undefined,
|
|
616
|
+
supportsVersions: boolean
|
|
617
|
+
): {
|
|
618
|
+
readonly input: unknown;
|
|
619
|
+
readonly version: TrailVersionReference | undefined;
|
|
620
|
+
} => {
|
|
621
|
+
if (!supportsVersions) {
|
|
622
|
+
return { input, version: undefined };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const headerVersion =
|
|
626
|
+
context?.version ?? readVersionFromHeaders(context?.headers);
|
|
627
|
+
if (!isJsonObjectSchema(input)) {
|
|
628
|
+
return { input, version: headerVersion };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const record = input as Record<string, unknown>;
|
|
632
|
+
const { [TRAIL_VERSION_INPUT_FIELD]: fieldVersion, ...rest } = record;
|
|
633
|
+
const version =
|
|
634
|
+
headerVersion ??
|
|
635
|
+
(typeof fieldVersion === 'string' || typeof fieldVersion === 'number'
|
|
636
|
+
? fieldVersion
|
|
637
|
+
: undefined);
|
|
638
|
+
return { input: rest, version };
|
|
639
|
+
};
|
|
640
|
+
|
|
575
641
|
/**
|
|
576
642
|
* Partition a parsed request input into the trail input plus per-layer
|
|
577
643
|
* inputs, using each layer's routing table.
|
|
@@ -639,8 +705,13 @@ const createExecute =
|
|
|
639
705
|
layerProjections: readonly HttpLayerInputProjection[]
|
|
640
706
|
): HttpRouteDefinition['execute'] =>
|
|
641
707
|
async (input, requestId, abortSignal, request) => {
|
|
642
|
-
const
|
|
708
|
+
const versionedInput = splitHttpSurfaceVersion(
|
|
643
709
|
input,
|
|
710
|
+
request,
|
|
711
|
+
t.version !== undefined
|
|
712
|
+
);
|
|
713
|
+
const { trailInput, layerInputs } = partitionHttpInput(
|
|
714
|
+
versionedInput.input,
|
|
644
715
|
layerProjections
|
|
645
716
|
);
|
|
646
717
|
const permitResolution = await resolveHttpPermit(
|
|
@@ -664,6 +735,9 @@ const createExecute =
|
|
|
664
735
|
surfaceLayers: layers,
|
|
665
736
|
topo: graph,
|
|
666
737
|
topoLayers: graph.layers,
|
|
738
|
+
...(versionedInput.version === undefined
|
|
739
|
+
? {}
|
|
740
|
+
: { version: versionedInput.version }),
|
|
667
741
|
});
|
|
668
742
|
};
|
|
669
743
|
|
|
@@ -721,8 +795,13 @@ const createWebhookConsumerExecute =
|
|
|
721
795
|
'activation.webhook',
|
|
722
796
|
'ok'
|
|
723
797
|
);
|
|
724
|
-
const
|
|
798
|
+
const versionedInput = splitHttpSurfaceVersion(
|
|
725
799
|
input,
|
|
800
|
+
request,
|
|
801
|
+
t.version !== undefined
|
|
802
|
+
);
|
|
803
|
+
const { trailInput, layerInputs } = partitionHttpInput(
|
|
804
|
+
versionedInput.input,
|
|
726
805
|
layerProjections
|
|
727
806
|
);
|
|
728
807
|
const permitResolution = await resolveHttpPermit(
|
|
@@ -746,6 +825,9 @@ const createWebhookConsumerExecute =
|
|
|
746
825
|
surfaceLayers: layers,
|
|
747
826
|
topo: graph,
|
|
748
827
|
topoLayers: graph.layers,
|
|
828
|
+
...(versionedInput.version === undefined
|
|
829
|
+
? {}
|
|
830
|
+
: { version: versionedInput.version }),
|
|
749
831
|
});
|
|
750
832
|
};
|
|
751
833
|
|
|
@@ -836,6 +918,8 @@ const buildRoute = (
|
|
|
836
918
|
options.layers
|
|
837
919
|
);
|
|
838
920
|
const inputProjection = projectHttpInputSchema(trail, attachedLayers);
|
|
921
|
+
const inputSchema = addVersionInputSchema(trail, inputProjection.schema);
|
|
922
|
+
const versions = deriveSurfaceTrailVersionProjections(trail);
|
|
839
923
|
return {
|
|
840
924
|
execute: createExecute(
|
|
841
925
|
graph,
|
|
@@ -844,9 +928,7 @@ const buildRoute = (
|
|
|
844
928
|
options,
|
|
845
929
|
inputProjection.projections
|
|
846
930
|
),
|
|
847
|
-
...(
|
|
848
|
-
? {}
|
|
849
|
-
: { inputSchema: inputProjection.schema }),
|
|
931
|
+
...(inputSchema === undefined ? {} : { inputSchema }),
|
|
850
932
|
inputSource: deriveHttpInputSource(method),
|
|
851
933
|
...(inputProjection.projections.length === 0
|
|
852
934
|
? {}
|
|
@@ -855,6 +937,7 @@ const buildRoute = (
|
|
|
855
937
|
path,
|
|
856
938
|
trail,
|
|
857
939
|
trailId: trail.id,
|
|
940
|
+
...(versions === undefined ? {} : { versions }),
|
|
858
941
|
};
|
|
859
942
|
};
|
|
860
943
|
|
|
@@ -973,6 +1056,8 @@ const buildWebhookRoute = (
|
|
|
973
1056
|
options.layers
|
|
974
1057
|
);
|
|
975
1058
|
const inputProjection = projectHttpInputSchema(trail, attachedLayers);
|
|
1059
|
+
const inputSchema = addVersionInputSchema(trail, inputProjection.schema);
|
|
1060
|
+
const versions = deriveSurfaceTrailVersionProjections(trail);
|
|
976
1061
|
const consumerExecute = createWebhookConsumerExecute(
|
|
977
1062
|
graph,
|
|
978
1063
|
trail,
|
|
@@ -991,9 +1076,7 @@ const buildWebhookRoute = (
|
|
|
991
1076
|
[WEBHOOK_CONSUMERS]: [consumerExecute],
|
|
992
1077
|
[WEBHOOK_INVALID_RECORDERS]: [consumerInvalidRecorder],
|
|
993
1078
|
execute: createWebhookExecute(consumerExecute),
|
|
994
|
-
...(
|
|
995
|
-
? {}
|
|
996
|
-
: { inputSchema: inputProjection.schema }),
|
|
1079
|
+
...(inputSchema === undefined ? {} : { inputSchema }),
|
|
997
1080
|
inputSource: 'webhook',
|
|
998
1081
|
...(inputProjection.projections.length === 0
|
|
999
1082
|
? {}
|
|
@@ -1007,6 +1090,7 @@ const buildWebhookRoute = (
|
|
|
1007
1090
|
trail,
|
|
1008
1091
|
trailId: trail.id,
|
|
1009
1092
|
verifyWebhook: (request) => verifyWebhookRequest(source.value, request),
|
|
1093
|
+
...(versions === undefined ? {} : { versions }),
|
|
1010
1094
|
webhookSource: source.value,
|
|
1011
1095
|
};
|
|
1012
1096
|
return Result.ok(route);
|
package/src/fetch.ts
CHANGED
|
@@ -488,6 +488,21 @@ const handleWebhookRoute = async (
|
|
|
488
488
|
|
|
489
489
|
/**
|
|
490
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
|
+
* ```
|
|
491
506
|
*/
|
|
492
507
|
export const createRouteHandler = (
|
|
493
508
|
route: HttpRouteDefinition,
|
|
@@ -534,6 +549,16 @@ export const createRouteHandler = (
|
|
|
534
549
|
|
|
535
550
|
/**
|
|
536
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
|
+
* ```
|
|
537
562
|
*/
|
|
538
563
|
export const createFetchHandler = (
|
|
539
564
|
graph: Topo,
|
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',
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PermissionError,
|
|
5
|
+
Result,
|
|
6
|
+
getWebhookHeader,
|
|
7
|
+
trail,
|
|
8
|
+
topo,
|
|
9
|
+
webhook,
|
|
10
|
+
} from '@ontrails/core';
|
|
11
|
+
import type { Topo } from '@ontrails/core';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
DeriveHttpRoutesOptions,
|
|
16
|
+
HttpHeaderSource,
|
|
17
|
+
ResolveHttpPermit,
|
|
18
|
+
} from './build.js';
|
|
19
|
+
import type { CreateRouteHandlerOptions } from './fetch.js';
|
|
20
|
+
|
|
21
|
+
export interface HttpAdapterConformanceApp {
|
|
22
|
+
readonly fetch: (request: Request) => Response | Promise<Response>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HttpAdapterConformanceOptions
|
|
26
|
+
extends DeriveHttpRoutesOptions, CreateRouteHandlerOptions {
|
|
27
|
+
readonly resolvePermit?: ResolveHttpPermit | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HttpAdapterConformanceAdapter {
|
|
31
|
+
readonly createApp: (
|
|
32
|
+
graph: Topo,
|
|
33
|
+
options?: HttpAdapterConformanceOptions
|
|
34
|
+
) => HttpAdapterConformanceApp | Promise<HttpAdapterConformanceApp>;
|
|
35
|
+
readonly name: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HttpAdapterConformanceCase {
|
|
39
|
+
readonly check: (adapter: HttpAdapterConformanceAdapter) => Promise<void>;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const echoTrail = trail('echo', {
|
|
44
|
+
blaze: (input) => Result.ok({ reply: input.message }),
|
|
45
|
+
input: z.object({ message: z.string() }),
|
|
46
|
+
intent: 'read',
|
|
47
|
+
output: z.object({ reply: z.string() }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const tagsTrail = trail('tags', {
|
|
51
|
+
blaze: (input) => Result.ok({ tags: input.tags }),
|
|
52
|
+
input: z.object({ tags: z.array(z.string()) }),
|
|
53
|
+
intent: 'read',
|
|
54
|
+
output: z.object({ tags: z.array(z.string()) }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const echoBodyTrail = trail('echo.body', {
|
|
58
|
+
blaze: (input) => Result.ok({ length: input.message.length }),
|
|
59
|
+
input: z.object({ message: z.string() }),
|
|
60
|
+
intent: 'write',
|
|
61
|
+
output: z.object({ length: z.number() }),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const genericRedactionError = (): Error =>
|
|
65
|
+
new Error('database password=secret');
|
|
66
|
+
|
|
67
|
+
const genericErrorTrail = trail('generic.error', {
|
|
68
|
+
blaze: () => Result.err(genericRedactionError()),
|
|
69
|
+
input: z.object({}),
|
|
70
|
+
intent: 'read',
|
|
71
|
+
output: z.object({ ok: z.boolean() }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const protectedTrail = trail('permit.scope', {
|
|
75
|
+
blaze: (_input, ctx) =>
|
|
76
|
+
Result.ok({
|
|
77
|
+
permitId: ctx.permit?.id,
|
|
78
|
+
requestId: ctx.requestId,
|
|
79
|
+
}),
|
|
80
|
+
input: z.object({}),
|
|
81
|
+
intent: 'read',
|
|
82
|
+
output: z.object({
|
|
83
|
+
permitId: z.string().optional(),
|
|
84
|
+
requestId: z.string().optional(),
|
|
85
|
+
}),
|
|
86
|
+
permit: { scopes: ['thing:read'] },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const abortingTrail = trail('abort.check', {
|
|
90
|
+
blaze: (_input, ctx) => Result.ok({ aborted: ctx.abortSignal.aborted }),
|
|
91
|
+
input: z.object({}),
|
|
92
|
+
intent: 'read',
|
|
93
|
+
output: z.object({ aborted: z.boolean() }),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const webhookSecret = 'secret';
|
|
97
|
+
const paymentWebhook = webhook('webhook.payment.received', {
|
|
98
|
+
parse: z.object({ paymentId: z.string() }),
|
|
99
|
+
path: '/webhooks/payment',
|
|
100
|
+
verify: (request) =>
|
|
101
|
+
getWebhookHeader(request, 'x-webhook-secret') === webhookSecret
|
|
102
|
+
? Result.ok()
|
|
103
|
+
: Result.err(new PermissionError('Invalid webhook secret')),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const paymentWebhookTrail = trail('payment.receive', {
|
|
107
|
+
blaze: (input) => Result.ok({ paymentId: input.paymentId }),
|
|
108
|
+
input: z.object({ paymentId: z.string() }),
|
|
109
|
+
on: [paymentWebhook],
|
|
110
|
+
output: z.object({ paymentId: z.string() }),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const buildRequest = (path: string, init: RequestInit = {}): Request =>
|
|
114
|
+
new Request(new URL(path, 'http://localhost').toString(), init);
|
|
115
|
+
|
|
116
|
+
const readHeader = (
|
|
117
|
+
headers: HttpHeaderSource | undefined,
|
|
118
|
+
name: string
|
|
119
|
+
): string | undefined => {
|
|
120
|
+
if (headers === undefined) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
if (headers instanceof Headers) {
|
|
124
|
+
return headers.get(name) ?? undefined;
|
|
125
|
+
}
|
|
126
|
+
const needle = name.toLowerCase();
|
|
127
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
128
|
+
if (key.toLowerCase() !== needle || value === undefined) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
return typeof value === 'string' ? value : value[0];
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const expectJson = async (
|
|
137
|
+
response: Response
|
|
138
|
+
): Promise<Record<string, unknown>> =>
|
|
139
|
+
(await response.json()) as Record<string, unknown>;
|
|
140
|
+
|
|
141
|
+
const materialize = async (
|
|
142
|
+
adapter: HttpAdapterConformanceAdapter,
|
|
143
|
+
graph: Topo,
|
|
144
|
+
options?: HttpAdapterConformanceOptions
|
|
145
|
+
): Promise<HttpAdapterConformanceApp> =>
|
|
146
|
+
await adapter.createApp(graph, options);
|
|
147
|
+
|
|
148
|
+
const expectOkResponse = async (
|
|
149
|
+
response: Response,
|
|
150
|
+
data: Record<string, unknown>
|
|
151
|
+
): Promise<void> => {
|
|
152
|
+
expect(response.status).toBe(200);
|
|
153
|
+
expect(await expectJson(response)).toEqual({ data });
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const request = async (
|
|
157
|
+
adapter: HttpAdapterConformanceAdapter,
|
|
158
|
+
graph: Topo,
|
|
159
|
+
path: string,
|
|
160
|
+
init?: RequestInit,
|
|
161
|
+
options?: HttpAdapterConformanceOptions
|
|
162
|
+
): Promise<Response> => {
|
|
163
|
+
const app = await materialize(adapter, graph, options);
|
|
164
|
+
return await app.fetch(buildRequest(path, init));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const readRouteCase = async (
|
|
168
|
+
adapter: HttpAdapterConformanceAdapter
|
|
169
|
+
): Promise<void> => {
|
|
170
|
+
const response = await request(
|
|
171
|
+
adapter,
|
|
172
|
+
topo('http-conformance-read', { echoTrail }),
|
|
173
|
+
'/echo?message=hello'
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
await expectOkResponse(response, { reply: 'hello' });
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const writeRouteCase = async (
|
|
180
|
+
adapter: HttpAdapterConformanceAdapter
|
|
181
|
+
): Promise<void> => {
|
|
182
|
+
const response = await request(
|
|
183
|
+
adapter,
|
|
184
|
+
topo('http-conformance-write', { echoBodyTrail }),
|
|
185
|
+
'/echo/body',
|
|
186
|
+
{
|
|
187
|
+
body: JSON.stringify({ message: 'hello' }),
|
|
188
|
+
headers: { 'Content-Type': 'application/json' },
|
|
189
|
+
method: 'POST',
|
|
190
|
+
}
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
await expectOkResponse(response, { length: 5 });
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const repeatedQueryCase = async (
|
|
197
|
+
adapter: HttpAdapterConformanceAdapter
|
|
198
|
+
): Promise<void> => {
|
|
199
|
+
const graph = topo('http-conformance-query', { tagsTrail });
|
|
200
|
+
const repeated = await request(adapter, graph, '/tags?tags=red&tags=blue');
|
|
201
|
+
const singleton = await request(adapter, graph, '/tags?tags=solo');
|
|
202
|
+
|
|
203
|
+
await expectOkResponse(repeated, { tags: ['red', 'blue'] });
|
|
204
|
+
expect(singleton.status).toBe(400);
|
|
205
|
+
expect(await expectJson(singleton)).toMatchObject({
|
|
206
|
+
error: { category: 'validation' },
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const publicErrorCase = async (
|
|
211
|
+
adapter: HttpAdapterConformanceAdapter
|
|
212
|
+
): Promise<void> => {
|
|
213
|
+
const response = await request(
|
|
214
|
+
adapter,
|
|
215
|
+
topo('http-conformance-errors', { genericErrorTrail }),
|
|
216
|
+
'/generic/error',
|
|
217
|
+
{ headers: { 'X-Request-ID': 'req-123 forged/line' } }
|
|
218
|
+
);
|
|
219
|
+
const body = await expectJson(response);
|
|
220
|
+
|
|
221
|
+
expect(response.status).toBe(500);
|
|
222
|
+
expect(body).toEqual({
|
|
223
|
+
error: {
|
|
224
|
+
category: 'internal',
|
|
225
|
+
code: 'InternalError',
|
|
226
|
+
message: 'Internal server error',
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
expect(JSON.stringify(body)).not.toContain('secret');
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const requestContextCase = async (
|
|
233
|
+
adapter: HttpAdapterConformanceAdapter
|
|
234
|
+
): Promise<void> => {
|
|
235
|
+
let observedTenant: string | null | undefined;
|
|
236
|
+
const resolvePermit: ResolveHttpPermit = ({ headers }) => {
|
|
237
|
+
observedTenant = readHeader(headers, 'x-tenant-id');
|
|
238
|
+
return Result.ok({ id: 'user-1', scopes: ['thing:read'] });
|
|
239
|
+
};
|
|
240
|
+
const graph = topo('http-conformance-context', {
|
|
241
|
+
abortingTrail,
|
|
242
|
+
protectedTrail,
|
|
243
|
+
});
|
|
244
|
+
const app = await materialize(adapter, graph, { resolvePermit });
|
|
245
|
+
const controller = new AbortController();
|
|
246
|
+
controller.abort();
|
|
247
|
+
|
|
248
|
+
const permitResponse = await app.fetch(
|
|
249
|
+
buildRequest('/permit/scope', {
|
|
250
|
+
headers: {
|
|
251
|
+
Authorization: 'Bearer strong',
|
|
252
|
+
'X-Request-ID': 'req-1',
|
|
253
|
+
'X-Tenant-ID': 'tenant-1',
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
const abortResponse = await app.fetch(
|
|
258
|
+
buildRequest('/abort/check', { signal: controller.signal })
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
await expectOkResponse(permitResponse, {
|
|
262
|
+
permitId: 'user-1',
|
|
263
|
+
requestId: 'req-1',
|
|
264
|
+
});
|
|
265
|
+
expect(observedTenant).toBe('tenant-1');
|
|
266
|
+
await expectOkResponse(abortResponse, { aborted: true });
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const webhookCase = async (
|
|
270
|
+
adapter: HttpAdapterConformanceAdapter
|
|
271
|
+
): Promise<void> => {
|
|
272
|
+
const graph = topo('http-conformance-webhooks', { paymentWebhookTrail });
|
|
273
|
+
const verified = await request(adapter, graph, '/webhooks/payment', {
|
|
274
|
+
body: JSON.stringify({ paymentId: 'pay_1' }),
|
|
275
|
+
headers: {
|
|
276
|
+
'Content-Type': 'application/json',
|
|
277
|
+
'X-Webhook-Secret': webhookSecret,
|
|
278
|
+
},
|
|
279
|
+
method: 'POST',
|
|
280
|
+
});
|
|
281
|
+
const denied = await request(adapter, graph, '/webhooks/payment', {
|
|
282
|
+
body: JSON.stringify({ paymentId: 'pay_1' }),
|
|
283
|
+
headers: {
|
|
284
|
+
'Content-Type': 'application/json',
|
|
285
|
+
'X-Webhook-Secret': 'wrong',
|
|
286
|
+
},
|
|
287
|
+
method: 'POST',
|
|
288
|
+
});
|
|
289
|
+
const invalidPayload = await request(adapter, graph, '/webhooks/payment', {
|
|
290
|
+
body: JSON.stringify({ paymentId: 123 }),
|
|
291
|
+
headers: {
|
|
292
|
+
'Content-Type': 'application/json',
|
|
293
|
+
'X-Webhook-Secret': webhookSecret,
|
|
294
|
+
},
|
|
295
|
+
method: 'POST',
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await expectOkResponse(verified, { paymentId: 'pay_1' });
|
|
299
|
+
expect(denied.status).toBe(403);
|
|
300
|
+
expect(await expectJson(denied)).toMatchObject({
|
|
301
|
+
error: { category: 'permission' },
|
|
302
|
+
});
|
|
303
|
+
expect(invalidPayload.status).toBe(400);
|
|
304
|
+
expect(await expectJson(invalidPayload)).toMatchObject({
|
|
305
|
+
error: { category: 'validation' },
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
export const createHttpAdapterConformanceCases =
|
|
310
|
+
(): readonly HttpAdapterConformanceCase[] => [
|
|
311
|
+
{ check: readRouteCase, name: 'serves read trails from query parameters' },
|
|
312
|
+
{ check: writeRouteCase, name: 'serves write trails from JSON bodies' },
|
|
313
|
+
{
|
|
314
|
+
check: repeatedQueryCase,
|
|
315
|
+
name: 'preserves repeated query keys before validation',
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
check: publicErrorCase,
|
|
319
|
+
name: 'projects generic errors as redacted public 500 responses',
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
check: requestContextCase,
|
|
323
|
+
name: 'threads request context and abort signals',
|
|
324
|
+
},
|
|
325
|
+
{ check: webhookCase, name: 'handles webhook verification and parsing' },
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
export const runConformance = (
|
|
329
|
+
adapter: HttpAdapterConformanceAdapter,
|
|
330
|
+
cases: readonly HttpAdapterConformanceCase[] = createHttpAdapterConformanceCases()
|
|
331
|
+
): void => {
|
|
332
|
+
describe(`${adapter.name} HTTP adapter conformance`, () => {
|
|
333
|
+
for (const conformanceCase of cases) {
|
|
334
|
+
test(conformanceCase.name, async () => {
|
|
335
|
+
await conformanceCase.check(adapter);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
};
|