@ontrails/http 1.0.0-beta.15 → 1.0.0-beta.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +18 -5
- package/package.json +10 -2
- package/src/build.ts +1095 -54
- package/src/index.ts +16 -2
- package/src/method.ts +24 -0
- package/src/openapi.ts +357 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/build.d.ts +0 -57
- package/dist/build.d.ts.map +0 -1
- package/dist/build.js +0 -130
- package/dist/build.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -3
- package/dist/index.js.map +0 -1
- package/src/__tests__/build.test.ts +0 -610
- package/src/__tests__/derive.test.ts +0 -31
- package/tsconfig.json +0 -9
- package/tsconfig.tests.json +0 -10
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,610 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
InternalError,
|
|
5
|
-
NotFoundError,
|
|
6
|
-
Result,
|
|
7
|
-
TRAILHEAD_KEY,
|
|
8
|
-
resource,
|
|
9
|
-
signal,
|
|
10
|
-
ValidationError,
|
|
11
|
-
trail,
|
|
12
|
-
topo,
|
|
13
|
-
} from '@ontrails/core';
|
|
14
|
-
import type { Layer, TrailContext } from '@ontrails/core';
|
|
15
|
-
import { z } from 'zod';
|
|
16
|
-
|
|
17
|
-
import { deriveHttpRoutes } from '../build.js';
|
|
18
|
-
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
// Test trails
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
|
|
23
|
-
const echoTrail = trail('echo', {
|
|
24
|
-
blaze: (input) => Result.ok({ reply: input.message }),
|
|
25
|
-
description: 'Echo a message back',
|
|
26
|
-
input: z.object({ message: z.string() }),
|
|
27
|
-
intent: 'read',
|
|
28
|
-
output: z.object({ reply: z.string() }),
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
const createTrail = trail('item.create', {
|
|
32
|
-
blaze: (input) => Result.ok({ id: '123', name: input.name }),
|
|
33
|
-
description: 'Create an item',
|
|
34
|
-
input: z.object({ name: z.string() }),
|
|
35
|
-
output: z.object({ id: z.string(), name: z.string() }),
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const deleteTrail = trail('item.delete', {
|
|
39
|
-
blaze: (_input) => Result.ok({ deleted: true }),
|
|
40
|
-
description: 'Delete an item',
|
|
41
|
-
input: z.object({ id: z.string() }),
|
|
42
|
-
intent: 'destroy',
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
const notFoundTrail = trail('item.get', {
|
|
46
|
-
blaze: (_input) => Result.err(new NotFoundError('Item not found')),
|
|
47
|
-
description: 'Get an item that does not exist',
|
|
48
|
-
input: z.object({ id: z.string() }),
|
|
49
|
-
intent: 'read',
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const internalTrail = trail('crash', {
|
|
53
|
-
blaze: () => Result.err(new InternalError('Something broke')),
|
|
54
|
-
description: 'Always fails with internal error',
|
|
55
|
-
input: z.object({}),
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const internalVisibilityTrail = trail('secret', {
|
|
59
|
-
blaze: () => Result.ok({ ok: true }),
|
|
60
|
-
description: 'Internal trail that should be skipped',
|
|
61
|
-
input: z.object({}),
|
|
62
|
-
visibility: 'internal',
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
const dbResource = resource('db.main', {
|
|
66
|
-
create: () =>
|
|
67
|
-
Result.ok({
|
|
68
|
-
source: 'factory',
|
|
69
|
-
}),
|
|
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
|
-
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// Tests
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
describe('deriveHttpRoutes', () => {
|
|
88
|
-
describe('method derivation', () => {
|
|
89
|
-
test('intent: read maps to GET', () => {
|
|
90
|
-
const app = topo('testapp', { echoTrail });
|
|
91
|
-
const result = deriveHttpRoutes(app);
|
|
92
|
-
|
|
93
|
-
expect(result.isOk()).toBe(true);
|
|
94
|
-
const routes = result.value;
|
|
95
|
-
expect(routes).toHaveLength(1);
|
|
96
|
-
expect(routes[0]?.method).toBe('GET');
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test('intent: destroy maps to DELETE', () => {
|
|
100
|
-
const app = topo('testapp', { deleteTrail });
|
|
101
|
-
const result = deriveHttpRoutes(app);
|
|
102
|
-
|
|
103
|
-
expect(result.isOk()).toBe(true);
|
|
104
|
-
const routes = result.value;
|
|
105
|
-
expect(routes).toHaveLength(1);
|
|
106
|
-
expect(routes[0]?.method).toBe('DELETE');
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test('default intent (write) maps to POST', () => {
|
|
110
|
-
const app = topo('testapp', { createTrail });
|
|
111
|
-
const result = deriveHttpRoutes(app);
|
|
112
|
-
|
|
113
|
-
expect(result.isOk()).toBe(true);
|
|
114
|
-
const routes = result.value;
|
|
115
|
-
expect(routes).toHaveLength(1);
|
|
116
|
-
expect(routes[0]?.method).toBe('POST');
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe('path derivation', () => {
|
|
121
|
-
test('dotted ID becomes slashed path', () => {
|
|
122
|
-
const app = topo('testapp', { createTrail });
|
|
123
|
-
const result = deriveHttpRoutes(app);
|
|
124
|
-
|
|
125
|
-
expect(result.isOk()).toBe(true);
|
|
126
|
-
expect(result.value[0]?.path).toBe('/item/create');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test('simple ID becomes /id', () => {
|
|
130
|
-
const app = topo('testapp', { echoTrail });
|
|
131
|
-
const result = deriveHttpRoutes(app);
|
|
132
|
-
|
|
133
|
-
expect(result.isOk()).toBe(true);
|
|
134
|
-
expect(result.value[0]?.path).toBe('/echo');
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('basePath is prepended', () => {
|
|
138
|
-
const app = topo('testapp', { echoTrail });
|
|
139
|
-
const result = deriveHttpRoutes(app, { basePath: '/api/v1' });
|
|
140
|
-
|
|
141
|
-
expect(result.isOk()).toBe(true);
|
|
142
|
-
expect(result.value[0]?.path).toBe('/api/v1/echo');
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test('basePath trailing slash is normalized', () => {
|
|
146
|
-
const app = topo('testapp', { echoTrail });
|
|
147
|
-
const result = deriveHttpRoutes(app, { basePath: '/api/v1/' });
|
|
148
|
-
|
|
149
|
-
expect(result.isOk()).toBe(true);
|
|
150
|
-
expect(result.value[0]?.path).toBe('/api/v1/echo');
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
describe('input source derivation', () => {
|
|
155
|
-
test('GET routes use query input source', () => {
|
|
156
|
-
const app = topo('testapp', { echoTrail });
|
|
157
|
-
const result = deriveHttpRoutes(app);
|
|
158
|
-
|
|
159
|
-
expect(result.isOk()).toBe(true);
|
|
160
|
-
expect(result.value[0]?.inputSource).toBe('query');
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test('POST routes use body input source', () => {
|
|
164
|
-
const app = topo('testapp', { createTrail });
|
|
165
|
-
const result = deriveHttpRoutes(app);
|
|
166
|
-
|
|
167
|
-
expect(result.isOk()).toBe(true);
|
|
168
|
-
expect(result.value[0]?.inputSource).toBe('body');
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test('DELETE routes use body input source', () => {
|
|
172
|
-
const app = topo('testapp', { deleteTrail });
|
|
173
|
-
const result = deriveHttpRoutes(app);
|
|
174
|
-
|
|
175
|
-
expect(result.isOk()).toBe(true);
|
|
176
|
-
expect(result.value[0]?.inputSource).toBe('body');
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
describe('filtering', () => {
|
|
181
|
-
test('internal trails are skipped', () => {
|
|
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);
|
|
253
|
-
|
|
254
|
-
expect(result.isOk()).toBe(true);
|
|
255
|
-
const routes = result.value;
|
|
256
|
-
expect(routes).toHaveLength(1);
|
|
257
|
-
expect(routes[0]?.trailId).toBe('echo');
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
describe('route definition shape', () => {
|
|
262
|
-
test('includes trail reference', () => {
|
|
263
|
-
const app = topo('testapp', { echoTrail });
|
|
264
|
-
const result = deriveHttpRoutes(app);
|
|
265
|
-
|
|
266
|
-
expect(result.isOk()).toBe(true);
|
|
267
|
-
expect(result.value[0]?.trail).toBe(echoTrail);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
test('execute is a function', () => {
|
|
271
|
-
const app = topo('testapp', { echoTrail });
|
|
272
|
-
const result = deriveHttpRoutes(app);
|
|
273
|
-
|
|
274
|
-
expect(result.isOk()).toBe(true);
|
|
275
|
-
expect(typeof result.value[0]?.execute).toBe('function');
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
describe('execute', () => {
|
|
280
|
-
test('returns ok Result on valid input', async () => {
|
|
281
|
-
const app = topo('testapp', { echoTrail });
|
|
282
|
-
const buildResult = deriveHttpRoutes(app);
|
|
283
|
-
|
|
284
|
-
expect(buildResult.isOk()).toBe(true);
|
|
285
|
-
const [route] = buildResult.value;
|
|
286
|
-
|
|
287
|
-
const result = await route?.execute({ message: 'hello' });
|
|
288
|
-
expect(result?.isOk()).toBe(true);
|
|
289
|
-
expect(result?.value).toEqual({ reply: 'hello' });
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
test('returns err Result on invalid input', async () => {
|
|
293
|
-
const app = topo('testapp', { echoTrail });
|
|
294
|
-
const buildResult = deriveHttpRoutes(app);
|
|
295
|
-
|
|
296
|
-
expect(buildResult.isOk()).toBe(true);
|
|
297
|
-
const [route] = buildResult.value;
|
|
298
|
-
|
|
299
|
-
const result = await route?.execute({});
|
|
300
|
-
expect(result?.isErr()).toBe(true);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
test('returns err Result from trail error', async () => {
|
|
304
|
-
const app = topo('testapp', { notFoundTrail });
|
|
305
|
-
const buildResult = deriveHttpRoutes(app);
|
|
306
|
-
|
|
307
|
-
expect(buildResult.isOk()).toBe(true);
|
|
308
|
-
const [route] = buildResult.value;
|
|
309
|
-
|
|
310
|
-
const result = await route?.execute({ id: 'missing' });
|
|
311
|
-
expect(result?.isErr()).toBe(true);
|
|
312
|
-
expect(result?.error?.message).toBe('Item not found');
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
test('returns err Result from internal error', async () => {
|
|
316
|
-
const app = topo('testapp', { internalTrail });
|
|
317
|
-
const buildResult = deriveHttpRoutes(app);
|
|
318
|
-
|
|
319
|
-
expect(buildResult.isOk()).toBe(true);
|
|
320
|
-
const [route] = buildResult.value;
|
|
321
|
-
|
|
322
|
-
const result = await route?.execute({});
|
|
323
|
-
expect(result?.isErr()).toBe(true);
|
|
324
|
-
expect(result?.error?.message).toBe('Something broke');
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
test('returns err Result when run function throws', async () => {
|
|
328
|
-
const throwingTrail = trail('throwing', {
|
|
329
|
-
blaze: () => {
|
|
330
|
-
throw new Error('unexpected throw');
|
|
331
|
-
},
|
|
332
|
-
input: z.object({}),
|
|
333
|
-
});
|
|
334
|
-
const app = topo('testapp', { throwingTrail });
|
|
335
|
-
const buildResult = deriveHttpRoutes(app);
|
|
336
|
-
|
|
337
|
-
expect(buildResult.isOk()).toBe(true);
|
|
338
|
-
const [route] = buildResult.value;
|
|
339
|
-
|
|
340
|
-
const result = await route?.execute({});
|
|
341
|
-
expect(result?.isErr()).toBe(true);
|
|
342
|
-
expect(result?.error).toBeInstanceOf(InternalError);
|
|
343
|
-
expect(result?.error?.message).toBe('unexpected throw');
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
test('returns err Result when createContext throws', async () => {
|
|
347
|
-
const app = topo('testapp', { echoTrail });
|
|
348
|
-
const buildResult = deriveHttpRoutes(app, {
|
|
349
|
-
createContext: () => {
|
|
350
|
-
throw new Error('context creation failed');
|
|
351
|
-
},
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
expect(buildResult.isOk()).toBe(true);
|
|
355
|
-
const [route] = buildResult.value;
|
|
356
|
-
|
|
357
|
-
const result = await route?.execute({ message: 'hi' });
|
|
358
|
-
expect(result?.isErr()).toBe(true);
|
|
359
|
-
expect(result?.error).toBeInstanceOf(InternalError);
|
|
360
|
-
expect(result?.error?.message).toBe('context creation failed');
|
|
361
|
-
});
|
|
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
|
-
|
|
398
|
-
test('passes requestId to context', async () => {
|
|
399
|
-
let capturedRequestId: string | undefined;
|
|
400
|
-
|
|
401
|
-
const ctxTrail = trail('ctx.check', {
|
|
402
|
-
blaze: (_input, ctx) => {
|
|
403
|
-
capturedRequestId = ctx.requestId;
|
|
404
|
-
return Result.ok({ ok: true });
|
|
405
|
-
},
|
|
406
|
-
input: z.object({}),
|
|
407
|
-
intent: 'read',
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
const app = topo('testapp', { ctxTrail });
|
|
411
|
-
const buildResult = deriveHttpRoutes(app);
|
|
412
|
-
|
|
413
|
-
expect(buildResult.isOk()).toBe(true);
|
|
414
|
-
const [route] = buildResult.value;
|
|
415
|
-
|
|
416
|
-
await route?.execute({}, 'custom-req-123');
|
|
417
|
-
expect(capturedRequestId).toBe('custom-req-123');
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
test('uses default requestId when none provided', async () => {
|
|
421
|
-
let capturedRequestId: string | undefined;
|
|
422
|
-
|
|
423
|
-
const ctxTrail = trail('ctx.default', {
|
|
424
|
-
blaze: (_input, ctx) => {
|
|
425
|
-
capturedRequestId = ctx.requestId;
|
|
426
|
-
return Result.ok({ ok: true });
|
|
427
|
-
},
|
|
428
|
-
input: z.object({}),
|
|
429
|
-
intent: 'read',
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
const app = topo('testapp', { ctxTrail });
|
|
433
|
-
const buildResult = deriveHttpRoutes(app);
|
|
434
|
-
|
|
435
|
-
expect(buildResult.isOk()).toBe(true);
|
|
436
|
-
const [route] = buildResult.value;
|
|
437
|
-
|
|
438
|
-
await route?.execute({});
|
|
439
|
-
expect(capturedRequestId).toBeDefined();
|
|
440
|
-
expect(capturedRequestId).not.toBe('');
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
test('forwards resource overrides into executeTrail', async () => {
|
|
444
|
-
const resourceTrail = trail('resource.check', {
|
|
445
|
-
blaze: (_input, ctx) =>
|
|
446
|
-
Result.ok({ source: dbResource.from(ctx).source as string }),
|
|
447
|
-
input: z.object({}),
|
|
448
|
-
output: z.object({ source: z.string() }),
|
|
449
|
-
resources: [dbResource],
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
const app = topo('testapp', { dbResource, resourceTrail });
|
|
453
|
-
const buildResult = deriveHttpRoutes(app, {
|
|
454
|
-
resources: { 'db.main': { source: 'override' } },
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
expect(buildResult.isOk()).toBe(true);
|
|
458
|
-
const [route] = buildResult.value;
|
|
459
|
-
|
|
460
|
-
const result = await route?.execute({});
|
|
461
|
-
expect(result?.isOk()).toBe(true);
|
|
462
|
-
expect(result?.value).toEqual({ source: 'override' });
|
|
463
|
-
});
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
describe('layers', () => {
|
|
467
|
-
test('layers compose around trail execution', async () => {
|
|
468
|
-
const calls: string[] = [];
|
|
469
|
-
|
|
470
|
-
const testGate: Layer = {
|
|
471
|
-
name: 'test-layer',
|
|
472
|
-
wrap(_trail, impl) {
|
|
473
|
-
return async (input, ctx) => {
|
|
474
|
-
calls.push('before');
|
|
475
|
-
const result = await impl(input, ctx);
|
|
476
|
-
calls.push('after');
|
|
477
|
-
return result;
|
|
478
|
-
};
|
|
479
|
-
},
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
const app = topo('testapp', { echoTrail });
|
|
483
|
-
const buildResult = deriveHttpRoutes(app, { layers: [testGate] });
|
|
484
|
-
|
|
485
|
-
expect(buildResult.isOk()).toBe(true);
|
|
486
|
-
const [route] = buildResult.value;
|
|
487
|
-
|
|
488
|
-
const result = await route?.execute({ message: 'hi' });
|
|
489
|
-
expect(result?.isOk()).toBe(true);
|
|
490
|
-
expect(calls).toEqual(['before', 'after']);
|
|
491
|
-
});
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
describe('custom createContext', () => {
|
|
495
|
-
test('custom createContext is used when provided', async () => {
|
|
496
|
-
const contextState = { custom: false, trailheadMarker: false };
|
|
497
|
-
|
|
498
|
-
const ctxTrail = trail('ctx.custom', {
|
|
499
|
-
blaze: (_input, ctx) => {
|
|
500
|
-
contextState.custom = ctx.extensions?.['custom'] === true;
|
|
501
|
-
contextState.trailheadMarker =
|
|
502
|
-
ctx.extensions?.[TRAILHEAD_KEY] === 'http';
|
|
503
|
-
return Result.ok({ ok: true });
|
|
504
|
-
},
|
|
505
|
-
input: z.object({}),
|
|
506
|
-
intent: 'read',
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
const app = topo('testapp', { ctxTrail });
|
|
510
|
-
const buildResult = deriveHttpRoutes(app, {
|
|
511
|
-
createContext: () => ({
|
|
512
|
-
abortSignal: new AbortController().signal,
|
|
513
|
-
extensions: { custom: true },
|
|
514
|
-
requestId: 'test-id',
|
|
515
|
-
}),
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
expect(buildResult.isOk()).toBe(true);
|
|
519
|
-
const [route] = buildResult.value;
|
|
520
|
-
|
|
521
|
-
const result = await route?.execute({});
|
|
522
|
-
expect(result?.isOk()).toBe(true);
|
|
523
|
-
expect(contextState.custom).toBe(true);
|
|
524
|
-
expect(contextState.trailheadMarker).toBe(true);
|
|
525
|
-
});
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
describe('collision detection', () => {
|
|
529
|
-
test('returns err on duplicate (path, method) pair', () => {
|
|
530
|
-
// "entity.show" derives path /entity/show (dots become slashes)
|
|
531
|
-
// "entity/show" derives path /entity/show (slashes are preserved)
|
|
532
|
-
// Both have intent: read -> GET, so they collide on GET /entity/show
|
|
533
|
-
const dotTrail = trail('entity.show', {
|
|
534
|
-
blaze: () => Result.ok({ dot: true }),
|
|
535
|
-
description: 'Show entity (dot notation)',
|
|
536
|
-
input: z.object({}),
|
|
537
|
-
intent: 'read',
|
|
538
|
-
});
|
|
539
|
-
const slashTrail = trail('entity/show', {
|
|
540
|
-
blaze: () => Result.ok({ slash: true }),
|
|
541
|
-
description: 'Show entity (slash notation)',
|
|
542
|
-
input: z.object({}),
|
|
543
|
-
intent: 'read',
|
|
544
|
-
});
|
|
545
|
-
const app = topo('testapp', { dotTrail, slashTrail });
|
|
546
|
-
const result = deriveHttpRoutes(app);
|
|
547
|
-
|
|
548
|
-
expect(result.isErr()).toBe(true);
|
|
549
|
-
expect(result.error).toBeInstanceOf(ValidationError);
|
|
550
|
-
expect(result.error?.message).toContain('GET /entity/show');
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
test('same path with different methods is allowed', () => {
|
|
554
|
-
// "item.resource" derives GET /item/resource (intent: read)
|
|
555
|
-
// "item/resource" derives POST /item/resource (default intent: write)
|
|
556
|
-
// Same path, different methods — no collision
|
|
557
|
-
const getItem = trail('item.resource', {
|
|
558
|
-
blaze: () => Result.ok({ get: true }),
|
|
559
|
-
description: 'Get item',
|
|
560
|
-
input: z.object({}),
|
|
561
|
-
intent: 'read',
|
|
562
|
-
});
|
|
563
|
-
const createItem = trail('item/resource', {
|
|
564
|
-
blaze: () => Result.ok({ created: true }),
|
|
565
|
-
description: 'Create item',
|
|
566
|
-
input: z.object({ name: z.string() }),
|
|
567
|
-
});
|
|
568
|
-
const app = topo('testapp', { createItem, getItem });
|
|
569
|
-
const result = deriveHttpRoutes(app);
|
|
570
|
-
|
|
571
|
-
expect(result.isOk()).toBe(true);
|
|
572
|
-
expect(result.value).toHaveLength(2);
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
test('collision error message identifies both trail IDs', () => {
|
|
576
|
-
const dotTrail = trail('entity.show', {
|
|
577
|
-
blaze: () => Result.ok({ one: true }),
|
|
578
|
-
description: 'Trail one',
|
|
579
|
-
input: z.object({}),
|
|
580
|
-
intent: 'read',
|
|
581
|
-
});
|
|
582
|
-
const slashTrail = trail('entity/show', {
|
|
583
|
-
blaze: () => Result.ok({ two: true }),
|
|
584
|
-
description: 'Trail two',
|
|
585
|
-
input: z.object({}),
|
|
586
|
-
intent: 'read',
|
|
587
|
-
});
|
|
588
|
-
const app = topo('testapp', { dotTrail, slashTrail });
|
|
589
|
-
const result = deriveHttpRoutes(app);
|
|
590
|
-
|
|
591
|
-
expect(result.isErr()).toBe(true);
|
|
592
|
-
expect(result.error?.message).toContain('entity');
|
|
593
|
-
});
|
|
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
|
-
});
|
|
610
|
-
});
|
|
@@ -1,31 +0,0 @@
|
|
|
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/tsconfig.json
DELETED
package/tsconfig.tests.json
DELETED
package/tsconfig.tsbuildinfo
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"root":["./src/build.ts","./src/index.ts"],"version":"5.9.3"}
|