@ontrails/http 1.0.0-beta.12

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.
@@ -0,0 +1,478 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ InternalError,
5
+ NotFoundError,
6
+ Result,
7
+ SURFACE_KEY,
8
+ service,
9
+ ValidationError,
10
+ trail,
11
+ topo,
12
+ } from '@ontrails/core';
13
+ import type { Layer } from '@ontrails/core';
14
+ import { z } from 'zod';
15
+
16
+ import { buildHttpRoutes } from '../build.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Test trails
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const echoTrail = trail('echo', {
23
+ description: 'Echo a message back',
24
+ input: z.object({ message: z.string() }),
25
+ intent: 'read',
26
+ output: z.object({ reply: z.string() }),
27
+ run: (input) => Result.ok({ reply: input.message }),
28
+ });
29
+
30
+ const createTrail = trail('item.create', {
31
+ description: 'Create an item',
32
+ input: z.object({ name: z.string() }),
33
+ output: z.object({ id: z.string(), name: z.string() }),
34
+ run: (input) => Result.ok({ id: '123', name: input.name }),
35
+ });
36
+
37
+ const deleteTrail = trail('item.delete', {
38
+ description: 'Delete an item',
39
+ input: z.object({ id: z.string() }),
40
+ intent: 'destroy',
41
+ run: (_input) => Result.ok({ deleted: true }),
42
+ });
43
+
44
+ const notFoundTrail = trail('item.get', {
45
+ description: 'Get an item that does not exist',
46
+ input: z.object({ id: z.string() }),
47
+ intent: 'read',
48
+ run: (_input) => Result.err(new NotFoundError('Item not found')),
49
+ });
50
+
51
+ const internalTrail = trail('crash', {
52
+ description: 'Always fails with internal error',
53
+ input: z.object({}),
54
+ run: () => Result.err(new InternalError('Something broke')),
55
+ });
56
+
57
+ const internalMetaTrail = trail('secret', {
58
+ description: 'Internal trail that should be skipped',
59
+ input: z.object({}),
60
+ metadata: { internal: true },
61
+ run: () => Result.ok({ ok: true }),
62
+ });
63
+
64
+ const dbService = service('db.main', {
65
+ create: () =>
66
+ Result.ok({
67
+ source: 'factory',
68
+ }),
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Tests
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe('buildHttpRoutes', () => {
76
+ describe('method derivation', () => {
77
+ test('intent: read maps to GET', () => {
78
+ const app = topo('testapp', { echoTrail });
79
+ const result = buildHttpRoutes(app);
80
+
81
+ expect(result.isOk()).toBe(true);
82
+ const routes = result.value;
83
+ expect(routes).toHaveLength(1);
84
+ expect(routes[0]?.method).toBe('GET');
85
+ });
86
+
87
+ test('intent: destroy maps to DELETE', () => {
88
+ const app = topo('testapp', { deleteTrail });
89
+ const result = buildHttpRoutes(app);
90
+
91
+ expect(result.isOk()).toBe(true);
92
+ const routes = result.value;
93
+ expect(routes).toHaveLength(1);
94
+ expect(routes[0]?.method).toBe('DELETE');
95
+ });
96
+
97
+ test('default intent (write) maps to POST', () => {
98
+ const app = topo('testapp', { createTrail });
99
+ const result = buildHttpRoutes(app);
100
+
101
+ expect(result.isOk()).toBe(true);
102
+ const routes = result.value;
103
+ expect(routes).toHaveLength(1);
104
+ expect(routes[0]?.method).toBe('POST');
105
+ });
106
+ });
107
+
108
+ describe('path derivation', () => {
109
+ test('dotted ID becomes slashed path', () => {
110
+ const app = topo('testapp', { createTrail });
111
+ const result = buildHttpRoutes(app);
112
+
113
+ expect(result.isOk()).toBe(true);
114
+ expect(result.value[0]?.path).toBe('/item/create');
115
+ });
116
+
117
+ test('simple ID becomes /id', () => {
118
+ const app = topo('testapp', { echoTrail });
119
+ const result = buildHttpRoutes(app);
120
+
121
+ expect(result.isOk()).toBe(true);
122
+ expect(result.value[0]?.path).toBe('/echo');
123
+ });
124
+
125
+ test('basePath is prepended', () => {
126
+ const app = topo('testapp', { echoTrail });
127
+ const result = buildHttpRoutes(app, { basePath: '/api/v1' });
128
+
129
+ expect(result.isOk()).toBe(true);
130
+ expect(result.value[0]?.path).toBe('/api/v1/echo');
131
+ });
132
+
133
+ test('basePath trailing slash is normalized', () => {
134
+ const app = topo('testapp', { echoTrail });
135
+ const result = buildHttpRoutes(app, { basePath: '/api/v1/' });
136
+
137
+ expect(result.isOk()).toBe(true);
138
+ expect(result.value[0]?.path).toBe('/api/v1/echo');
139
+ });
140
+ });
141
+
142
+ describe('input source derivation', () => {
143
+ test('GET routes use query input source', () => {
144
+ const app = topo('testapp', { echoTrail });
145
+ const result = buildHttpRoutes(app);
146
+
147
+ expect(result.isOk()).toBe(true);
148
+ expect(result.value[0]?.inputSource).toBe('query');
149
+ });
150
+
151
+ test('POST routes use body input source', () => {
152
+ const app = topo('testapp', { createTrail });
153
+ const result = buildHttpRoutes(app);
154
+
155
+ expect(result.isOk()).toBe(true);
156
+ expect(result.value[0]?.inputSource).toBe('body');
157
+ });
158
+
159
+ test('DELETE routes use body input source', () => {
160
+ const app = topo('testapp', { deleteTrail });
161
+ const result = buildHttpRoutes(app);
162
+
163
+ expect(result.isOk()).toBe(true);
164
+ expect(result.value[0]?.inputSource).toBe('body');
165
+ });
166
+ });
167
+
168
+ describe('filtering', () => {
169
+ test('internal trails are skipped', () => {
170
+ const app = topo('testapp', { echoTrail, internalMetaTrail });
171
+ const result = buildHttpRoutes(app);
172
+
173
+ expect(result.isOk()).toBe(true);
174
+ const routes = result.value;
175
+ expect(routes).toHaveLength(1);
176
+ expect(routes[0]?.trailId).toBe('echo');
177
+ });
178
+ });
179
+
180
+ describe('route definition shape', () => {
181
+ test('includes trail reference', () => {
182
+ const app = topo('testapp', { echoTrail });
183
+ const result = buildHttpRoutes(app);
184
+
185
+ expect(result.isOk()).toBe(true);
186
+ expect(result.value[0]?.trail).toBe(echoTrail);
187
+ });
188
+
189
+ test('execute is a function', () => {
190
+ const app = topo('testapp', { echoTrail });
191
+ const result = buildHttpRoutes(app);
192
+
193
+ expect(result.isOk()).toBe(true);
194
+ expect(typeof result.value[0]?.execute).toBe('function');
195
+ });
196
+ });
197
+
198
+ describe('execute', () => {
199
+ test('returns ok Result on valid input', async () => {
200
+ const app = topo('testapp', { echoTrail });
201
+ const buildResult = buildHttpRoutes(app);
202
+
203
+ expect(buildResult.isOk()).toBe(true);
204
+ const [route] = buildResult.value;
205
+
206
+ const result = await route?.execute({ message: 'hello' });
207
+ expect(result?.isOk()).toBe(true);
208
+ expect(result?.value).toEqual({ reply: 'hello' });
209
+ });
210
+
211
+ test('returns err Result on invalid input', async () => {
212
+ const app = topo('testapp', { echoTrail });
213
+ const buildResult = buildHttpRoutes(app);
214
+
215
+ expect(buildResult.isOk()).toBe(true);
216
+ const [route] = buildResult.value;
217
+
218
+ const result = await route?.execute({});
219
+ expect(result?.isErr()).toBe(true);
220
+ });
221
+
222
+ test('returns err Result from trail error', async () => {
223
+ const app = topo('testapp', { notFoundTrail });
224
+ const buildResult = buildHttpRoutes(app);
225
+
226
+ expect(buildResult.isOk()).toBe(true);
227
+ const [route] = buildResult.value;
228
+
229
+ const result = await route?.execute({ id: 'missing' });
230
+ expect(result?.isErr()).toBe(true);
231
+ expect(result?.error?.message).toBe('Item not found');
232
+ });
233
+
234
+ test('returns err Result from internal error', async () => {
235
+ const app = topo('testapp', { internalTrail });
236
+ const buildResult = buildHttpRoutes(app);
237
+
238
+ expect(buildResult.isOk()).toBe(true);
239
+ const [route] = buildResult.value;
240
+
241
+ const result = await route?.execute({});
242
+ expect(result?.isErr()).toBe(true);
243
+ expect(result?.error?.message).toBe('Something broke');
244
+ });
245
+
246
+ test('returns err Result when run function throws', async () => {
247
+ const throwingTrail = trail('throwing', {
248
+ input: z.object({}),
249
+ run: () => {
250
+ throw new Error('unexpected throw');
251
+ },
252
+ });
253
+ const app = topo('testapp', { throwingTrail });
254
+ const buildResult = buildHttpRoutes(app);
255
+
256
+ expect(buildResult.isOk()).toBe(true);
257
+ const [route] = buildResult.value;
258
+
259
+ const result = await route?.execute({});
260
+ expect(result?.isErr()).toBe(true);
261
+ expect(result?.error).toBeInstanceOf(InternalError);
262
+ expect(result?.error?.message).toBe('unexpected throw');
263
+ });
264
+
265
+ test('returns err Result when createContext throws', async () => {
266
+ const app = topo('testapp', { echoTrail });
267
+ const buildResult = buildHttpRoutes(app, {
268
+ createContext: () => {
269
+ throw new Error('context creation failed');
270
+ },
271
+ });
272
+
273
+ expect(buildResult.isOk()).toBe(true);
274
+ const [route] = buildResult.value;
275
+
276
+ const result = await route?.execute({ message: 'hi' });
277
+ expect(result?.isErr()).toBe(true);
278
+ expect(result?.error).toBeInstanceOf(InternalError);
279
+ expect(result?.error?.message).toBe('context creation failed');
280
+ });
281
+
282
+ test('passes requestId to context', async () => {
283
+ let capturedRequestId: string | undefined;
284
+
285
+ const ctxTrail = trail('ctx.check', {
286
+ input: z.object({}),
287
+ intent: 'read',
288
+ run: (_input, ctx) => {
289
+ capturedRequestId = ctx.requestId;
290
+ return Result.ok({ ok: true });
291
+ },
292
+ });
293
+
294
+ const app = topo('testapp', { ctxTrail });
295
+ const buildResult = buildHttpRoutes(app);
296
+
297
+ expect(buildResult.isOk()).toBe(true);
298
+ const [route] = buildResult.value;
299
+
300
+ await route?.execute({}, 'custom-req-123');
301
+ expect(capturedRequestId).toBe('custom-req-123');
302
+ });
303
+
304
+ test('uses default requestId when none provided', async () => {
305
+ let capturedRequestId: string | undefined;
306
+
307
+ const ctxTrail = trail('ctx.default', {
308
+ input: z.object({}),
309
+ intent: 'read',
310
+ run: (_input, ctx) => {
311
+ capturedRequestId = ctx.requestId;
312
+ return Result.ok({ ok: true });
313
+ },
314
+ });
315
+
316
+ const app = topo('testapp', { ctxTrail });
317
+ const buildResult = buildHttpRoutes(app);
318
+
319
+ expect(buildResult.isOk()).toBe(true);
320
+ const [route] = buildResult.value;
321
+
322
+ await route?.execute({});
323
+ expect(capturedRequestId).toBeDefined();
324
+ expect(capturedRequestId).not.toBe('');
325
+ });
326
+
327
+ test('forwards service overrides into executeTrail', async () => {
328
+ const serviceTrail = trail('service.check', {
329
+ input: z.object({}),
330
+ output: z.object({ source: z.string() }),
331
+ run: (_input, ctx) =>
332
+ Result.ok({ source: dbService.from(ctx).source as string }),
333
+ services: [dbService],
334
+ });
335
+
336
+ const app = topo('testapp', { serviceTrail });
337
+ const buildResult = buildHttpRoutes(app, {
338
+ services: { 'db.main': { source: 'override' } },
339
+ });
340
+
341
+ expect(buildResult.isOk()).toBe(true);
342
+ const [route] = buildResult.value;
343
+
344
+ const result = await route?.execute({});
345
+ expect(result?.isOk()).toBe(true);
346
+ expect(result?.value).toEqual({ source: 'override' });
347
+ });
348
+ });
349
+
350
+ describe('layers', () => {
351
+ test('layers compose around trail execution', async () => {
352
+ const calls: string[] = [];
353
+
354
+ const testLayer: Layer = {
355
+ name: 'test-layer',
356
+ wrap(_trail, impl) {
357
+ return async (input, ctx) => {
358
+ calls.push('before');
359
+ const result = await impl(input, ctx);
360
+ calls.push('after');
361
+ return result;
362
+ };
363
+ },
364
+ };
365
+
366
+ const app = topo('testapp', { echoTrail });
367
+ const buildResult = buildHttpRoutes(app, { layers: [testLayer] });
368
+
369
+ expect(buildResult.isOk()).toBe(true);
370
+ const [route] = buildResult.value;
371
+
372
+ const result = await route?.execute({ message: 'hi' });
373
+ expect(result?.isOk()).toBe(true);
374
+ expect(calls).toEqual(['before', 'after']);
375
+ });
376
+ });
377
+
378
+ describe('custom createContext', () => {
379
+ test('custom createContext is used when provided', async () => {
380
+ const contextState = { custom: false, surface: false };
381
+
382
+ const ctxTrail = trail('ctx.custom', {
383
+ input: z.object({}),
384
+ intent: 'read',
385
+ run: (_input, ctx) => {
386
+ contextState.custom = ctx.extensions?.['custom'] === true;
387
+ contextState.surface = ctx.extensions?.[SURFACE_KEY] === 'http';
388
+ return Result.ok({ ok: true });
389
+ },
390
+ });
391
+
392
+ const app = topo('testapp', { ctxTrail });
393
+ const buildResult = buildHttpRoutes(app, {
394
+ createContext: () => ({
395
+ extensions: { custom: true },
396
+ requestId: 'test-id',
397
+ signal: new AbortController().signal,
398
+ }),
399
+ });
400
+
401
+ expect(buildResult.isOk()).toBe(true);
402
+ const [route] = buildResult.value;
403
+
404
+ const result = await route?.execute({});
405
+ expect(result?.isOk()).toBe(true);
406
+ expect(contextState.custom).toBe(true);
407
+ expect(contextState.surface).toBe(true);
408
+ });
409
+ });
410
+
411
+ describe('collision detection', () => {
412
+ test('returns err on duplicate (path, method) pair', () => {
413
+ // "entity.show" derives path /entity/show (dots become slashes)
414
+ // "entity/show" derives path /entity/show (slashes are preserved)
415
+ // Both have intent: read -> GET, so they collide on GET /entity/show
416
+ const dotTrail = trail('entity.show', {
417
+ description: 'Show entity (dot notation)',
418
+ input: z.object({}),
419
+ intent: 'read',
420
+ run: () => Result.ok({ dot: true }),
421
+ });
422
+ const slashTrail = trail('entity/show', {
423
+ description: 'Show entity (slash notation)',
424
+ input: z.object({}),
425
+ intent: 'read',
426
+ run: () => Result.ok({ slash: true }),
427
+ });
428
+ const app = topo('testapp', { dotTrail, slashTrail });
429
+ const result = buildHttpRoutes(app);
430
+
431
+ expect(result.isErr()).toBe(true);
432
+ expect(result.error).toBeInstanceOf(ValidationError);
433
+ expect(result.error?.message).toContain('GET /entity/show');
434
+ });
435
+
436
+ test('same path with different methods is allowed', () => {
437
+ // "item.resource" derives GET /item/resource (intent: read)
438
+ // "item/resource" derives POST /item/resource (default intent: write)
439
+ // Same path, different methods — no collision
440
+ const getItem = trail('item.resource', {
441
+ description: 'Get item',
442
+ input: z.object({}),
443
+ intent: 'read',
444
+ run: () => Result.ok({ get: true }),
445
+ });
446
+ const createItem = trail('item/resource', {
447
+ description: 'Create item',
448
+ input: z.object({ name: z.string() }),
449
+ run: () => Result.ok({ created: true }),
450
+ });
451
+ const app = topo('testapp', { createItem, getItem });
452
+ const result = buildHttpRoutes(app);
453
+
454
+ expect(result.isOk()).toBe(true);
455
+ expect(result.value).toHaveLength(2);
456
+ });
457
+
458
+ test('collision error message identifies both trail IDs', () => {
459
+ const dotTrail = trail('entity.show', {
460
+ description: 'Trail one',
461
+ input: z.object({}),
462
+ intent: 'read',
463
+ run: () => Result.ok({ one: true }),
464
+ });
465
+ const slashTrail = trail('entity/show', {
466
+ description: 'Trail two',
467
+ input: z.object({}),
468
+ intent: 'read',
469
+ run: () => Result.ok({ two: true }),
470
+ });
471
+ const app = topo('testapp', { dotTrail, slashTrail });
472
+ const result = buildHttpRoutes(app);
473
+
474
+ expect(result.isErr()).toBe(true);
475
+ expect(result.error?.message).toContain('entity');
476
+ });
477
+ });
478
+ });
package/src/build.ts ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Build framework-agnostic HTTP route definitions from a Trails topo.
3
+ *
4
+ * Each route definition describes the path, method, input source, and an
5
+ * `execute` function that validates input, composes layers, and runs the
6
+ * implementation -- all without referencing any HTTP framework types.
7
+ */
8
+
9
+ import {
10
+ Result,
11
+ SURFACE_KEY,
12
+ ValidationError,
13
+ executeTrail,
14
+ } from '@ontrails/core';
15
+ import type {
16
+ Layer,
17
+ ServiceOverrideMap,
18
+ Topo,
19
+ Trail,
20
+ TrailContextInit,
21
+ } from '@ontrails/core';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Public types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface BuildHttpRoutesOptions {
28
+ readonly basePath?: string | undefined;
29
+ /** Config values for services that declare a `config` schema, keyed by service ID. */
30
+ readonly configValues?:
31
+ | Readonly<Record<string, Record<string, unknown>>>
32
+ | undefined;
33
+ readonly createContext?:
34
+ | (() => TrailContextInit | Promise<TrailContextInit>)
35
+ | undefined;
36
+ readonly layers?: readonly Layer[] | undefined;
37
+ readonly services?: ServiceOverrideMap | undefined;
38
+ }
39
+
40
+ export type HttpMethod = 'GET' | 'POST' | 'DELETE';
41
+
42
+ /** Input source derived from the HTTP method. */
43
+ export type InputSource = 'query' | 'body';
44
+
45
+ export interface HttpRouteDefinition {
46
+ readonly method: HttpMethod;
47
+ readonly path: string;
48
+ readonly trailId: string;
49
+ readonly inputSource: InputSource;
50
+ readonly trail: Trail<unknown, unknown>;
51
+ /**
52
+ * Validate input, compose layers, and execute the trail implementation.
53
+ *
54
+ * The caller is responsible for parsing raw input from the request and
55
+ * mapping the Result to an HTTP response. This function is framework-agnostic.
56
+ *
57
+ * @param signal - Optional AbortSignal from the HTTP request. When provided,
58
+ * it takes final precedence over any context factory signal, allowing
59
+ * client-initiated cancellation to propagate into trail execution.
60
+ */
61
+ readonly execute: (
62
+ input: unknown,
63
+ requestId?: string | undefined,
64
+ signal?: AbortSignal | undefined
65
+ ) => Promise<Result<unknown, Error>>;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Internal helpers
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /** Explicit intent → HTTP method mapping. */
73
+ const intentToMethod: Record<string, HttpMethod> = {
74
+ destroy: 'DELETE',
75
+ read: 'GET',
76
+ write: 'POST',
77
+ };
78
+
79
+ /** Derive HTTP method from trail intent. */
80
+ const deriveMethod = (trail: Trail<unknown, unknown>): HttpMethod =>
81
+ intentToMethod[trail.intent] ?? 'POST';
82
+
83
+ /** Derive HTTP path from trail ID: `entity.show` -> `/entity/show`. */
84
+ const derivePath = (basePath: string, trailId: string): string => {
85
+ const segments = trailId.replaceAll('.', '/');
86
+ const base = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
87
+ return `${base}/${segments}`;
88
+ };
89
+
90
+ /** Derive input source from HTTP method. */
91
+ const deriveInputSource = (method: HttpMethod): InputSource =>
92
+ method === 'GET' ? 'query' : 'body';
93
+
94
+ /** Check if a trail should be included (skip internal trails). */
95
+ const shouldInclude = (trail: Trail<unknown, unknown>): boolean =>
96
+ trail.metadata?.['internal'] !== true;
97
+
98
+ /** Build per-request context overrides with the HTTP surface marker. */
99
+ const withHttpSurface = (
100
+ requestId: string | undefined
101
+ ): Partial<TrailContextInit> => ({
102
+ ...(requestId === undefined ? {} : { requestId }),
103
+ extensions: {
104
+ [SURFACE_KEY]: 'http' as const,
105
+ },
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Execute factory
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Create an `execute` function for a single trail.
114
+ *
115
+ * Delegates to the centralized `executeTrail` pipeline in core.
116
+ * The returned function returns a `Result` and never throws.
117
+ */
118
+ const createExecute =
119
+ (
120
+ t: Trail<unknown, unknown>,
121
+ layers: readonly Layer[],
122
+ options: BuildHttpRoutesOptions
123
+ ): HttpRouteDefinition['execute'] =>
124
+ (input, requestId, signal) =>
125
+ executeTrail(t, input, {
126
+ configValues: options.configValues,
127
+ createContext: options.createContext,
128
+ ctx: withHttpSurface(requestId),
129
+ layers,
130
+ services: options.services,
131
+ signal,
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Builder helpers
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /** Filter topo items to eligible trails. */
139
+ const eligibleTrails = (app: Topo): Trail<unknown, unknown>[] =>
140
+ app.list().filter((trail) => shouldInclude(trail));
141
+
142
+ /** Build a single route definition from a trail. */
143
+ const buildRoute = (
144
+ trail: Trail<unknown, unknown>,
145
+ basePath: string,
146
+ layers: readonly Layer[],
147
+ options: BuildHttpRoutesOptions
148
+ ): HttpRouteDefinition => {
149
+ const method = deriveMethod(trail);
150
+ const path = derivePath(basePath, trail.id);
151
+ return {
152
+ execute: createExecute(trail, layers, options),
153
+ inputSource: deriveInputSource(method),
154
+ method,
155
+ path,
156
+ trail,
157
+ trailId: trail.id,
158
+ };
159
+ };
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Collision detection
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /** Derive the lookup key for (method, path) collision detection. */
166
+ const routeKey = (route: HttpRouteDefinition): `${string} ${string}` =>
167
+ `${route.method} ${route.path}`;
168
+
169
+ /** Register a route, checking for (path, method) collisions. */
170
+ const registerRoute = (
171
+ route: HttpRouteDefinition,
172
+ seenRoutes: Map<string, string>,
173
+ routes: HttpRouteDefinition[]
174
+ ): Result<void, Error> => {
175
+ const key = routeKey(route);
176
+ const existingId = seenRoutes.get(key);
177
+ if (existingId !== undefined) {
178
+ return Result.err(
179
+ new ValidationError(
180
+ `HTTP route collision: trails "${existingId}" and "${route.trailId}" both derive ${route.method} ${route.path}`
181
+ )
182
+ );
183
+ }
184
+ seenRoutes.set(key, route.trailId);
185
+ routes.push(route);
186
+ return Result.ok();
187
+ };
188
+
189
+ /** Accumulate route definitions, returning early on the first collision. */
190
+ const accumulateRoutes = (
191
+ trails: Trail<unknown, unknown>[],
192
+ basePath: string,
193
+ layers: readonly Layer[],
194
+ options: BuildHttpRoutesOptions
195
+ ): Result<HttpRouteDefinition[], Error> => {
196
+ const routes: HttpRouteDefinition[] = [];
197
+ const seenRoutes = new Map<string, string>();
198
+
199
+ for (const trail of trails) {
200
+ const route = buildRoute(trail, basePath, layers, options);
201
+ const registered = registerRoute(route, seenRoutes, routes);
202
+ if (registered.isErr()) {
203
+ return registered;
204
+ }
205
+ }
206
+
207
+ return Result.ok(routes);
208
+ };
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Builder
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Build HTTP route definitions from a topo.
216
+ *
217
+ * Each trail becomes an HttpRouteDefinition with:
218
+ * - An HTTP method derived from intent (read -> GET, write -> POST, destroy -> DELETE)
219
+ * - A path derived from the trail ID (dots become slashes)
220
+ * - An input source derived from the method (GET -> query, others -> body)
221
+ * - An `execute` function that validates, layers, and runs the implementation
222
+ *
223
+ * Returns `Result.err(ValidationError)` if two trails derive the same
224
+ * (method, path) pair. Returns `Result.ok(routes)` on success.
225
+ */
226
+ export const buildHttpRoutes = (
227
+ app: Topo,
228
+ options: BuildHttpRoutesOptions = {}
229
+ ): Result<HttpRouteDefinition[], Error> => {
230
+ const basePath = (options.basePath ?? '').replace(/\/+$/, '');
231
+ const layers = options.layers ?? [];
232
+ return accumulateRoutes(eligibleTrails(app), basePath, layers, options);
233
+ };