@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.
@@ -1,527 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
-
3
- import {
4
- InternalError,
5
- NotFoundError,
6
- Result,
7
- provision,
8
- trail,
9
- topo,
10
- } from '@ontrails/core';
11
- import type { Gate } from '@ontrails/core';
12
- import { z } from 'zod';
13
-
14
- import { trailhead } from '../trailhead.js';
15
-
16
- // ---------------------------------------------------------------------------
17
- // Test trails
18
- // ---------------------------------------------------------------------------
19
-
20
- const echoTrail = trail('echo', {
21
- blaze: (input) => Result.ok({ reply: input.message }),
22
- description: 'Echo a message back',
23
- input: z.object({ message: z.string() }),
24
- intent: 'read',
25
- output: z.object({ reply: z.string() }),
26
- });
27
-
28
- const createTrail = trail('item.create', {
29
- blaze: (input) => Result.ok({ id: '123', name: input.name }),
30
- description: 'Create an item',
31
- input: z.object({ name: z.string() }),
32
- output: z.object({ id: z.string(), name: z.string() }),
33
- });
34
-
35
- const deleteTrail = trail('item.delete', {
36
- blaze: (_input) => Result.ok({ deleted: true }),
37
- description: 'Delete an item',
38
- input: z.object({ id: z.string() }),
39
- intent: 'destroy',
40
- });
41
-
42
- const notFoundTrail = trail('item.get', {
43
- blaze: (_input) => Result.err(new NotFoundError('Item not found')),
44
- description: 'Get an item that does not exist',
45
- input: z.object({ id: z.string() }),
46
- intent: 'read',
47
- });
48
-
49
- const internalTrail = trail('crash', {
50
- blaze: () => Result.err(new InternalError('Something broke')),
51
- description: 'Always fails with internal error',
52
- input: z.object({}),
53
- });
54
-
55
- const dbProvision = provision('db.main', {
56
- create: () =>
57
- Result.ok({
58
- source: 'factory',
59
- }),
60
- });
61
-
62
- // ---------------------------------------------------------------------------
63
- // Helpers
64
- // ---------------------------------------------------------------------------
65
-
66
- /** Make a request against a Hono test app. */
67
- const request = (
68
- app: Awaited<ReturnType<typeof trailhead>>,
69
- method: string,
70
- path: string,
71
- body?: Record<string, unknown>,
72
- headers?: Record<string, string>
73
- ): Promise<Response> => {
74
- const init: RequestInit = { headers: { ...headers }, method };
75
- if (body !== undefined) {
76
- (init.headers as Record<string, string>)['Content-Type'] =
77
- 'application/json';
78
- init.body = JSON.stringify(body);
79
- }
80
- return app.request(path, init);
81
- };
82
-
83
- /** Make a request with a raw string body. */
84
- const requestRaw = (
85
- app: Awaited<ReturnType<typeof trailhead>>,
86
- method: string,
87
- path: string,
88
- rawBody: string,
89
- headers?: Record<string, string>
90
- ): Promise<Response> => {
91
- const init: RequestInit = {
92
- body: rawBody,
93
- headers: { 'Content-Type': 'application/json', ...headers },
94
- method,
95
- };
96
- return app.request(path, init);
97
- };
98
-
99
- // ---------------------------------------------------------------------------
100
- // Tests
101
- // ---------------------------------------------------------------------------
102
-
103
- describe('trailhead (Hono connector)', () => {
104
- describe('validation', () => {
105
- test('trailhead throws on invalid topo', async () => {
106
- const t = trail('broken', {
107
- blaze: () => Result.ok({}),
108
- crosses: ['nonexistent.trail'],
109
- input: z.object({}),
110
- output: z.object({}),
111
- });
112
- const app = topo('test', { t });
113
- await expect(trailhead(app, { serve: false })).rejects.toThrow(
114
- /validation/i
115
- );
116
- });
117
-
118
- test('trailhead skips validation when validate: false', async () => {
119
- const t = trail('broken', {
120
- blaze: () => Result.ok({}),
121
- crosses: ['nonexistent.trail'],
122
- input: z.object({}),
123
- output: z.object({}),
124
- });
125
- const app = topo('test', { t });
126
- await expect(
127
- trailhead(app, { serve: false, validate: false })
128
- ).resolves.toBeDefined();
129
- });
130
- });
131
-
132
- describe('GET handler', () => {
133
- test('returns 200 with data on success', async () => {
134
- const app = topo('testapp', { echoTrail });
135
- const hono = await trailhead(app, { serve: false });
136
-
137
- const res = await request(hono, 'GET', '/echo?message=hello');
138
- expect(res.status).toBe(200);
139
-
140
- const json = await res.json();
141
- expect(json).toEqual({ data: { reply: 'hello' } });
142
- });
143
-
144
- test('returns 400 on invalid input', async () => {
145
- const app = topo('testapp', { echoTrail });
146
- const hono = await trailhead(app, { serve: false });
147
-
148
- const res = await request(hono, 'GET', '/echo');
149
- expect(res.status).toBe(400);
150
-
151
- const json = await res.json();
152
- expect(json.error).toBeDefined();
153
- expect(json.error.category).toBe('validation');
154
- });
155
- });
156
-
157
- describe('POST handler', () => {
158
- test('returns 200 with data on success', async () => {
159
- const app = topo('testapp', { createTrail });
160
- const hono = await trailhead(app, { serve: false });
161
-
162
- const res = await request(hono, 'POST', '/item/create', {
163
- name: 'Widget',
164
- });
165
- expect(res.status).toBe(200);
166
-
167
- const json = await res.json();
168
- expect(json).toEqual({ data: { id: '123', name: 'Widget' } });
169
- });
170
-
171
- test('returns 400 on invalid input', async () => {
172
- const app = topo('testapp', { createTrail });
173
- const hono = await trailhead(app, { serve: false });
174
-
175
- const res = await request(hono, 'POST', '/item/create', {});
176
- expect(res.status).toBe(400);
177
-
178
- const json = await res.json();
179
- expect(json.error.category).toBe('validation');
180
- });
181
-
182
- test('POST with empty input schema succeeds without a body', async () => {
183
- const emptyWriteTrail = trail('empty.write', {
184
- blaze: () => Result.ok({ ok: true }),
185
- input: z.object({}),
186
- intent: 'write',
187
- output: z.object({ ok: z.boolean() }),
188
- });
189
-
190
- const app = topo('testapp', { emptyWriteTrail });
191
- const hono = await trailhead(app, { serve: false });
192
-
193
- // No body, no Content-Type header — mirrors a client that obeys the
194
- // OpenAPI spec (no requestBody declared for empty-input POST routes).
195
- const res = await hono.request('/empty/write', { method: 'POST' });
196
- expect(res.status).toBe(200);
197
-
198
- const json = await res.json();
199
- expect(json).toEqual({ data: { ok: true } });
200
- });
201
- });
202
-
203
- describe('DELETE handler', () => {
204
- test('returns 200 with data on success', async () => {
205
- const app = topo('testapp', { deleteTrail });
206
- const hono = await trailhead(app, { serve: false });
207
-
208
- const res = await request(hono, 'DELETE', '/item/delete', {
209
- id: 'abc',
210
- });
211
- expect(res.status).toBe(200);
212
-
213
- const json = await res.json();
214
- expect(json).toEqual({ data: { deleted: true } });
215
- });
216
- });
217
-
218
- describe('error mapping', () => {
219
- test('NotFoundError maps to 404', async () => {
220
- const app = topo('testapp', { notFoundTrail });
221
- const hono = await trailhead(app, { serve: false });
222
-
223
- const res = await request(hono, 'GET', '/item/get?id=missing');
224
- expect(res.status).toBe(404);
225
-
226
- const json = await res.json();
227
- expect(json.error.category).toBe('not_found');
228
- expect(json.error.message).toBe('Item not found');
229
- });
230
-
231
- test('InternalError maps to 500', async () => {
232
- const app = topo('testapp', { internalTrail });
233
- const hono = await trailhead(app, { serve: false });
234
-
235
- const res = await request(hono, 'POST', '/crash', {});
236
- expect(res.status).toBe(500);
237
-
238
- const json = await res.json();
239
- expect(json.error.category).toBe('internal');
240
- });
241
-
242
- test('thrown exceptions map to 500', async () => {
243
- const throwTrail = trail('throw', {
244
- blaze: () => {
245
- throw new Error('unexpected crash');
246
- },
247
- input: z.object({}),
248
- });
249
-
250
- const app = topo('testapp', { throwTrail });
251
- const hono = await trailhead(app, { serve: false });
252
-
253
- const res = await request(hono, 'POST', '/throw', {});
254
- expect(res.status).toBe(500);
255
-
256
- const json = await res.json();
257
- expect(json.error.message).toBe('unexpected crash');
258
- });
259
- });
260
-
261
- describe('gates', () => {
262
- test('gates compose around trail execution', async () => {
263
- const calls: string[] = [];
264
-
265
- const testGate: Gate = {
266
- name: 'test-gate',
267
- wrap(_trail, impl) {
268
- return async (input, ctx) => {
269
- calls.push('before');
270
- const result = await impl(input, ctx);
271
- calls.push('after');
272
- return result;
273
- };
274
- },
275
- };
276
-
277
- const app = topo('testapp', { echoTrail });
278
- const hono = await trailhead(app, { gates: [testGate], serve: false });
279
-
280
- const res = await request(hono, 'GET', '/echo?message=hi');
281
- expect(res.status).toBe(200);
282
- expect(calls).toEqual(['before', 'after']);
283
- });
284
- });
285
-
286
- describe('malformed JSON body', () => {
287
- test('returns 400 for invalid JSON in POST body', async () => {
288
- const app = topo('testapp', { createTrail });
289
- const hono = await trailhead(app, { serve: false });
290
-
291
- const res = await requestRaw(hono, 'POST', '/item/create', '{invalid');
292
- expect(res.status).toBe(400);
293
-
294
- const json = await res.json();
295
- expect(json.error.message).toBe('Invalid JSON in request body');
296
- expect(json.error.code).toBe('ValidationError');
297
- expect(json.error.category).toBe('validation');
298
- });
299
-
300
- test('returns 400 for invalid JSON in DELETE body', async () => {
301
- const app = topo('testapp', { deleteTrail });
302
- const hono = await trailhead(app, { serve: false });
303
-
304
- const res = await requestRaw(hono, 'DELETE', '/item/delete', 'not-json');
305
- expect(res.status).toBe(400);
306
-
307
- const json = await res.json();
308
- expect(json.error.message).toBe('Invalid JSON in request body');
309
- });
310
- });
311
-
312
- describe('query param parsing', () => {
313
- test('numeric-looking string is preserved as string', async () => {
314
- const stringIdTrail = trail('lookup', {
315
- blaze: (input) => Result.ok({ id: input.id }),
316
- input: z.object({ id: z.string() }),
317
- intent: 'read',
318
- });
319
-
320
- const app = topo('testapp', { stringIdTrail });
321
- const hono = await trailhead(app, { serve: false });
322
-
323
- const res = await request(hono, 'GET', '/lookup?id=00123');
324
- expect(res.status).toBe(200);
325
-
326
- const json = await res.json();
327
- expect(json.data.id).toBe('00123');
328
- });
329
-
330
- test('repeated keys become arrays', async () => {
331
- const tagsTrail = trail('tags', {
332
- blaze: (input) => Result.ok({ tags: input.tags }),
333
- input: z.object({ tags: z.array(z.string()) }),
334
- intent: 'read',
335
- });
336
-
337
- const app = topo('testapp', { tagsTrail });
338
- const hono = await trailhead(app, { serve: false });
339
-
340
- const res = await request(hono, 'GET', '/tags?tags=a&tags=b');
341
- expect(res.status).toBe(200);
342
-
343
- const json = await res.json();
344
- expect(json.data.tags).toEqual(['a', 'b']);
345
- });
346
-
347
- test('single value is wrapped in array when schema expects z.array()', async () => {
348
- const tagsTrail = trail('tags.single', {
349
- blaze: (input) => Result.ok({ tags: input.tags }),
350
- input: z.object({ tags: z.array(z.string()) }),
351
- intent: 'read',
352
- });
353
-
354
- const app = topo('testapp', { tagsTrail });
355
- const hono = await trailhead(app, { serve: false });
356
-
357
- const res = await request(hono, 'GET', '/tags/single?tags=foo');
358
- expect(res.status).toBe(200);
359
-
360
- const json = await res.json();
361
- expect(json.data.tags).toEqual(['foo']);
362
- });
363
-
364
- test('single value stays scalar when schema expects a string', async () => {
365
- const nameTrail = trail('name.check', {
366
- blaze: (input) => Result.ok({ name: input.name }),
367
- input: z.object({ name: z.string() }),
368
- intent: 'read',
369
- });
370
-
371
- const app = topo('testapp', { nameTrail });
372
- const hono = await trailhead(app, { serve: false });
373
-
374
- const res = await request(hono, 'GET', '/name/check?name=bar');
375
- expect(res.status).toBe(200);
376
-
377
- const json = await res.json();
378
- expect(json.data.name).toBe('bar');
379
- });
380
-
381
- test('optional array field with single value is wrapped in array', async () => {
382
- const optArrayTrail = trail('opt.array', {
383
- blaze: (input) => Result.ok({ ids: input.ids }),
384
- input: z.object({ ids: z.array(z.string()).optional() }),
385
- intent: 'read',
386
- });
387
-
388
- const app = topo('testapp', { optArrayTrail });
389
- const hono = await trailhead(app, { serve: false });
390
-
391
- const res = await request(hono, 'GET', '/opt/array?ids=one');
392
- expect(res.status).toBe(200);
393
-
394
- const json = await res.json();
395
- expect(json.data.ids).toEqual(['one']);
396
- });
397
- });
398
-
399
- describe('AbortSignal', () => {
400
- test('passes request AbortSignal to trail context', async () => {
401
- let capturedSignal: AbortSignal | undefined;
402
-
403
- const signalTrail = trail('signal.check', {
404
- blaze: (_input, ctx) => {
405
- capturedSignal = ctx.abortSignal;
406
- return Result.ok({ ok: true });
407
- },
408
- input: z.object({}),
409
- intent: 'read',
410
- output: z.object({ ok: z.boolean() }),
411
- });
412
-
413
- const app = topo('testapp', { signalTrail });
414
- const hono = await trailhead(app, { serve: false });
415
-
416
- const res = await request(hono, 'GET', '/signal/check');
417
- expect(res.status).toBe(200);
418
- expect(capturedSignal).toBeInstanceOf(AbortSignal);
419
- });
420
-
421
- test('signal is aborted when request is cancelled', async () => {
422
- let capturedSignal: AbortSignal | undefined;
423
-
424
- const signalTrail = trail('signal.aborted', {
425
- blaze: (_input, ctx) => {
426
- capturedSignal = ctx.abortSignal;
427
- return Result.ok({ ok: true });
428
- },
429
- input: z.object({}),
430
- intent: 'read',
431
- output: z.object({ ok: z.boolean() }),
432
- });
433
-
434
- const app = topo('testapp', { signalTrail });
435
- const hono = await trailhead(app, { serve: false });
436
-
437
- const controller = new AbortController();
438
- controller.abort();
439
-
440
- // Pass the pre-aborted signal directly in the Request.
441
- // Hono's fetch propagates Request.signal into c.req.raw.signal.
442
- const res = await hono.fetch(
443
- new Request('http://localhost/signal/aborted', {
444
- method: 'GET',
445
- signal: controller.signal,
446
- })
447
- );
448
- expect(res.status).toBe(200);
449
- expect(capturedSignal?.aborted).toBe(true);
450
- });
451
- });
452
-
453
- describe('context', () => {
454
- test('X-Request-ID header is used for requestId', async () => {
455
- let capturedRequestId: string | undefined;
456
-
457
- const ctxTrail = trail('ctx.check', {
458
- blaze: (_input, ctx) => {
459
- capturedRequestId = ctx.requestId;
460
- return Result.ok({ ok: true });
461
- },
462
- input: z.object({}),
463
- intent: 'read',
464
- });
465
-
466
- const app = topo('testapp', { ctxTrail });
467
- const hono = await trailhead(app, { serve: false });
468
-
469
- const res = await request(hono, 'GET', '/ctx/check', undefined, {
470
- 'X-Request-ID': 'custom-req-123',
471
- });
472
-
473
- expect(res.status).toBe(200);
474
- expect(capturedRequestId).toBe('custom-req-123');
475
- });
476
-
477
- test('custom createContext is used when provided', async () => {
478
- let contextUsed = false;
479
-
480
- const ctxTrail = trail('ctx.custom', {
481
- blaze: (_input, ctx) => {
482
- contextUsed = ctx.extensions?.['custom'] === true;
483
- return Result.ok({ ok: true });
484
- },
485
- input: z.object({}),
486
- intent: 'read',
487
- });
488
-
489
- const app = topo('testapp', { ctxTrail });
490
- const hono = await trailhead(app, {
491
- createContext: () => ({
492
- abortSignal: new AbortController().signal,
493
- extensions: { custom: true },
494
- requestId: 'test-id',
495
- }),
496
- serve: false,
497
- });
498
-
499
- const res = await request(hono, 'GET', '/ctx/custom');
500
- expect(res.status).toBe(200);
501
- expect(contextUsed).toBe(true);
502
- });
503
-
504
- test('provision overrides reach the trail through trailhead()', async () => {
505
- const provisionTrail = trail('provision.check', {
506
- blaze: (_input, ctx) =>
507
- Result.ok({ source: dbProvision.from(ctx).source as string }),
508
- input: z.object({}),
509
- intent: 'read',
510
- output: z.object({ source: z.string() }),
511
- provisions: [dbProvision],
512
- });
513
-
514
- const app = topo('testapp', { dbProvision, provisionTrail });
515
- const hono = await trailhead(app, {
516
- provisions: { 'db.main': { source: 'override' } },
517
- serve: false,
518
- });
519
-
520
- const res = await request(hono, 'GET', '/provision/check');
521
- expect(res.status).toBe(200);
522
-
523
- const json = await res.json();
524
- expect(json.data.source).toBe('override');
525
- });
526
- });
527
- });
package/src/hono/index.ts DELETED
@@ -1 +0,0 @@
1
- export { trailhead, type TrailheadHttpOptions } from './trailhead.js';