@ontrails/http 1.0.0-beta.13 → 1.0.0-beta.15

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.
@@ -5,15 +5,16 @@ import {
5
5
  NotFoundError,
6
6
  Result,
7
7
  TRAILHEAD_KEY,
8
- provision,
8
+ resource,
9
+ signal,
9
10
  ValidationError,
10
11
  trail,
11
12
  topo,
12
13
  } from '@ontrails/core';
13
- import type { Gate } from '@ontrails/core';
14
+ import type { Layer, TrailContext } from '@ontrails/core';
14
15
  import { z } from 'zod';
15
16
 
16
- import { buildHttpRoutes } from '../build.js';
17
+ import { deriveHttpRoutes } from '../build.js';
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Test trails
@@ -54,29 +55,40 @@ const internalTrail = trail('crash', {
54
55
  input: z.object({}),
55
56
  });
56
57
 
57
- const internalMetaTrail = trail('secret', {
58
+ const internalVisibilityTrail = trail('secret', {
58
59
  blaze: () => Result.ok({ ok: true }),
59
60
  description: 'Internal trail that should be skipped',
60
61
  input: z.object({}),
61
- meta: { internal: true },
62
+ visibility: 'internal',
62
63
  });
63
64
 
64
- const dbProvision = provision('db.main', {
65
+ const dbResource = resource('db.main', {
65
66
  create: () =>
66
67
  Result.ok({
67
68
  source: 'factory',
68
69
  }),
69
70
  });
70
71
 
72
+ const orderPlaced = signal('order.placed', {
73
+ payload: z.object({ orderId: z.string() }),
74
+ });
75
+
76
+ const requireFire = (fire: TrailContext['fire']) => {
77
+ if (!fire) {
78
+ throw new Error('Expected ctx.fire to be bound');
79
+ }
80
+ return fire;
81
+ };
82
+
71
83
  // ---------------------------------------------------------------------------
72
84
  // Tests
73
85
  // ---------------------------------------------------------------------------
74
86
 
75
- describe('buildHttpRoutes', () => {
87
+ describe('deriveHttpRoutes', () => {
76
88
  describe('method derivation', () => {
77
89
  test('intent: read maps to GET', () => {
78
90
  const app = topo('testapp', { echoTrail });
79
- const result = buildHttpRoutes(app);
91
+ const result = deriveHttpRoutes(app);
80
92
 
81
93
  expect(result.isOk()).toBe(true);
82
94
  const routes = result.value;
@@ -86,7 +98,7 @@ describe('buildHttpRoutes', () => {
86
98
 
87
99
  test('intent: destroy maps to DELETE', () => {
88
100
  const app = topo('testapp', { deleteTrail });
89
- const result = buildHttpRoutes(app);
101
+ const result = deriveHttpRoutes(app);
90
102
 
91
103
  expect(result.isOk()).toBe(true);
92
104
  const routes = result.value;
@@ -96,7 +108,7 @@ describe('buildHttpRoutes', () => {
96
108
 
97
109
  test('default intent (write) maps to POST', () => {
98
110
  const app = topo('testapp', { createTrail });
99
- const result = buildHttpRoutes(app);
111
+ const result = deriveHttpRoutes(app);
100
112
 
101
113
  expect(result.isOk()).toBe(true);
102
114
  const routes = result.value;
@@ -108,7 +120,7 @@ describe('buildHttpRoutes', () => {
108
120
  describe('path derivation', () => {
109
121
  test('dotted ID becomes slashed path', () => {
110
122
  const app = topo('testapp', { createTrail });
111
- const result = buildHttpRoutes(app);
123
+ const result = deriveHttpRoutes(app);
112
124
 
113
125
  expect(result.isOk()).toBe(true);
114
126
  expect(result.value[0]?.path).toBe('/item/create');
@@ -116,7 +128,7 @@ describe('buildHttpRoutes', () => {
116
128
 
117
129
  test('simple ID becomes /id', () => {
118
130
  const app = topo('testapp', { echoTrail });
119
- const result = buildHttpRoutes(app);
131
+ const result = deriveHttpRoutes(app);
120
132
 
121
133
  expect(result.isOk()).toBe(true);
122
134
  expect(result.value[0]?.path).toBe('/echo');
@@ -124,7 +136,7 @@ describe('buildHttpRoutes', () => {
124
136
 
125
137
  test('basePath is prepended', () => {
126
138
  const app = topo('testapp', { echoTrail });
127
- const result = buildHttpRoutes(app, { basePath: '/api/v1' });
139
+ const result = deriveHttpRoutes(app, { basePath: '/api/v1' });
128
140
 
129
141
  expect(result.isOk()).toBe(true);
130
142
  expect(result.value[0]?.path).toBe('/api/v1/echo');
@@ -132,7 +144,7 @@ describe('buildHttpRoutes', () => {
132
144
 
133
145
  test('basePath trailing slash is normalized', () => {
134
146
  const app = topo('testapp', { echoTrail });
135
- const result = buildHttpRoutes(app, { basePath: '/api/v1/' });
147
+ const result = deriveHttpRoutes(app, { basePath: '/api/v1/' });
136
148
 
137
149
  expect(result.isOk()).toBe(true);
138
150
  expect(result.value[0]?.path).toBe('/api/v1/echo');
@@ -142,7 +154,7 @@ describe('buildHttpRoutes', () => {
142
154
  describe('input source derivation', () => {
143
155
  test('GET routes use query input source', () => {
144
156
  const app = topo('testapp', { echoTrail });
145
- const result = buildHttpRoutes(app);
157
+ const result = deriveHttpRoutes(app);
146
158
 
147
159
  expect(result.isOk()).toBe(true);
148
160
  expect(result.value[0]?.inputSource).toBe('query');
@@ -150,7 +162,7 @@ describe('buildHttpRoutes', () => {
150
162
 
151
163
  test('POST routes use body input source', () => {
152
164
  const app = topo('testapp', { createTrail });
153
- const result = buildHttpRoutes(app);
165
+ const result = deriveHttpRoutes(app);
154
166
 
155
167
  expect(result.isOk()).toBe(true);
156
168
  expect(result.value[0]?.inputSource).toBe('body');
@@ -158,7 +170,7 @@ describe('buildHttpRoutes', () => {
158
170
 
159
171
  test('DELETE routes use body input source', () => {
160
172
  const app = topo('testapp', { deleteTrail });
161
- const result = buildHttpRoutes(app);
173
+ const result = deriveHttpRoutes(app);
162
174
 
163
175
  expect(result.isOk()).toBe(true);
164
176
  expect(result.value[0]?.inputSource).toBe('body');
@@ -167,8 +179,77 @@ describe('buildHttpRoutes', () => {
167
179
 
168
180
  describe('filtering', () => {
169
181
  test('internal trails are skipped', () => {
170
- const app = topo('testapp', { echoTrail, internalMetaTrail });
171
- const result = buildHttpRoutes(app);
182
+ const app = topo('testapp', { echoTrail, internalVisibilityTrail });
183
+ const result = deriveHttpRoutes(app);
184
+
185
+ expect(result.isOk()).toBe(true);
186
+ const routes = result.value;
187
+ expect(routes).toHaveLength(1);
188
+ expect(routes[0]?.trailId).toBe('echo');
189
+ });
190
+
191
+ test('exact include can expose an internal trail', () => {
192
+ const app = topo('testapp', { echoTrail, internalVisibilityTrail });
193
+ const result = deriveHttpRoutes(app, { include: ['secret'] });
194
+
195
+ expect(result.isOk()).toBe(true);
196
+ const routes = result.value;
197
+ expect(routes).toHaveLength(1);
198
+ expect(routes[0]?.trailId).toBe('secret');
199
+ });
200
+
201
+ test('wildcard include does not expose internal trails', () => {
202
+ const app = topo('testapp', { echoTrail, internalVisibilityTrail });
203
+ const result = deriveHttpRoutes(app, { include: ['**'] });
204
+
205
+ expect(result.isOk()).toBe(true);
206
+ const routes = result.value;
207
+ expect(routes).toHaveLength(1);
208
+ expect(routes[0]?.trailId).toBe('echo');
209
+ });
210
+
211
+ test('exclude patterns win before include narrowing', () => {
212
+ const app = topo('testapp', { deleteTrail, echoTrail });
213
+ const result = deriveHttpRoutes(app, {
214
+ exclude: ['item.**'],
215
+ include: ['echo', 'item.delete'],
216
+ });
217
+
218
+ expect(result.isOk()).toBe(true);
219
+ expect(result.value.map((route) => route.trailId)).toEqual(['echo']);
220
+ });
221
+
222
+ test('intent filters narrow the route table', () => {
223
+ const app = topo('testapp', { deleteTrail, echoTrail });
224
+ const result = deriveHttpRoutes(app, { intent: ['read'] });
225
+
226
+ expect(result.isOk()).toBe(true);
227
+ expect(result.value.map((route) => route.trailId)).toEqual(['echo']);
228
+ });
229
+
230
+ test('intent filters compose with include patterns using AND logic', () => {
231
+ const app = topo('testapp', { deleteTrail, echoTrail });
232
+ const result = deriveHttpRoutes(app, {
233
+ include: ['item.*', 'echo'],
234
+ intent: ['destroy'],
235
+ });
236
+
237
+ expect(result.isOk()).toBe(true);
238
+ expect(result.value.map((route) => route.trailId)).toEqual([
239
+ 'item.delete',
240
+ ]);
241
+ });
242
+
243
+ test('consumer trails (on: [...]) are skipped', () => {
244
+ const consumerTrail = trail('notify.email', {
245
+ blaze: (input: { orderId: string }) =>
246
+ Result.ok({ delivered: true, orderId: input.orderId }),
247
+ description: 'Send email on order placed',
248
+ input: z.object({ orderId: z.string() }),
249
+ on: ['order.placed'],
250
+ });
251
+ const app = topo('testapp', { consumerTrail, echoTrail, orderPlaced });
252
+ const result = deriveHttpRoutes(app);
172
253
 
173
254
  expect(result.isOk()).toBe(true);
174
255
  const routes = result.value;
@@ -180,7 +261,7 @@ describe('buildHttpRoutes', () => {
180
261
  describe('route definition shape', () => {
181
262
  test('includes trail reference', () => {
182
263
  const app = topo('testapp', { echoTrail });
183
- const result = buildHttpRoutes(app);
264
+ const result = deriveHttpRoutes(app);
184
265
 
185
266
  expect(result.isOk()).toBe(true);
186
267
  expect(result.value[0]?.trail).toBe(echoTrail);
@@ -188,7 +269,7 @@ describe('buildHttpRoutes', () => {
188
269
 
189
270
  test('execute is a function', () => {
190
271
  const app = topo('testapp', { echoTrail });
191
- const result = buildHttpRoutes(app);
272
+ const result = deriveHttpRoutes(app);
192
273
 
193
274
  expect(result.isOk()).toBe(true);
194
275
  expect(typeof result.value[0]?.execute).toBe('function');
@@ -198,7 +279,7 @@ describe('buildHttpRoutes', () => {
198
279
  describe('execute', () => {
199
280
  test('returns ok Result on valid input', async () => {
200
281
  const app = topo('testapp', { echoTrail });
201
- const buildResult = buildHttpRoutes(app);
282
+ const buildResult = deriveHttpRoutes(app);
202
283
 
203
284
  expect(buildResult.isOk()).toBe(true);
204
285
  const [route] = buildResult.value;
@@ -210,7 +291,7 @@ describe('buildHttpRoutes', () => {
210
291
 
211
292
  test('returns err Result on invalid input', async () => {
212
293
  const app = topo('testapp', { echoTrail });
213
- const buildResult = buildHttpRoutes(app);
294
+ const buildResult = deriveHttpRoutes(app);
214
295
 
215
296
  expect(buildResult.isOk()).toBe(true);
216
297
  const [route] = buildResult.value;
@@ -221,7 +302,7 @@ describe('buildHttpRoutes', () => {
221
302
 
222
303
  test('returns err Result from trail error', async () => {
223
304
  const app = topo('testapp', { notFoundTrail });
224
- const buildResult = buildHttpRoutes(app);
305
+ const buildResult = deriveHttpRoutes(app);
225
306
 
226
307
  expect(buildResult.isOk()).toBe(true);
227
308
  const [route] = buildResult.value;
@@ -233,7 +314,7 @@ describe('buildHttpRoutes', () => {
233
314
 
234
315
  test('returns err Result from internal error', async () => {
235
316
  const app = topo('testapp', { internalTrail });
236
- const buildResult = buildHttpRoutes(app);
317
+ const buildResult = deriveHttpRoutes(app);
237
318
 
238
319
  expect(buildResult.isOk()).toBe(true);
239
320
  const [route] = buildResult.value;
@@ -251,7 +332,7 @@ describe('buildHttpRoutes', () => {
251
332
  input: z.object({}),
252
333
  });
253
334
  const app = topo('testapp', { throwingTrail });
254
- const buildResult = buildHttpRoutes(app);
335
+ const buildResult = deriveHttpRoutes(app);
255
336
 
256
337
  expect(buildResult.isOk()).toBe(true);
257
338
  const [route] = buildResult.value;
@@ -264,7 +345,7 @@ describe('buildHttpRoutes', () => {
264
345
 
265
346
  test('returns err Result when createContext throws', async () => {
266
347
  const app = topo('testapp', { echoTrail });
267
- const buildResult = buildHttpRoutes(app, {
348
+ const buildResult = deriveHttpRoutes(app, {
268
349
  createContext: () => {
269
350
  throw new Error('context creation failed');
270
351
  },
@@ -279,6 +360,41 @@ describe('buildHttpRoutes', () => {
279
360
  expect(result?.error?.message).toBe('context creation failed');
280
361
  });
281
362
 
363
+ test('passes topo to executeTrail so HTTP-invoked producers can fan out', async () => {
364
+ const captured: string[] = [];
365
+ const consumer = trail('notify.email', {
366
+ blaze: (input: { orderId: string }) => {
367
+ captured.push(input.orderId);
368
+ return Result.ok({ delivered: true });
369
+ },
370
+ input: z.object({ orderId: z.string() }),
371
+ on: ['order.placed'],
372
+ });
373
+ const producer = trail('order.create', {
374
+ blaze: async (input: { orderId: string }, ctx) => {
375
+ const fired = await requireFire(ctx.fire)('order.placed', {
376
+ orderId: input.orderId,
377
+ });
378
+ return fired.match({
379
+ err: (error) => Result.err(error),
380
+ ok: () => Result.ok({ ok: true }),
381
+ });
382
+ },
383
+ fires: ['order.placed'],
384
+ input: z.object({ orderId: z.string() }),
385
+ });
386
+ const app = topo('signal-http', { consumer, orderPlaced, producer });
387
+ const buildResult = deriveHttpRoutes(app);
388
+
389
+ expect(buildResult.isOk()).toBe(true);
390
+ const [route] = buildResult.value;
391
+
392
+ const result = await route?.execute({ orderId: 'o-http' });
393
+
394
+ expect(result?.isOk()).toBe(true);
395
+ expect(captured).toEqual(['o-http']);
396
+ });
397
+
282
398
  test('passes requestId to context', async () => {
283
399
  let capturedRequestId: string | undefined;
284
400
 
@@ -292,7 +408,7 @@ describe('buildHttpRoutes', () => {
292
408
  });
293
409
 
294
410
  const app = topo('testapp', { ctxTrail });
295
- const buildResult = buildHttpRoutes(app);
411
+ const buildResult = deriveHttpRoutes(app);
296
412
 
297
413
  expect(buildResult.isOk()).toBe(true);
298
414
  const [route] = buildResult.value;
@@ -314,7 +430,7 @@ describe('buildHttpRoutes', () => {
314
430
  });
315
431
 
316
432
  const app = topo('testapp', { ctxTrail });
317
- const buildResult = buildHttpRoutes(app);
433
+ const buildResult = deriveHttpRoutes(app);
318
434
 
319
435
  expect(buildResult.isOk()).toBe(true);
320
436
  const [route] = buildResult.value;
@@ -324,18 +440,18 @@ describe('buildHttpRoutes', () => {
324
440
  expect(capturedRequestId).not.toBe('');
325
441
  });
326
442
 
327
- test('forwards provision overrides into executeTrail', async () => {
328
- const provisionTrail = trail('provision.check', {
443
+ test('forwards resource overrides into executeTrail', async () => {
444
+ const resourceTrail = trail('resource.check', {
329
445
  blaze: (_input, ctx) =>
330
- Result.ok({ source: dbProvision.from(ctx).source as string }),
446
+ Result.ok({ source: dbResource.from(ctx).source as string }),
331
447
  input: z.object({}),
332
448
  output: z.object({ source: z.string() }),
333
- provisions: [dbProvision],
449
+ resources: [dbResource],
334
450
  });
335
451
 
336
- const app = topo('testapp', { provisionTrail });
337
- const buildResult = buildHttpRoutes(app, {
338
- provisions: { 'db.main': { source: 'override' } },
452
+ const app = topo('testapp', { dbResource, resourceTrail });
453
+ const buildResult = deriveHttpRoutes(app, {
454
+ resources: { 'db.main': { source: 'override' } },
339
455
  });
340
456
 
341
457
  expect(buildResult.isOk()).toBe(true);
@@ -347,12 +463,12 @@ describe('buildHttpRoutes', () => {
347
463
  });
348
464
  });
349
465
 
350
- describe('gates', () => {
351
- test('gates compose around trail execution', async () => {
466
+ describe('layers', () => {
467
+ test('layers compose around trail execution', async () => {
352
468
  const calls: string[] = [];
353
469
 
354
- const testGate: Gate = {
355
- name: 'test-gate',
470
+ const testGate: Layer = {
471
+ name: 'test-layer',
356
472
  wrap(_trail, impl) {
357
473
  return async (input, ctx) => {
358
474
  calls.push('before');
@@ -364,7 +480,7 @@ describe('buildHttpRoutes', () => {
364
480
  };
365
481
 
366
482
  const app = topo('testapp', { echoTrail });
367
- const buildResult = buildHttpRoutes(app, { gates: [testGate] });
483
+ const buildResult = deriveHttpRoutes(app, { layers: [testGate] });
368
484
 
369
485
  expect(buildResult.isOk()).toBe(true);
370
486
  const [route] = buildResult.value;
@@ -391,7 +507,7 @@ describe('buildHttpRoutes', () => {
391
507
  });
392
508
 
393
509
  const app = topo('testapp', { ctxTrail });
394
- const buildResult = buildHttpRoutes(app, {
510
+ const buildResult = deriveHttpRoutes(app, {
395
511
  createContext: () => ({
396
512
  abortSignal: new AbortController().signal,
397
513
  extensions: { custom: true },
@@ -427,7 +543,7 @@ describe('buildHttpRoutes', () => {
427
543
  intent: 'read',
428
544
  });
429
545
  const app = topo('testapp', { dotTrail, slashTrail });
430
- const result = buildHttpRoutes(app);
546
+ const result = deriveHttpRoutes(app);
431
547
 
432
548
  expect(result.isErr()).toBe(true);
433
549
  expect(result.error).toBeInstanceOf(ValidationError);
@@ -450,7 +566,7 @@ describe('buildHttpRoutes', () => {
450
566
  input: z.object({ name: z.string() }),
451
567
  });
452
568
  const app = topo('testapp', { createItem, getItem });
453
- const result = buildHttpRoutes(app);
569
+ const result = deriveHttpRoutes(app);
454
570
 
455
571
  expect(result.isOk()).toBe(true);
456
572
  expect(result.value).toHaveLength(2);
@@ -470,10 +586,25 @@ describe('buildHttpRoutes', () => {
470
586
  intent: 'read',
471
587
  });
472
588
  const app = topo('testapp', { dotTrail, slashTrail });
473
- const result = buildHttpRoutes(app);
589
+ const result = deriveHttpRoutes(app);
474
590
 
475
591
  expect(result.isErr()).toBe(true);
476
592
  expect(result.error?.message).toContain('entity');
477
593
  });
478
594
  });
595
+
596
+ describe('established graph enforcement', () => {
597
+ test('returns err when draft contamination remains', () => {
598
+ const draftTrail = trail('entity.export', {
599
+ blaze: () => Result.ok({ ok: true }),
600
+ crosses: ['_draft.entity.prepare'],
601
+ input: z.object({}),
602
+ });
603
+
604
+ const result = deriveHttpRoutes(topo('testapp', { draftTrail }));
605
+
606
+ expect(result.isErr()).toBe(true);
607
+ expect(result.error?.message).toMatch(/draft/i);
608
+ });
609
+ });
479
610
  });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { Result, trail, topo } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { deriveHttpRoutes } from '../build.js';
7
+
8
+ const unwrapOk = <T>(result: Result<T, Error>): T =>
9
+ result.match({
10
+ err: (error) => {
11
+ throw error;
12
+ },
13
+ ok: (value) => value,
14
+ });
15
+
16
+ describe('deriveHttpRoutes', () => {
17
+ test('aliases the HTTP projection API', () => {
18
+ const graph = topo('testapp', {
19
+ echo: trail('echo', {
20
+ blaze: (input: { message: string }) =>
21
+ Result.ok({ reply: input.message }),
22
+ input: z.object({ message: z.string() }),
23
+ intent: 'read',
24
+ output: z.object({ reply: z.string() }),
25
+ }),
26
+ });
27
+
28
+ const routes = unwrapOk(deriveHttpRoutes(graph));
29
+ expect(routes[0]?.path).toBe('/echo');
30
+ });
31
+ });
package/src/build.ts CHANGED
@@ -2,7 +2,7 @@
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 gates, and runs the
5
+ * `execute` function that validates input, composes layers, and runs the
6
6
  * implementation -- all without referencing any HTTP framework types.
7
7
  */
8
8
 
@@ -11,10 +11,13 @@ import {
11
11
  TRAILHEAD_KEY,
12
12
  ValidationError,
13
13
  executeTrail,
14
+ filterSurfaceTrails,
15
+ validateEstablishedTopo,
14
16
  } from '@ontrails/core';
15
17
  import type {
16
- Gate,
17
- ProvisionOverrideMap,
18
+ Intent,
19
+ Layer,
20
+ ResourceOverrideMap,
18
21
  Topo,
19
22
  Trail,
20
23
  TrailContextInit,
@@ -24,17 +27,22 @@ import type {
24
27
  // Public types
25
28
  // ---------------------------------------------------------------------------
26
29
 
27
- export interface BuildHttpRoutesOptions {
30
+ export interface DeriveHttpRoutesOptions {
28
31
  readonly basePath?: string | undefined;
29
- /** Config values for provisions that declare a `config` schema, keyed by provision ID. */
32
+ /** Config values for resources that declare a `config` schema, keyed by resource ID. */
30
33
  readonly configValues?:
31
34
  | Readonly<Record<string, Record<string, unknown>>>
32
35
  | undefined;
33
36
  readonly createContext?:
34
37
  | (() => TrailContextInit | Promise<TrailContextInit>)
35
38
  | undefined;
36
- readonly gates?: readonly Gate[] | undefined;
37
- readonly provisions?: ProvisionOverrideMap | undefined;
39
+ readonly exclude?: readonly string[] | undefined;
40
+ readonly include?: readonly string[] | undefined;
41
+ readonly intent?: readonly Intent[] | undefined;
42
+ readonly layers?: readonly Layer[] | undefined;
43
+ readonly resources?: ResourceOverrideMap | undefined;
44
+ /** Set to `false` to skip topo validation while building routes. */
45
+ readonly validate?: boolean | undefined;
38
46
  }
39
47
 
40
48
  export type HttpMethod = 'GET' | 'POST' | 'DELETE';
@@ -47,9 +55,9 @@ export interface HttpRouteDefinition {
47
55
  readonly path: string;
48
56
  readonly trailId: string;
49
57
  readonly inputSource: InputSource;
50
- readonly trail: Trail<unknown, unknown>;
58
+ readonly trail: Trail<unknown, unknown, unknown>;
51
59
  /**
52
- * Validate input, compose gates, and execute the trail implementation.
60
+ * Validate input, compose layers, and execute the trail implementation.
53
61
  *
54
62
  * The caller is responsible for parsing raw input from the request and
55
63
  * mapping the Result to an HTTP response. This function is framework-agnostic.
@@ -77,7 +85,7 @@ const intentToMethod: Record<string, HttpMethod> = {
77
85
  };
78
86
 
79
87
  /** Derive HTTP method from trail intent. */
80
- const deriveMethod = (trail: Trail<unknown, unknown>): HttpMethod =>
88
+ const deriveMethod = (trail: Trail<unknown, unknown, unknown>): HttpMethod =>
81
89
  intentToMethod[trail.intent] ?? 'POST';
82
90
 
83
91
  /** Derive HTTP path from trail ID: `entity.show` -> `/entity/show`. */
@@ -91,10 +99,6 @@ const derivePath = (basePath: string, trailId: string): string => {
91
99
  const deriveInputSource = (method: HttpMethod): InputSource =>
92
100
  method === 'GET' ? 'query' : 'body';
93
101
 
94
- /** Check if a trail should be included (skip internal trails). */
95
- const shouldInclude = (trail: Trail<unknown, unknown>): boolean =>
96
- trail.meta?.['internal'] !== true;
97
-
98
102
  /** Build per-request context overrides with the HTTP trailhead marker. */
99
103
  const withHttpTrailhead = (
100
104
  requestId: string | undefined
@@ -117,9 +121,10 @@ const withHttpTrailhead = (
117
121
  */
118
122
  const createExecute =
119
123
  (
120
- t: Trail<unknown, unknown>,
121
- gates: readonly Gate[],
122
- options: BuildHttpRoutesOptions
124
+ graph: Topo,
125
+ t: Trail<unknown, unknown, unknown>,
126
+ layers: readonly Layer[],
127
+ options: DeriveHttpRoutesOptions
123
128
  ): HttpRouteDefinition['execute'] =>
124
129
  (input, requestId, abortSignal) =>
125
130
  executeTrail(t, input, {
@@ -127,8 +132,9 @@ const createExecute =
127
132
  configValues: options.configValues,
128
133
  createContext: options.createContext,
129
134
  ctx: withHttpTrailhead(requestId),
130
- gates,
131
- provisions: options.provisions,
135
+ layers,
136
+ resources: options.resources,
137
+ topo: graph,
132
138
  });
133
139
 
134
140
  // ---------------------------------------------------------------------------
@@ -136,20 +142,28 @@ const createExecute =
136
142
  // ---------------------------------------------------------------------------
137
143
 
138
144
  /** Filter topo items to eligible trails. */
139
- const eligibleTrails = (app: Topo): Trail<unknown, unknown>[] =>
140
- app.list().filter((trail) => shouldInclude(trail));
145
+ const eligibleTrails = (
146
+ graph: Topo,
147
+ options: DeriveHttpRoutesOptions
148
+ ): Trail<unknown, unknown, unknown>[] =>
149
+ filterSurfaceTrails(graph.list(), {
150
+ exclude: options.exclude,
151
+ include: options.include,
152
+ intent: options.intent,
153
+ });
141
154
 
142
155
  /** Build a single route definition from a trail. */
143
156
  const buildRoute = (
144
- trail: Trail<unknown, unknown>,
157
+ graph: Topo,
158
+ trail: Trail<unknown, unknown, unknown>,
145
159
  basePath: string,
146
- gates: readonly Gate[],
147
- options: BuildHttpRoutesOptions
160
+ layers: readonly Layer[],
161
+ options: DeriveHttpRoutesOptions
148
162
  ): HttpRouteDefinition => {
149
163
  const method = deriveMethod(trail);
150
164
  const path = derivePath(basePath, trail.id);
151
165
  return {
152
- execute: createExecute(trail, gates, options),
166
+ execute: createExecute(graph, trail, layers, options),
153
167
  inputSource: deriveInputSource(method),
154
168
  method,
155
169
  path,
@@ -188,16 +202,17 @@ const registerRoute = (
188
202
 
189
203
  /** Accumulate route definitions, returning early on the first collision. */
190
204
  const accumulateRoutes = (
191
- trails: Trail<unknown, unknown>[],
205
+ graph: Topo,
206
+ trails: Trail<unknown, unknown, unknown>[],
192
207
  basePath: string,
193
- gates: readonly Gate[],
194
- options: BuildHttpRoutesOptions
208
+ layers: readonly Layer[],
209
+ options: DeriveHttpRoutesOptions
195
210
  ): Result<HttpRouteDefinition[], Error> => {
196
211
  const routes: HttpRouteDefinition[] = [];
197
212
  const seenRoutes = new Map<string, string>();
198
213
 
199
214
  for (const trail of trails) {
200
- const route = buildRoute(trail, basePath, gates, options);
215
+ const route = buildRoute(graph, trail, basePath, layers, options);
201
216
  const registered = registerRoute(route, seenRoutes, routes);
202
217
  if (registered.isErr()) {
203
218
  return registered;
@@ -218,16 +233,29 @@ const accumulateRoutes = (
218
233
  * - An HTTP method derived from intent (read -> GET, write -> POST, destroy -> DELETE)
219
234
  * - A path derived from the trail ID (dots become slashes)
220
235
  * - An input source derived from the method (GET -> query, others -> body)
221
- * - An `execute` function that validates, gates, and runs the implementation
236
+ * - An `execute` function that validates, layers, and runs the implementation
222
237
  *
223
238
  * Returns `Result.err(ValidationError)` if two trails derive the same
224
239
  * (method, path) pair. Returns `Result.ok(routes)` on success.
225
240
  */
226
- export const buildHttpRoutes = (
227
- app: Topo,
228
- options: BuildHttpRoutesOptions = {}
241
+ export const deriveHttpRoutes = (
242
+ graph: Topo,
243
+ options: DeriveHttpRoutesOptions = {}
229
244
  ): Result<HttpRouteDefinition[], Error> => {
245
+ if (options.validate !== false) {
246
+ const validated = validateEstablishedTopo(graph);
247
+ if (validated.isErr()) {
248
+ return Result.err(validated.error);
249
+ }
250
+ }
251
+
230
252
  const basePath = (options.basePath ?? '').replace(/\/+$/, '');
231
- const gates = options.gates ?? [];
232
- return accumulateRoutes(eligibleTrails(app), basePath, gates, options);
253
+ const layers = options.layers ?? [];
254
+ return accumulateRoutes(
255
+ graph,
256
+ eligibleTrails(graph, options),
257
+ basePath,
258
+ layers,
259
+ options
260
+ );
233
261
  };