@ontrails/http 1.0.0-beta.14 → 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 +7 -0
- package/README.md +39 -15
- package/dist/build.d.ts +13 -10
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +20 -17
- 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 +162 -46
- package/src/__tests__/derive.test.ts +31 -0
- package/src/build.ts +54 -36
- 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 -36
- package/dist/hono/trailhead.d.ts.map +0 -1
- package/dist/hono/trailhead.js +0 -222
- 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 -340
|
@@ -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,7 +586,7 @@ 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');
|
|
@@ -485,7 +601,7 @@ describe('buildHttpRoutes', () => {
|
|
|
485
601
|
input: z.object({}),
|
|
486
602
|
});
|
|
487
603
|
|
|
488
|
-
const result =
|
|
604
|
+
const result = deriveHttpRoutes(topo('testapp', { draftTrail }));
|
|
489
605
|
|
|
490
606
|
expect(result.isErr()).toBe(true);
|
|
491
607
|
expect(result.error?.message).toMatch(/draft/i);
|
|
@@ -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,11 +11,13 @@ import {
|
|
|
11
11
|
TRAILHEAD_KEY,
|
|
12
12
|
ValidationError,
|
|
13
13
|
executeTrail,
|
|
14
|
+
filterSurfaceTrails,
|
|
14
15
|
validateEstablishedTopo,
|
|
15
16
|
} from '@ontrails/core';
|
|
16
17
|
import type {
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
Intent,
|
|
19
|
+
Layer,
|
|
20
|
+
ResourceOverrideMap,
|
|
19
21
|
Topo,
|
|
20
22
|
Trail,
|
|
21
23
|
TrailContextInit,
|
|
@@ -25,17 +27,20 @@ import type {
|
|
|
25
27
|
// Public types
|
|
26
28
|
// ---------------------------------------------------------------------------
|
|
27
29
|
|
|
28
|
-
export interface
|
|
30
|
+
export interface DeriveHttpRoutesOptions {
|
|
29
31
|
readonly basePath?: string | undefined;
|
|
30
|
-
/** Config values for
|
|
32
|
+
/** Config values for resources that declare a `config` schema, keyed by resource ID. */
|
|
31
33
|
readonly configValues?:
|
|
32
34
|
| Readonly<Record<string, Record<string, unknown>>>
|
|
33
35
|
| undefined;
|
|
34
36
|
readonly createContext?:
|
|
35
37
|
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
36
38
|
| undefined;
|
|
37
|
-
readonly
|
|
38
|
-
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;
|
|
39
44
|
/** Set to `false` to skip topo validation while building routes. */
|
|
40
45
|
readonly validate?: boolean | undefined;
|
|
41
46
|
}
|
|
@@ -50,9 +55,9 @@ export interface HttpRouteDefinition {
|
|
|
50
55
|
readonly path: string;
|
|
51
56
|
readonly trailId: string;
|
|
52
57
|
readonly inputSource: InputSource;
|
|
53
|
-
readonly trail: Trail<unknown, unknown>;
|
|
58
|
+
readonly trail: Trail<unknown, unknown, unknown>;
|
|
54
59
|
/**
|
|
55
|
-
* Validate input, compose
|
|
60
|
+
* Validate input, compose layers, and execute the trail implementation.
|
|
56
61
|
*
|
|
57
62
|
* The caller is responsible for parsing raw input from the request and
|
|
58
63
|
* mapping the Result to an HTTP response. This function is framework-agnostic.
|
|
@@ -80,7 +85,7 @@ const intentToMethod: Record<string, HttpMethod> = {
|
|
|
80
85
|
};
|
|
81
86
|
|
|
82
87
|
/** Derive HTTP method from trail intent. */
|
|
83
|
-
const deriveMethod = (trail: Trail<unknown, unknown>): HttpMethod =>
|
|
88
|
+
const deriveMethod = (trail: Trail<unknown, unknown, unknown>): HttpMethod =>
|
|
84
89
|
intentToMethod[trail.intent] ?? 'POST';
|
|
85
90
|
|
|
86
91
|
/** Derive HTTP path from trail ID: `entity.show` -> `/entity/show`. */
|
|
@@ -94,10 +99,6 @@ const derivePath = (basePath: string, trailId: string): string => {
|
|
|
94
99
|
const deriveInputSource = (method: HttpMethod): InputSource =>
|
|
95
100
|
method === 'GET' ? 'query' : 'body';
|
|
96
101
|
|
|
97
|
-
/** Check if a trail should be included (skip internal trails). */
|
|
98
|
-
const shouldInclude = (trail: Trail<unknown, unknown>): boolean =>
|
|
99
|
-
trail.meta?.['internal'] !== true;
|
|
100
|
-
|
|
101
102
|
/** Build per-request context overrides with the HTTP trailhead marker. */
|
|
102
103
|
const withHttpTrailhead = (
|
|
103
104
|
requestId: string | undefined
|
|
@@ -120,9 +121,10 @@ const withHttpTrailhead = (
|
|
|
120
121
|
*/
|
|
121
122
|
const createExecute =
|
|
122
123
|
(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
graph: Topo,
|
|
125
|
+
t: Trail<unknown, unknown, unknown>,
|
|
126
|
+
layers: readonly Layer[],
|
|
127
|
+
options: DeriveHttpRoutesOptions
|
|
126
128
|
): HttpRouteDefinition['execute'] =>
|
|
127
129
|
(input, requestId, abortSignal) =>
|
|
128
130
|
executeTrail(t, input, {
|
|
@@ -130,8 +132,9 @@ const createExecute =
|
|
|
130
132
|
configValues: options.configValues,
|
|
131
133
|
createContext: options.createContext,
|
|
132
134
|
ctx: withHttpTrailhead(requestId),
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
layers,
|
|
136
|
+
resources: options.resources,
|
|
137
|
+
topo: graph,
|
|
135
138
|
});
|
|
136
139
|
|
|
137
140
|
// ---------------------------------------------------------------------------
|
|
@@ -139,20 +142,28 @@ const createExecute =
|
|
|
139
142
|
// ---------------------------------------------------------------------------
|
|
140
143
|
|
|
141
144
|
/** Filter topo items to eligible trails. */
|
|
142
|
-
const eligibleTrails = (
|
|
143
|
-
|
|
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
|
+
});
|
|
144
154
|
|
|
145
155
|
/** Build a single route definition from a trail. */
|
|
146
156
|
const buildRoute = (
|
|
147
|
-
|
|
157
|
+
graph: Topo,
|
|
158
|
+
trail: Trail<unknown, unknown, unknown>,
|
|
148
159
|
basePath: string,
|
|
149
|
-
|
|
150
|
-
options:
|
|
160
|
+
layers: readonly Layer[],
|
|
161
|
+
options: DeriveHttpRoutesOptions
|
|
151
162
|
): HttpRouteDefinition => {
|
|
152
163
|
const method = deriveMethod(trail);
|
|
153
164
|
const path = derivePath(basePath, trail.id);
|
|
154
165
|
return {
|
|
155
|
-
execute: createExecute(trail,
|
|
166
|
+
execute: createExecute(graph, trail, layers, options),
|
|
156
167
|
inputSource: deriveInputSource(method),
|
|
157
168
|
method,
|
|
158
169
|
path,
|
|
@@ -191,16 +202,17 @@ const registerRoute = (
|
|
|
191
202
|
|
|
192
203
|
/** Accumulate route definitions, returning early on the first collision. */
|
|
193
204
|
const accumulateRoutes = (
|
|
194
|
-
|
|
205
|
+
graph: Topo,
|
|
206
|
+
trails: Trail<unknown, unknown, unknown>[],
|
|
195
207
|
basePath: string,
|
|
196
|
-
|
|
197
|
-
options:
|
|
208
|
+
layers: readonly Layer[],
|
|
209
|
+
options: DeriveHttpRoutesOptions
|
|
198
210
|
): Result<HttpRouteDefinition[], Error> => {
|
|
199
211
|
const routes: HttpRouteDefinition[] = [];
|
|
200
212
|
const seenRoutes = new Map<string, string>();
|
|
201
213
|
|
|
202
214
|
for (const trail of trails) {
|
|
203
|
-
const route = buildRoute(trail, basePath,
|
|
215
|
+
const route = buildRoute(graph, trail, basePath, layers, options);
|
|
204
216
|
const registered = registerRoute(route, seenRoutes, routes);
|
|
205
217
|
if (registered.isErr()) {
|
|
206
218
|
return registered;
|
|
@@ -221,23 +233,29 @@ const accumulateRoutes = (
|
|
|
221
233
|
* - An HTTP method derived from intent (read -> GET, write -> POST, destroy -> DELETE)
|
|
222
234
|
* - A path derived from the trail ID (dots become slashes)
|
|
223
235
|
* - An input source derived from the method (GET -> query, others -> body)
|
|
224
|
-
* - An `execute` function that validates,
|
|
236
|
+
* - An `execute` function that validates, layers, and runs the implementation
|
|
225
237
|
*
|
|
226
238
|
* Returns `Result.err(ValidationError)` if two trails derive the same
|
|
227
239
|
* (method, path) pair. Returns `Result.ok(routes)` on success.
|
|
228
240
|
*/
|
|
229
|
-
export const
|
|
230
|
-
|
|
231
|
-
options:
|
|
241
|
+
export const deriveHttpRoutes = (
|
|
242
|
+
graph: Topo,
|
|
243
|
+
options: DeriveHttpRoutesOptions = {}
|
|
232
244
|
): Result<HttpRouteDefinition[], Error> => {
|
|
233
245
|
if (options.validate !== false) {
|
|
234
|
-
const validated = validateEstablishedTopo(
|
|
246
|
+
const validated = validateEstablishedTopo(graph);
|
|
235
247
|
if (validated.isErr()) {
|
|
236
248
|
return Result.err(validated.error);
|
|
237
249
|
}
|
|
238
250
|
}
|
|
239
251
|
|
|
240
252
|
const basePath = (options.basePath ?? '').replace(/\/+$/, '');
|
|
241
|
-
const
|
|
242
|
-
return accumulateRoutes(
|
|
253
|
+
const layers = options.layers ?? [];
|
|
254
|
+
return accumulateRoutes(
|
|
255
|
+
graph,
|
|
256
|
+
eligibleTrails(graph, options),
|
|
257
|
+
basePath,
|
|
258
|
+
layers,
|
|
259
|
+
options
|
|
260
|
+
);
|
|
243
261
|
};
|