@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.
- package/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +39 -15
- package/dist/build.d.ts +15 -10
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +25 -16
- package/dist/build.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -9
- package/src/__tests__/build.test.ts +176 -45
- package/src/__tests__/derive.test.ts +31 -0
- package/src/build.ts +63 -35
- package/src/index.ts +2 -2
- package/tsconfig.tests.json +10 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/blaze.d.ts +0 -25
- package/dist/blaze.d.ts.map +0 -1
- package/dist/blaze.js +0 -69
- package/dist/blaze.js.map +0 -1
- package/dist/hono/blaze.d.ts +0 -33
- package/dist/hono/blaze.d.ts.map +0 -1
- package/dist/hono/blaze.js +0 -232
- package/dist/hono/blaze.js.map +0 -1
- package/dist/hono/index.d.ts +0 -2
- package/dist/hono/index.d.ts.map +0 -1
- package/dist/hono/index.js +0 -2
- package/dist/hono/index.js.map +0 -1
- package/dist/hono/trailhead.d.ts +0 -33
- package/dist/hono/trailhead.d.ts.map +0 -1
- package/dist/hono/trailhead.js +0 -232
- package/dist/hono/trailhead.js.map +0 -1
- package/src/hono/__tests__/trailhead.test.ts +0 -527
- package/src/hono/index.ts +0 -1
- package/src/hono/trailhead.ts +0 -352
|
@@ -5,15 +5,16 @@ import {
|
|
|
5
5
|
NotFoundError,
|
|
6
6
|
Result,
|
|
7
7
|
TRAILHEAD_KEY,
|
|
8
|
-
|
|
8
|
+
resource,
|
|
9
|
+
signal,
|
|
9
10
|
ValidationError,
|
|
10
11
|
trail,
|
|
11
12
|
topo,
|
|
12
13
|
} from '@ontrails/core';
|
|
13
|
-
import type {
|
|
14
|
+
import type { Layer, TrailContext } from '@ontrails/core';
|
|
14
15
|
import { z } from 'zod';
|
|
15
16
|
|
|
16
|
-
import {
|
|
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
|
|
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
|
-
|
|
62
|
+
visibility: 'internal',
|
|
62
63
|
});
|
|
63
64
|
|
|
64
|
-
const
|
|
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('
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
171
|
-
const result =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
328
|
-
const
|
|
443
|
+
test('forwards resource overrides into executeTrail', async () => {
|
|
444
|
+
const resourceTrail = trail('resource.check', {
|
|
329
445
|
blaze: (_input, ctx) =>
|
|
330
|
-
Result.ok({ source:
|
|
446
|
+
Result.ok({ source: dbResource.from(ctx).source as string }),
|
|
331
447
|
input: z.object({}),
|
|
332
448
|
output: z.object({ source: z.string() }),
|
|
333
|
-
|
|
449
|
+
resources: [dbResource],
|
|
334
450
|
});
|
|
335
451
|
|
|
336
|
-
const app = topo('testapp', {
|
|
337
|
-
const buildResult =
|
|
338
|
-
|
|
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('
|
|
351
|
-
test('
|
|
466
|
+
describe('layers', () => {
|
|
467
|
+
test('layers compose around trail execution', async () => {
|
|
352
468
|
const calls: string[] = [];
|
|
353
469
|
|
|
354
|
-
const testGate:
|
|
355
|
-
name: 'test-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
30
|
+
export interface DeriveHttpRoutesOptions {
|
|
28
31
|
readonly basePath?: string | undefined;
|
|
29
|
-
/** Config values for
|
|
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
|
|
37
|
-
readonly
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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 = (
|
|
140
|
-
|
|
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
|
-
|
|
157
|
+
graph: Topo,
|
|
158
|
+
trail: Trail<unknown, unknown, unknown>,
|
|
145
159
|
basePath: string,
|
|
146
|
-
|
|
147
|
-
options:
|
|
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,
|
|
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
|
-
|
|
205
|
+
graph: Topo,
|
|
206
|
+
trails: Trail<unknown, unknown, unknown>[],
|
|
192
207
|
basePath: string,
|
|
193
|
-
|
|
194
|
-
options:
|
|
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,
|
|
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,
|
|
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
|
|
227
|
-
|
|
228
|
-
options:
|
|
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
|
|
232
|
-
return accumulateRoutes(
|
|
253
|
+
const layers = options.layers ?? [];
|
|
254
|
+
return accumulateRoutes(
|
|
255
|
+
graph,
|
|
256
|
+
eligibleTrails(graph, options),
|
|
257
|
+
basePath,
|
|
258
|
+
layers,
|
|
259
|
+
options
|
|
260
|
+
);
|
|
233
261
|
};
|