@ontrails/http 1.0.0-beta.12 → 1.0.0-beta.13
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 +51 -23
- package/README.md +10 -10
- package/dist/build.d.ts +9 -9
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +18 -18
- package/dist/build.js.map +1 -1
- package/dist/hono/blaze.js +2 -2
- package/dist/hono/blaze.js.map +1 -1
- package/dist/hono/index.d.ts +1 -1
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +1 -1
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/trailhead.d.ts +33 -0
- package/dist/hono/trailhead.d.ts.map +1 -0
- package/dist/hono/trailhead.js +232 -0
- package/dist/hono/trailhead.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/build.test.ts +45 -44
- package/src/build.ts +27 -27
- package/src/hono/__tests__/{blaze.test.ts → trailhead.test.ts} +79 -77
- package/src/hono/index.ts +1 -1
- package/src/hono/{blaze.ts → trailhead.ts} +16 -16
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono connector for Trails HTTP routes.
|
|
3
|
+
*
|
|
4
|
+
* Takes framework-agnostic HttpRouteDefinition[] and wires them into a
|
|
5
|
+
* Hono application, handling request parsing, response mapping, and errors.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const app = topo("myapp", entity);
|
|
9
|
+
* await trailhead(app, { port: 3000 });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { isTrailsError, statusCodeMap, validateTopo } from '@ontrails/core';
|
|
13
|
+
import { Hono } from 'hono';
|
|
14
|
+
import { buildHttpRoutes } from '../build.js';
|
|
15
|
+
/** Unwrap optional/default wrappers to reach the underlying Zod type name. */
|
|
16
|
+
const unwrapZodType = (node) => {
|
|
17
|
+
let current = node;
|
|
18
|
+
while (current._zod.def['type'] === 'optional' ||
|
|
19
|
+
current._zod.def['type'] === 'default') {
|
|
20
|
+
current = current._zod.def['innerType'];
|
|
21
|
+
}
|
|
22
|
+
return current._zod.def['type'];
|
|
23
|
+
};
|
|
24
|
+
/** Extract the object shape from a Zod schema, or undefined if not an object. */
|
|
25
|
+
const extractShape = (schema) => {
|
|
26
|
+
const s = schema;
|
|
27
|
+
if (s._zod.def['type'] !== 'object') {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return s._zod.def['shape'];
|
|
31
|
+
};
|
|
32
|
+
/** Collect top-level field names whose underlying type is array. */
|
|
33
|
+
const collectArrayKeys = (inputSchema) => {
|
|
34
|
+
const shape = inputSchema ? extractShape(inputSchema) : undefined;
|
|
35
|
+
if (!shape) {
|
|
36
|
+
return new Set();
|
|
37
|
+
}
|
|
38
|
+
const keys = new Set();
|
|
39
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
40
|
+
if (unwrapZodType(value) === 'array') {
|
|
41
|
+
keys.add(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return keys;
|
|
45
|
+
};
|
|
46
|
+
/** Parse query params into a plain object, preserving raw strings for Zod. */
|
|
47
|
+
const parseQueryParams = (c, inputSchema) => {
|
|
48
|
+
const result = {};
|
|
49
|
+
const url = new URL(c.req.url);
|
|
50
|
+
const arrayKeys = collectArrayKeys(inputSchema);
|
|
51
|
+
for (const key of url.searchParams.keys()) {
|
|
52
|
+
// Already collected via getAll
|
|
53
|
+
if (key in result) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const all = url.searchParams.getAll(key);
|
|
57
|
+
result[key] = all.length > 1 || arrayKeys.has(key) ? all : all[0];
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
/** Sentinel indicating a JSON parse failure. */
|
|
62
|
+
const JSON_PARSE_ERROR = Symbol('JSON_PARSE_ERROR');
|
|
63
|
+
/** Return true when the request has no body content. */
|
|
64
|
+
const isEmptyBody = (c) => {
|
|
65
|
+
const contentLength = c.req.header('Content-Length');
|
|
66
|
+
if (contentLength !== undefined) {
|
|
67
|
+
return Number.parseInt(contentLength, 10) === 0;
|
|
68
|
+
}
|
|
69
|
+
// No Content-Length header — treat as empty when Content-Type is also absent.
|
|
70
|
+
return c.req.header('Content-Type') === undefined;
|
|
71
|
+
};
|
|
72
|
+
/** Read input from request based on input source. */
|
|
73
|
+
const readInput = async (c, inputSource, inputSchema) => {
|
|
74
|
+
if (inputSource === 'query') {
|
|
75
|
+
return parseQueryParams(c, inputSchema);
|
|
76
|
+
}
|
|
77
|
+
if (isEmptyBody(c)) {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return await c.req.json();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return JSON_PARSE_ERROR;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Response mapping
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
/** Map a TrailsError or generic Error to an HTTP error response. */
|
|
91
|
+
const mapErrorResponse = (error) => {
|
|
92
|
+
if (isTrailsError(error)) {
|
|
93
|
+
return {
|
|
94
|
+
body: {
|
|
95
|
+
error: {
|
|
96
|
+
category: error.category,
|
|
97
|
+
code: error.name,
|
|
98
|
+
message: error.message,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
status: statusCodeMap[error.category],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
body: {
|
|
106
|
+
error: {
|
|
107
|
+
category: 'internal',
|
|
108
|
+
code: 'InternalError',
|
|
109
|
+
message: error.message,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
status: 500,
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Route registration
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
/** Map a Result to an HTTP response via Hono context. */
|
|
119
|
+
const mapResultToResponse = (result, c) => {
|
|
120
|
+
if (result.isOk()) {
|
|
121
|
+
return c.json({ data: result.value }, 200);
|
|
122
|
+
}
|
|
123
|
+
const { body, status } = mapErrorResponse(result.error ?? new Error('Unknown error'));
|
|
124
|
+
return c.json(body, status);
|
|
125
|
+
};
|
|
126
|
+
/** Convert a caught unknown value to an error response. */
|
|
127
|
+
const handleCaughtError = (error, c) => {
|
|
128
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
129
|
+
const { body, status } = mapErrorResponse(err);
|
|
130
|
+
return c.json(body, status);
|
|
131
|
+
};
|
|
132
|
+
/** Create a Hono handler from a route definition. */
|
|
133
|
+
const createHonoHandler = (route) => async (c) => {
|
|
134
|
+
const rawInput = await readInput(c, route.inputSource, route.trail.input);
|
|
135
|
+
if (rawInput === JSON_PARSE_ERROR) {
|
|
136
|
+
return c.json({
|
|
137
|
+
error: {
|
|
138
|
+
category: 'validation',
|
|
139
|
+
code: 'ValidationError',
|
|
140
|
+
message: 'Invalid JSON in request body',
|
|
141
|
+
},
|
|
142
|
+
}, 400);
|
|
143
|
+
}
|
|
144
|
+
const requestId = c.req.header('X-Request-ID') ?? undefined;
|
|
145
|
+
const { signal: abortSignal } = c.req.raw;
|
|
146
|
+
try {
|
|
147
|
+
const result = await route.execute(rawInput, requestId, abortSignal);
|
|
148
|
+
return mapResultToResponse(result, c);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
return handleCaughtError(error, c);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
/** Route registration keyed by HTTP method. */
|
|
155
|
+
const routeRegistrars = {
|
|
156
|
+
DELETE: (hono, path, handler) => {
|
|
157
|
+
hono.delete(path, handler);
|
|
158
|
+
},
|
|
159
|
+
GET: (hono, path, handler) => {
|
|
160
|
+
hono.get(path, handler);
|
|
161
|
+
},
|
|
162
|
+
POST: (hono, path, handler) => {
|
|
163
|
+
hono.post(path, handler);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const registerRoutes = (hono, routes) => {
|
|
167
|
+
for (const route of routes) {
|
|
168
|
+
const handler = createHonoHandler(route);
|
|
169
|
+
routeRegistrars[route.method](hono, route.path, handler);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Global error handler
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
const registerErrorHandler = (hono) => {
|
|
176
|
+
// oxlint-disable-next-line prefer-await-to-callbacks -- Hono's onError API requires a callback
|
|
177
|
+
hono.onError((err, c) => c.json({
|
|
178
|
+
error: {
|
|
179
|
+
category: 'internal',
|
|
180
|
+
code: 'InternalError',
|
|
181
|
+
message: err.message,
|
|
182
|
+
},
|
|
183
|
+
}, 500));
|
|
184
|
+
};
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Validation
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
/**
|
|
189
|
+
* Throw a ValidationError if the topo has structural issues.
|
|
190
|
+
* Pass `skip: true` to bypass validation (e.g. when `validate: false` is set).
|
|
191
|
+
*/
|
|
192
|
+
const assertValidTopo = (app, skip = false) => {
|
|
193
|
+
if (skip) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const validated = validateTopo(app);
|
|
197
|
+
if (validated.isErr()) {
|
|
198
|
+
throw validated.error;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// trailhead
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
/**
|
|
205
|
+
* Build HTTP routes from a topo, create a Hono app, and optionally start serving.
|
|
206
|
+
*/
|
|
207
|
+
// oxlint-disable-next-line require-await -- async for consistency with other trailhead() entrypoints
|
|
208
|
+
export const trailhead = async (app, options = {}) => {
|
|
209
|
+
assertValidTopo(app, options.validate === false);
|
|
210
|
+
const hono = new Hono();
|
|
211
|
+
registerErrorHandler(hono);
|
|
212
|
+
const routesResult = buildHttpRoutes(app, {
|
|
213
|
+
basePath: options.basePath,
|
|
214
|
+
configValues: options.configValues,
|
|
215
|
+
createContext: options.createContext,
|
|
216
|
+
gates: options.gates,
|
|
217
|
+
provisions: options.provisions,
|
|
218
|
+
});
|
|
219
|
+
if (routesResult.isErr()) {
|
|
220
|
+
throw routesResult.error;
|
|
221
|
+
}
|
|
222
|
+
registerRoutes(hono, routesResult.value);
|
|
223
|
+
if (options.serve !== false) {
|
|
224
|
+
Bun.serve({
|
|
225
|
+
fetch: hono.fetch,
|
|
226
|
+
hostname: options.hostname ?? '0.0.0.0',
|
|
227
|
+
port: options.port ?? 3000,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return hono;
|
|
231
|
+
};
|
|
232
|
+
//# sourceMappingURL=trailhead.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trailhead.js","sourceRoot":"","sources":["../../src/hono/trailhead.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -4,13 +4,13 @@ import {
|
|
|
4
4
|
InternalError,
|
|
5
5
|
NotFoundError,
|
|
6
6
|
Result,
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
TRAILHEAD_KEY,
|
|
8
|
+
provision,
|
|
9
9
|
ValidationError,
|
|
10
10
|
trail,
|
|
11
11
|
topo,
|
|
12
12
|
} from '@ontrails/core';
|
|
13
|
-
import type {
|
|
13
|
+
import type { Gate } from '@ontrails/core';
|
|
14
14
|
import { z } from 'zod';
|
|
15
15
|
|
|
16
16
|
import { buildHttpRoutes } from '../build.js';
|
|
@@ -20,48 +20,48 @@ import { buildHttpRoutes } from '../build.js';
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
|
|
22
22
|
const echoTrail = trail('echo', {
|
|
23
|
+
blaze: (input) => Result.ok({ reply: input.message }),
|
|
23
24
|
description: 'Echo a message back',
|
|
24
25
|
input: z.object({ message: z.string() }),
|
|
25
26
|
intent: 'read',
|
|
26
27
|
output: z.object({ reply: z.string() }),
|
|
27
|
-
run: (input) => Result.ok({ reply: input.message }),
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
const createTrail = trail('item.create', {
|
|
31
|
+
blaze: (input) => Result.ok({ id: '123', name: input.name }),
|
|
31
32
|
description: 'Create an item',
|
|
32
33
|
input: z.object({ name: z.string() }),
|
|
33
34
|
output: z.object({ id: z.string(), name: z.string() }),
|
|
34
|
-
run: (input) => Result.ok({ id: '123', name: input.name }),
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
const deleteTrail = trail('item.delete', {
|
|
38
|
+
blaze: (_input) => Result.ok({ deleted: true }),
|
|
38
39
|
description: 'Delete an item',
|
|
39
40
|
input: z.object({ id: z.string() }),
|
|
40
41
|
intent: 'destroy',
|
|
41
|
-
run: (_input) => Result.ok({ deleted: true }),
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
const notFoundTrail = trail('item.get', {
|
|
45
|
+
blaze: (_input) => Result.err(new NotFoundError('Item not found')),
|
|
45
46
|
description: 'Get an item that does not exist',
|
|
46
47
|
input: z.object({ id: z.string() }),
|
|
47
48
|
intent: 'read',
|
|
48
|
-
run: (_input) => Result.err(new NotFoundError('Item not found')),
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
const internalTrail = trail('crash', {
|
|
52
|
+
blaze: () => Result.err(new InternalError('Something broke')),
|
|
52
53
|
description: 'Always fails with internal error',
|
|
53
54
|
input: z.object({}),
|
|
54
|
-
run: () => Result.err(new InternalError('Something broke')),
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
const internalMetaTrail = trail('secret', {
|
|
58
|
+
blaze: () => Result.ok({ ok: true }),
|
|
58
59
|
description: 'Internal trail that should be skipped',
|
|
59
60
|
input: z.object({}),
|
|
60
|
-
|
|
61
|
-
run: () => Result.ok({ ok: true }),
|
|
61
|
+
meta: { internal: true },
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
const
|
|
64
|
+
const dbProvision = provision('db.main', {
|
|
65
65
|
create: () =>
|
|
66
66
|
Result.ok({
|
|
67
67
|
source: 'factory',
|
|
@@ -245,10 +245,10 @@ describe('buildHttpRoutes', () => {
|
|
|
245
245
|
|
|
246
246
|
test('returns err Result when run function throws', async () => {
|
|
247
247
|
const throwingTrail = trail('throwing', {
|
|
248
|
-
|
|
249
|
-
run: () => {
|
|
248
|
+
blaze: () => {
|
|
250
249
|
throw new Error('unexpected throw');
|
|
251
250
|
},
|
|
251
|
+
input: z.object({}),
|
|
252
252
|
});
|
|
253
253
|
const app = topo('testapp', { throwingTrail });
|
|
254
254
|
const buildResult = buildHttpRoutes(app);
|
|
@@ -283,12 +283,12 @@ describe('buildHttpRoutes', () => {
|
|
|
283
283
|
let capturedRequestId: string | undefined;
|
|
284
284
|
|
|
285
285
|
const ctxTrail = trail('ctx.check', {
|
|
286
|
-
|
|
287
|
-
intent: 'read',
|
|
288
|
-
run: (_input, ctx) => {
|
|
286
|
+
blaze: (_input, ctx) => {
|
|
289
287
|
capturedRequestId = ctx.requestId;
|
|
290
288
|
return Result.ok({ ok: true });
|
|
291
289
|
},
|
|
290
|
+
input: z.object({}),
|
|
291
|
+
intent: 'read',
|
|
292
292
|
});
|
|
293
293
|
|
|
294
294
|
const app = topo('testapp', { ctxTrail });
|
|
@@ -305,12 +305,12 @@ describe('buildHttpRoutes', () => {
|
|
|
305
305
|
let capturedRequestId: string | undefined;
|
|
306
306
|
|
|
307
307
|
const ctxTrail = trail('ctx.default', {
|
|
308
|
-
|
|
309
|
-
intent: 'read',
|
|
310
|
-
run: (_input, ctx) => {
|
|
308
|
+
blaze: (_input, ctx) => {
|
|
311
309
|
capturedRequestId = ctx.requestId;
|
|
312
310
|
return Result.ok({ ok: true });
|
|
313
311
|
},
|
|
312
|
+
input: z.object({}),
|
|
313
|
+
intent: 'read',
|
|
314
314
|
});
|
|
315
315
|
|
|
316
316
|
const app = topo('testapp', { ctxTrail });
|
|
@@ -324,18 +324,18 @@ describe('buildHttpRoutes', () => {
|
|
|
324
324
|
expect(capturedRequestId).not.toBe('');
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
-
test('forwards
|
|
328
|
-
const
|
|
327
|
+
test('forwards provision overrides into executeTrail', async () => {
|
|
328
|
+
const provisionTrail = trail('provision.check', {
|
|
329
|
+
blaze: (_input, ctx) =>
|
|
330
|
+
Result.ok({ source: dbProvision.from(ctx).source as string }),
|
|
329
331
|
input: z.object({}),
|
|
330
332
|
output: z.object({ source: z.string() }),
|
|
331
|
-
|
|
332
|
-
Result.ok({ source: dbService.from(ctx).source as string }),
|
|
333
|
-
services: [dbService],
|
|
333
|
+
provisions: [dbProvision],
|
|
334
334
|
});
|
|
335
335
|
|
|
336
|
-
const app = topo('testapp', {
|
|
336
|
+
const app = topo('testapp', { provisionTrail });
|
|
337
337
|
const buildResult = buildHttpRoutes(app, {
|
|
338
|
-
|
|
338
|
+
provisions: { 'db.main': { source: 'override' } },
|
|
339
339
|
});
|
|
340
340
|
|
|
341
341
|
expect(buildResult.isOk()).toBe(true);
|
|
@@ -347,12 +347,12 @@ describe('buildHttpRoutes', () => {
|
|
|
347
347
|
});
|
|
348
348
|
});
|
|
349
349
|
|
|
350
|
-
describe('
|
|
351
|
-
test('
|
|
350
|
+
describe('gates', () => {
|
|
351
|
+
test('gates compose around trail execution', async () => {
|
|
352
352
|
const calls: string[] = [];
|
|
353
353
|
|
|
354
|
-
const
|
|
355
|
-
name: 'test-
|
|
354
|
+
const testGate: Gate = {
|
|
355
|
+
name: 'test-gate',
|
|
356
356
|
wrap(_trail, impl) {
|
|
357
357
|
return async (input, ctx) => {
|
|
358
358
|
calls.push('before');
|
|
@@ -364,7 +364,7 @@ describe('buildHttpRoutes', () => {
|
|
|
364
364
|
};
|
|
365
365
|
|
|
366
366
|
const app = topo('testapp', { echoTrail });
|
|
367
|
-
const buildResult = buildHttpRoutes(app, {
|
|
367
|
+
const buildResult = buildHttpRoutes(app, { gates: [testGate] });
|
|
368
368
|
|
|
369
369
|
expect(buildResult.isOk()).toBe(true);
|
|
370
370
|
const [route] = buildResult.value;
|
|
@@ -377,24 +377,25 @@ describe('buildHttpRoutes', () => {
|
|
|
377
377
|
|
|
378
378
|
describe('custom createContext', () => {
|
|
379
379
|
test('custom createContext is used when provided', async () => {
|
|
380
|
-
const contextState = { custom: false,
|
|
380
|
+
const contextState = { custom: false, trailheadMarker: false };
|
|
381
381
|
|
|
382
382
|
const ctxTrail = trail('ctx.custom', {
|
|
383
|
-
|
|
384
|
-
intent: 'read',
|
|
385
|
-
run: (_input, ctx) => {
|
|
383
|
+
blaze: (_input, ctx) => {
|
|
386
384
|
contextState.custom = ctx.extensions?.['custom'] === true;
|
|
387
|
-
contextState.
|
|
385
|
+
contextState.trailheadMarker =
|
|
386
|
+
ctx.extensions?.[TRAILHEAD_KEY] === 'http';
|
|
388
387
|
return Result.ok({ ok: true });
|
|
389
388
|
},
|
|
389
|
+
input: z.object({}),
|
|
390
|
+
intent: 'read',
|
|
390
391
|
});
|
|
391
392
|
|
|
392
393
|
const app = topo('testapp', { ctxTrail });
|
|
393
394
|
const buildResult = buildHttpRoutes(app, {
|
|
394
395
|
createContext: () => ({
|
|
396
|
+
abortSignal: new AbortController().signal,
|
|
395
397
|
extensions: { custom: true },
|
|
396
398
|
requestId: 'test-id',
|
|
397
|
-
signal: new AbortController().signal,
|
|
398
399
|
}),
|
|
399
400
|
});
|
|
400
401
|
|
|
@@ -404,7 +405,7 @@ describe('buildHttpRoutes', () => {
|
|
|
404
405
|
const result = await route?.execute({});
|
|
405
406
|
expect(result?.isOk()).toBe(true);
|
|
406
407
|
expect(contextState.custom).toBe(true);
|
|
407
|
-
expect(contextState.
|
|
408
|
+
expect(contextState.trailheadMarker).toBe(true);
|
|
408
409
|
});
|
|
409
410
|
});
|
|
410
411
|
|
|
@@ -414,16 +415,16 @@ describe('buildHttpRoutes', () => {
|
|
|
414
415
|
// "entity/show" derives path /entity/show (slashes are preserved)
|
|
415
416
|
// Both have intent: read -> GET, so they collide on GET /entity/show
|
|
416
417
|
const dotTrail = trail('entity.show', {
|
|
418
|
+
blaze: () => Result.ok({ dot: true }),
|
|
417
419
|
description: 'Show entity (dot notation)',
|
|
418
420
|
input: z.object({}),
|
|
419
421
|
intent: 'read',
|
|
420
|
-
run: () => Result.ok({ dot: true }),
|
|
421
422
|
});
|
|
422
423
|
const slashTrail = trail('entity/show', {
|
|
424
|
+
blaze: () => Result.ok({ slash: true }),
|
|
423
425
|
description: 'Show entity (slash notation)',
|
|
424
426
|
input: z.object({}),
|
|
425
427
|
intent: 'read',
|
|
426
|
-
run: () => Result.ok({ slash: true }),
|
|
427
428
|
});
|
|
428
429
|
const app = topo('testapp', { dotTrail, slashTrail });
|
|
429
430
|
const result = buildHttpRoutes(app);
|
|
@@ -438,15 +439,15 @@ describe('buildHttpRoutes', () => {
|
|
|
438
439
|
// "item/resource" derives POST /item/resource (default intent: write)
|
|
439
440
|
// Same path, different methods — no collision
|
|
440
441
|
const getItem = trail('item.resource', {
|
|
442
|
+
blaze: () => Result.ok({ get: true }),
|
|
441
443
|
description: 'Get item',
|
|
442
444
|
input: z.object({}),
|
|
443
445
|
intent: 'read',
|
|
444
|
-
run: () => Result.ok({ get: true }),
|
|
445
446
|
});
|
|
446
447
|
const createItem = trail('item/resource', {
|
|
448
|
+
blaze: () => Result.ok({ created: true }),
|
|
447
449
|
description: 'Create item',
|
|
448
450
|
input: z.object({ name: z.string() }),
|
|
449
|
-
run: () => Result.ok({ created: true }),
|
|
450
451
|
});
|
|
451
452
|
const app = topo('testapp', { createItem, getItem });
|
|
452
453
|
const result = buildHttpRoutes(app);
|
|
@@ -457,16 +458,16 @@ describe('buildHttpRoutes', () => {
|
|
|
457
458
|
|
|
458
459
|
test('collision error message identifies both trail IDs', () => {
|
|
459
460
|
const dotTrail = trail('entity.show', {
|
|
461
|
+
blaze: () => Result.ok({ one: true }),
|
|
460
462
|
description: 'Trail one',
|
|
461
463
|
input: z.object({}),
|
|
462
464
|
intent: 'read',
|
|
463
|
-
run: () => Result.ok({ one: true }),
|
|
464
465
|
});
|
|
465
466
|
const slashTrail = trail('entity/show', {
|
|
467
|
+
blaze: () => Result.ok({ two: true }),
|
|
466
468
|
description: 'Trail two',
|
|
467
469
|
input: z.object({}),
|
|
468
470
|
intent: 'read',
|
|
469
|
-
run: () => Result.ok({ two: true }),
|
|
470
471
|
});
|
|
471
472
|
const app = topo('testapp', { dotTrail, slashTrail });
|
|
472
473
|
const result = buildHttpRoutes(app);
|
package/src/build.ts
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
* Build framework-agnostic HTTP route definitions from a Trails topo.
|
|
3
3
|
*
|
|
4
4
|
* Each route definition describes the path, method, input source, and an
|
|
5
|
-
* `execute` function that validates input, composes
|
|
5
|
+
* `execute` function that validates input, composes gates, and runs the
|
|
6
6
|
* implementation -- all without referencing any HTTP framework types.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
Result,
|
|
11
|
-
|
|
11
|
+
TRAILHEAD_KEY,
|
|
12
12
|
ValidationError,
|
|
13
13
|
executeTrail,
|
|
14
14
|
} from '@ontrails/core';
|
|
15
15
|
import type {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
Gate,
|
|
17
|
+
ProvisionOverrideMap,
|
|
18
18
|
Topo,
|
|
19
19
|
Trail,
|
|
20
20
|
TrailContextInit,
|
|
@@ -26,15 +26,15 @@ import type {
|
|
|
26
26
|
|
|
27
27
|
export interface BuildHttpRoutesOptions {
|
|
28
28
|
readonly basePath?: string | undefined;
|
|
29
|
-
/** Config values for
|
|
29
|
+
/** Config values for provisions that declare a `config` schema, keyed by provision ID. */
|
|
30
30
|
readonly configValues?:
|
|
31
31
|
| Readonly<Record<string, Record<string, unknown>>>
|
|
32
32
|
| undefined;
|
|
33
33
|
readonly createContext?:
|
|
34
34
|
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
35
35
|
| undefined;
|
|
36
|
-
readonly
|
|
37
|
-
readonly
|
|
36
|
+
readonly gates?: readonly Gate[] | undefined;
|
|
37
|
+
readonly provisions?: ProvisionOverrideMap | undefined;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export type HttpMethod = 'GET' | 'POST' | 'DELETE';
|
|
@@ -49,19 +49,19 @@ export interface HttpRouteDefinition {
|
|
|
49
49
|
readonly inputSource: InputSource;
|
|
50
50
|
readonly trail: Trail<unknown, unknown>;
|
|
51
51
|
/**
|
|
52
|
-
* Validate input, compose
|
|
52
|
+
* Validate input, compose gates, and execute the trail implementation.
|
|
53
53
|
*
|
|
54
54
|
* The caller is responsible for parsing raw input from the request and
|
|
55
55
|
* mapping the Result to an HTTP response. This function is framework-agnostic.
|
|
56
56
|
*
|
|
57
|
-
* @param
|
|
57
|
+
* @param abortSignal - Optional AbortSignal from the HTTP request. When provided,
|
|
58
58
|
* it takes final precedence over any context factory signal, allowing
|
|
59
59
|
* client-initiated cancellation to propagate into trail execution.
|
|
60
60
|
*/
|
|
61
61
|
readonly execute: (
|
|
62
62
|
input: unknown,
|
|
63
63
|
requestId?: string | undefined,
|
|
64
|
-
|
|
64
|
+
abortSignal?: AbortSignal | undefined
|
|
65
65
|
) => Promise<Result<unknown, Error>>;
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -93,15 +93,15 @@ const deriveInputSource = (method: HttpMethod): InputSource =>
|
|
|
93
93
|
|
|
94
94
|
/** Check if a trail should be included (skip internal trails). */
|
|
95
95
|
const shouldInclude = (trail: Trail<unknown, unknown>): boolean =>
|
|
96
|
-
trail.
|
|
96
|
+
trail.meta?.['internal'] !== true;
|
|
97
97
|
|
|
98
|
-
/** Build per-request context overrides with the HTTP
|
|
99
|
-
const
|
|
98
|
+
/** Build per-request context overrides with the HTTP trailhead marker. */
|
|
99
|
+
const withHttpTrailhead = (
|
|
100
100
|
requestId: string | undefined
|
|
101
101
|
): Partial<TrailContextInit> => ({
|
|
102
102
|
...(requestId === undefined ? {} : { requestId }),
|
|
103
103
|
extensions: {
|
|
104
|
-
[
|
|
104
|
+
[TRAILHEAD_KEY]: 'http' as const,
|
|
105
105
|
},
|
|
106
106
|
});
|
|
107
107
|
|
|
@@ -118,17 +118,17 @@ const withHttpSurface = (
|
|
|
118
118
|
const createExecute =
|
|
119
119
|
(
|
|
120
120
|
t: Trail<unknown, unknown>,
|
|
121
|
-
|
|
121
|
+
gates: readonly Gate[],
|
|
122
122
|
options: BuildHttpRoutesOptions
|
|
123
123
|
): HttpRouteDefinition['execute'] =>
|
|
124
|
-
(input, requestId,
|
|
124
|
+
(input, requestId, abortSignal) =>
|
|
125
125
|
executeTrail(t, input, {
|
|
126
|
+
abortSignal,
|
|
126
127
|
configValues: options.configValues,
|
|
127
128
|
createContext: options.createContext,
|
|
128
|
-
ctx:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
signal,
|
|
129
|
+
ctx: withHttpTrailhead(requestId),
|
|
130
|
+
gates,
|
|
131
|
+
provisions: options.provisions,
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
// ---------------------------------------------------------------------------
|
|
@@ -143,13 +143,13 @@ const eligibleTrails = (app: Topo): Trail<unknown, unknown>[] =>
|
|
|
143
143
|
const buildRoute = (
|
|
144
144
|
trail: Trail<unknown, unknown>,
|
|
145
145
|
basePath: string,
|
|
146
|
-
|
|
146
|
+
gates: readonly Gate[],
|
|
147
147
|
options: BuildHttpRoutesOptions
|
|
148
148
|
): HttpRouteDefinition => {
|
|
149
149
|
const method = deriveMethod(trail);
|
|
150
150
|
const path = derivePath(basePath, trail.id);
|
|
151
151
|
return {
|
|
152
|
-
execute: createExecute(trail,
|
|
152
|
+
execute: createExecute(trail, gates, options),
|
|
153
153
|
inputSource: deriveInputSource(method),
|
|
154
154
|
method,
|
|
155
155
|
path,
|
|
@@ -190,14 +190,14 @@ const registerRoute = (
|
|
|
190
190
|
const accumulateRoutes = (
|
|
191
191
|
trails: Trail<unknown, unknown>[],
|
|
192
192
|
basePath: string,
|
|
193
|
-
|
|
193
|
+
gates: readonly Gate[],
|
|
194
194
|
options: BuildHttpRoutesOptions
|
|
195
195
|
): Result<HttpRouteDefinition[], Error> => {
|
|
196
196
|
const routes: HttpRouteDefinition[] = [];
|
|
197
197
|
const seenRoutes = new Map<string, string>();
|
|
198
198
|
|
|
199
199
|
for (const trail of trails) {
|
|
200
|
-
const route = buildRoute(trail, basePath,
|
|
200
|
+
const route = buildRoute(trail, basePath, gates, options);
|
|
201
201
|
const registered = registerRoute(route, seenRoutes, routes);
|
|
202
202
|
if (registered.isErr()) {
|
|
203
203
|
return registered;
|
|
@@ -218,7 +218,7 @@ const accumulateRoutes = (
|
|
|
218
218
|
* - An HTTP method derived from intent (read -> GET, write -> POST, destroy -> DELETE)
|
|
219
219
|
* - A path derived from the trail ID (dots become slashes)
|
|
220
220
|
* - An input source derived from the method (GET -> query, others -> body)
|
|
221
|
-
* - An `execute` function that validates,
|
|
221
|
+
* - An `execute` function that validates, gates, and runs the implementation
|
|
222
222
|
*
|
|
223
223
|
* Returns `Result.err(ValidationError)` if two trails derive the same
|
|
224
224
|
* (method, path) pair. Returns `Result.ok(routes)` on success.
|
|
@@ -228,6 +228,6 @@ export const buildHttpRoutes = (
|
|
|
228
228
|
options: BuildHttpRoutesOptions = {}
|
|
229
229
|
): Result<HttpRouteDefinition[], Error> => {
|
|
230
230
|
const basePath = (options.basePath ?? '').replace(/\/+$/, '');
|
|
231
|
-
const
|
|
232
|
-
return accumulateRoutes(eligibleTrails(app), basePath,
|
|
231
|
+
const gates = options.gates ?? [];
|
|
232
|
+
return accumulateRoutes(eligibleTrails(app), basePath, gates, options);
|
|
233
233
|
};
|