@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/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# @ontrails/http
|
|
2
|
+
|
|
3
|
+
Framework-agnostic HTTP route derivation and Web Fetch request handling for Trails. Pair this package with `@ontrails/hono` when you want Hono portability, or use `@ontrails/http/bun` when you want Bun-native serving without a third-party framework.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { trail, topo, Result } from '@ontrails/core';
|
|
9
|
+
import { surface } from '@ontrails/hono';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
const greet = trail('greet', {
|
|
13
|
+
input: z.object({ name: z.string().describe('Who to greet') }),
|
|
14
|
+
output: z.object({ message: z.string() }),
|
|
15
|
+
intent: 'read',
|
|
16
|
+
implementation: (input) => Result.ok({ message: `Hello, ${input.name}!` }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const graph = topo('myapp', { greet });
|
|
20
|
+
await surface(graph, { port: 3000 });
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This starts a Hono-based HTTP server. The `greet` trail becomes `GET /greet?name=...` because its `intent` is `'read'`.
|
|
24
|
+
|
|
25
|
+
For Bun-native HTTP without Hono, use the Bun-native HTTP binding subpath:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { surface } from '@ontrails/http/bun';
|
|
29
|
+
|
|
30
|
+
await surface(graph, { port: 3000 });
|
|
31
|
+
```
|
|
32
|
+
|
|
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.
|
|
34
|
+
|
|
35
|
+
## Rendering and runtime binding
|
|
36
|
+
|
|
37
|
+
The HTTP package follows the surface API naming split:
|
|
38
|
+
|
|
39
|
+
- `derive*` exports are pure renderings from the topo. Use `deriveHttpRoutes()` for route definitions and `deriveOpenApiSpec()` for the OpenAPI contract.
|
|
40
|
+
- `create*` exports build runtime objects without opening a network boundary.
|
|
41
|
+
`@ontrails/http/fetch` exports `createRouteHandler()` for one route and
|
|
42
|
+
`createFetchHandler()` for a full topo dispatcher.
|
|
43
|
+
- `surface()` opens the runtime boundary. `@ontrails/hono` opens an adapter
|
|
44
|
+
binding to Hono; `@ontrails/http/bun` opens the native Bun HTTP binding.
|
|
45
|
+
|
|
46
|
+
The shared `@ontrails/http/fetch` kernel owns query/body parsing, content-length validation, public error rendering, diagnostics, request IDs, headers, abort propagation, and webhook verification/parsing behavior. Hono and Bun both consume that kernel so route semantics stay aligned.
|
|
47
|
+
|
|
48
|
+
For more control, build the routes yourself:
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { deriveHttpRoutes } from '@ontrails/http';
|
|
52
|
+
|
|
53
|
+
const result = deriveHttpRoutes(graph);
|
|
54
|
+
if (result.isErr()) throw result.error; // ValidationError on route collision
|
|
55
|
+
for (const route of result.value) {
|
|
56
|
+
console.log(`${route.method} ${route.path} → ${route.trailId}`);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`deriveHttpRoutes` returns `Result<HttpRouteDefinition[], Error>` rather than a bare array. It returns `Result.err(ValidationError)` if two trails derive the same `(method, path)` pair.
|
|
61
|
+
|
|
62
|
+
OpenAPI is the HTTP surface's persisted client contract rendering:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { deriveOpenApiSpec } from '@ontrails/http';
|
|
66
|
+
|
|
67
|
+
const spec = deriveOpenApiSpec(graph, { basePath: '/api' });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`deriveOpenApiSpec()` emits an OpenAPI 3.1 document from the same trail contracts used by `deriveHttpRoutes()`.
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
| Export | What it does |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| `deriveHttpRoutes(graph, options?)` | Build framework-agnostic route definitions from a topo |
|
|
77
|
+
| `deriveOpenApiSpec(graph, options?)` | Generate an OpenAPI 3.1 document for the HTTP surface |
|
|
78
|
+
| `@ontrails/http/fetch` | Shared Web Fetch `createRouteHandler()` and `createFetchHandler()` kernel |
|
|
79
|
+
| `@ontrails/http/bun` | Bun-native `createApp()` and `surface()` binding |
|
|
80
|
+
| `@ontrails/http/testing` | Owner-owned adapter conformance factory for HTTP adapter authors |
|
|
81
|
+
|
|
82
|
+
## Adapter authoring
|
|
83
|
+
|
|
84
|
+
HTTP adapter authors should validate adapters through the owner-owned testing subpath instead of copying conformance behavior into each adapter:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import {
|
|
88
|
+
createHttpAdapterConformanceCases,
|
|
89
|
+
runConformance,
|
|
90
|
+
} from '@ontrails/http/testing';
|
|
91
|
+
import { myHttpAdapter } from './adapter.js';
|
|
92
|
+
|
|
93
|
+
runConformance(myHttpAdapter, createHttpAdapterConformanceCases());
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
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 rendering, validation envelopes, public error redaction, request context, abort propagation, and webhook verification/parsing behavior.
|
|
97
|
+
|
|
98
|
+
## Route derivation
|
|
99
|
+
|
|
100
|
+
Trail intent maps directly to HTTP method and input source:
|
|
101
|
+
|
|
102
|
+
| Trail field | HTTP method | Input source |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| `intent: 'read'` | `GET` | Query string |
|
|
105
|
+
| `intent: 'write'` | `POST` | JSON body |
|
|
106
|
+
| `intent: 'destroy'` | `DELETE` | JSON body |
|
|
107
|
+
| (none) | `POST` | JSON body |
|
|
108
|
+
|
|
109
|
+
Trail IDs map to paths: `entity.show` becomes `/entity/show`. Dots become slashes, everything lowercase.
|
|
110
|
+
|
|
111
|
+
## Collision detection
|
|
112
|
+
|
|
113
|
+
`deriveHttpRoutes` detects when two trails would produce the same `(method, path)` pair and returns `Result.err(ValidationError)` describing both trail IDs. The `surface()` helper from `@ontrails/hono` throws on collision.
|
|
114
|
+
|
|
115
|
+
## Resource resolution
|
|
116
|
+
|
|
117
|
+
Declared resources on each trail are resolved into the context before the implementation receives input.
|
|
118
|
+
|
|
119
|
+
## Filtering
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
const result = deriveHttpRoutes(graph, {
|
|
123
|
+
include: ['entity.**'],
|
|
124
|
+
exclude: ['dev.**'],
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`*` matches one dotted segment and `**` matches any depth. Trails declared with `visibility: 'internal'` stay hidden unless you include their exact trail ID intentionally.
|
|
129
|
+
|
|
130
|
+
## Request context and abort propagation
|
|
131
|
+
|
|
132
|
+
The `execute` function on each `HttpRouteDefinition` accepts optional `requestId`, `abortSignal`, and request context arguments. HTTP adapters should pass the request's `AbortSignal` so client disconnects propagate into trail execution, and pass headers in the request context when Bearer auth should resolve into `ctx.permit`.
|
|
133
|
+
|
|
134
|
+
## `HttpRouteDefinition`
|
|
135
|
+
|
|
136
|
+
Each route definition produced by `deriveHttpRoutes` includes:
|
|
137
|
+
|
|
138
|
+
| Field | Type | What it is |
|
|
139
|
+
| --- | --- | --- |
|
|
140
|
+
| `method` | `'GET' \| 'POST' \| 'DELETE'` | HTTP method |
|
|
141
|
+
| `path` | `string` | Derived path (e.g. `/entity/show`) |
|
|
142
|
+
| `trailId` | `string` | The trail ID this route was derived from |
|
|
143
|
+
| `inputSource` | `'query' \| 'body'` | Where to read input |
|
|
144
|
+
| `trail` | `Trail` | The original trail definition |
|
|
145
|
+
| `execute` | `(input, requestId?, abortSignal?, context?) => Promise<Result>` | Validates, layers, resolves request auth when configured, and runs the trail |
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
GET query values declared as numbers or booleans are converted at the HTTP boundary before schema validation. This includes primitive literals and union or nullable schemas whose non-null branches all resolve to the same primitive kind. Root object unions convert a field only when every branch that has its required fields present explicitly owns the field with the same primitive shape; otherwise the raw value is preserved. This keeps unknown and passthrough fields unchanged without choosing a union branch. Fields authored with Zod coercion receive the raw query value so their authored parser retains the same behavior as direct and library invocation; if any supported union branch for a field uses coercion, the boundary conservatively preserves that field. Numbers use JSON number syntax and must be finite; booleans accept the exact spellings `true` and `false`. Malformed values and the string `null` continue through normal validation and return a `400` response when the authored schema rejects them. Declared strings remain strings. Repeated keys for declared primitive arrays, including homogeneous union or nullable array schemas, apply the same conversion to each element, while a singleton remains a scalar and must satisfy the authored schema as-is.
|
|
150
|
+
|
|
151
|
+
For versioned trails, query conversion resolves the selected version's input schema. `X-Trails-Version` and `X-Trail-Version` headers take precedence over the `trailVersion` query field, matching execution. If a historical input field conflicts with a layer parameter name rendered for the current version, the boundary preserves raw query strings so it does not guess which schema owns the field.
|
|
152
|
+
|
|
153
|
+
## Installation
|
|
154
|
+
|
|
155
|
+
These commands target stable `0.2.0`. Run them after that version is published to npm.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
bun add --exact @ontrails/http@0.2.0 @ontrails/hono@0.2.0
|
|
159
|
+
# or, for Bun-native serving:
|
|
160
|
+
bun add --exact @ontrails/http@0.2.0
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Migration
|
|
164
|
+
|
|
165
|
+
Hono integration now lives in `@ontrails/hono`.
|
|
166
|
+
|
|
167
|
+
<!-- warden-ignore-next-line -->
|
|
168
|
+
- Replace `import { trailhead } from '@ontrails/http/hono'` with `import { surface } from '@ontrails/hono'`
|
|
169
|
+
- Keep `deriveHttpRoutes()` and the route model imports on `@ontrails/http`
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ontrails/http",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/outfitter-dev/trails.git",
|
|
7
|
+
"directory": "packages/http"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/**/*.ts",
|
|
11
|
+
"!src/**/__tests__/**",
|
|
12
|
+
"!src/**/*.test.ts",
|
|
13
|
+
"!src/**/*.test-d.ts",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts",
|
|
20
|
+
"./bun": "./src/bun.ts",
|
|
21
|
+
"./fetch": "./src/fetch.ts",
|
|
22
|
+
"./testing": "./src/testing.ts",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -b",
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"lint": "oxlint ./src",
|
|
30
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@ontrails/core": "^0.2.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"zod": "^4.3.5"
|
|
37
|
+
},
|
|
38
|
+
"trails": {
|
|
39
|
+
"adapters": {
|
|
40
|
+
"./bun": {
|
|
41
|
+
"target": "http"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"adapterTargets": {
|
|
45
|
+
"http": {
|
|
46
|
+
"conformance": {
|
|
47
|
+
"adapterType": "HttpAdapterConformanceAdapter",
|
|
48
|
+
"casesFactory": "createHttpAdapterConformanceCases",
|
|
49
|
+
"runner": "runConformance"
|
|
50
|
+
},
|
|
51
|
+
"placements": [
|
|
52
|
+
"extracted",
|
|
53
|
+
"subpath"
|
|
54
|
+
],
|
|
55
|
+
"testingImport": "@ontrails/http/testing"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared recognition for BlobRef trail output schemas.
|
|
3
|
+
*
|
|
4
|
+
* The runtime handler (`fetch.ts`) and the OpenAPI derivation
|
|
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 derivation.
|
|
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
|
+
};
|