ts-procedures 5.3.0 → 5.4.0

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.
Files changed (38) hide show
  1. package/README.md +90 -0
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +15 -0
  3. package/agent_config/claude-code/skills/guide/anti-patterns.md +106 -0
  4. package/agent_config/claude-code/skills/guide/api-reference.md +150 -4
  5. package/agent_config/claude-code/skills/guide/patterns.md +155 -0
  6. package/agent_config/claude-code/skills/review/checklist.md +22 -0
  7. package/agent_config/claude-code/skills/scaffold/SKILL.md +3 -1
  8. package/agent_config/claude-code/skills/scaffold/templates/hono-api.md +169 -0
  9. package/agent_config/copilot/copilot-instructions.md +35 -0
  10. package/agent_config/cursor/cursorrules +35 -0
  11. package/build/implementations/http/hono-api/index.d.ts +102 -0
  12. package/build/implementations/http/hono-api/index.js +339 -0
  13. package/build/implementations/http/hono-api/index.js.map +1 -0
  14. package/build/implementations/http/hono-api/index.test.d.ts +1 -0
  15. package/build/implementations/http/hono-api/index.test.js +983 -0
  16. package/build/implementations/http/hono-api/index.test.js.map +1 -0
  17. package/build/implementations/http/hono-api/types.d.ts +13 -0
  18. package/build/implementations/http/hono-api/types.js +2 -0
  19. package/build/implementations/http/hono-api/types.js.map +1 -0
  20. package/build/implementations/types.d.ts +44 -0
  21. package/build/index.d.ts +28 -6
  22. package/build/index.js +28 -0
  23. package/build/index.js.map +1 -1
  24. package/build/schema/compute-schema.d.ts +5 -0
  25. package/build/schema/compute-schema.js +8 -1
  26. package/build/schema/compute-schema.js.map +1 -1
  27. package/build/schema/parser.d.ts +6 -5
  28. package/build/schema/parser.js +54 -0
  29. package/build/schema/parser.js.map +1 -1
  30. package/package.json +8 -3
  31. package/src/implementations/http/README.md +45 -2
  32. package/src/implementations/http/hono-api/index.test.ts +1328 -0
  33. package/src/implementations/http/hono-api/index.ts +461 -0
  34. package/src/implementations/http/hono-api/types.ts +16 -0
  35. package/src/implementations/types.ts +52 -0
  36. package/src/index.ts +87 -10
  37. package/src/schema/compute-schema.ts +23 -2
  38. package/src/schema/parser.ts +70 -3
@@ -0,0 +1,983 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { Hono } from 'hono';
3
+ import { Type } from 'typebox';
4
+ import { Procedures } from '../../../index.js';
5
+ import { HonoAPIAppBuilder } from './index.js';
6
+ /**
7
+ * HonoAPIAppBuilder Test Suite
8
+ *
9
+ * Tests the REST-style Hono integration for ts-procedures.
10
+ * Uses schema.input for per-channel type-safe input validation.
11
+ */
12
+ describe('HonoAPIAppBuilder', () => {
13
+ // --------------------------------------------------------------------------
14
+ // Constructor Tests
15
+ // --------------------------------------------------------------------------
16
+ describe('constructor', () => {
17
+ test('creates default Hono app', async () => {
18
+ const builder = new HonoAPIAppBuilder();
19
+ const API = Procedures();
20
+ API.Create('GetUser', {
21
+ path: '/users/:id',
22
+ method: 'get',
23
+ schema: {
24
+ input: {
25
+ pathParams: Type.Object({ id: Type.String() }),
26
+ },
27
+ returnType: Type.Object({ id: Type.String() }),
28
+ },
29
+ }, async (ctx, { pathParams }) => ({ id: pathParams.id }));
30
+ builder.register(API, () => ({ userId: '123' }));
31
+ const app = await builder.build();
32
+ const res = await app.request('/users/abc', { method: 'GET' });
33
+ expect(res.status).toBe(200);
34
+ const body = await res.json();
35
+ expect(body).toEqual({ id: 'abc' });
36
+ });
37
+ test('uses provided Hono app', async () => {
38
+ const customApp = new Hono();
39
+ customApp.get('/custom', (c) => c.json({ custom: true }));
40
+ const builder = new HonoAPIAppBuilder({ app: customApp });
41
+ const API = Procedures();
42
+ API.Create('Health', { path: '/health', method: 'get' }, async () => ({ ok: true }));
43
+ builder.register(API, () => ({}));
44
+ const app = await builder.build();
45
+ // Custom route still works
46
+ const customRes = await app.request('/custom');
47
+ expect(customRes.status).toBe(200);
48
+ expect(await customRes.json()).toEqual({ custom: true });
49
+ // API route works
50
+ const apiRes = await app.request('/health');
51
+ expect(apiRes.status).toBe(200);
52
+ });
53
+ test('handles empty config', () => {
54
+ const builder = new HonoAPIAppBuilder({});
55
+ expect(builder.app).toBeDefined();
56
+ expect(builder.docs).toEqual([]);
57
+ });
58
+ });
59
+ // --------------------------------------------------------------------------
60
+ // HTTP Methods
61
+ // --------------------------------------------------------------------------
62
+ describe('HTTP methods', () => {
63
+ test('GET endpoint', async () => {
64
+ const builder = new HonoAPIAppBuilder();
65
+ const API = Procedures();
66
+ API.Create('ListUsers', { path: '/users', method: 'get' }, async () => ({ users: [] }));
67
+ builder.register(API, () => ({}));
68
+ const app = await builder.build();
69
+ const res = await app.request('/users');
70
+ expect(res.status).toBe(200);
71
+ expect(await res.json()).toEqual({ users: [] });
72
+ });
73
+ test('POST endpoint returns 201 by default', async () => {
74
+ const builder = new HonoAPIAppBuilder();
75
+ const API = Procedures();
76
+ API.Create('CreateUser', {
77
+ path: '/users',
78
+ method: 'post',
79
+ schema: {
80
+ input: {
81
+ body: Type.Object({ name: Type.String() }),
82
+ },
83
+ },
84
+ }, async (ctx, { body }) => ({ id: '123', name: body.name }));
85
+ builder.register(API, () => ({}));
86
+ const app = await builder.build();
87
+ const res = await app.request('/users', {
88
+ method: 'POST',
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: JSON.stringify({ name: 'John' }),
91
+ });
92
+ expect(res.status).toBe(201);
93
+ expect(await res.json()).toEqual({ id: '123', name: 'John' });
94
+ });
95
+ test('PUT endpoint returns 200 by default', async () => {
96
+ const builder = new HonoAPIAppBuilder();
97
+ const API = Procedures();
98
+ API.Create('UpdateUser', {
99
+ path: '/users/:id',
100
+ method: 'put',
101
+ schema: {
102
+ input: {
103
+ pathParams: Type.Object({ id: Type.String() }),
104
+ body: Type.Object({ name: Type.String() }),
105
+ },
106
+ },
107
+ }, async (ctx, { pathParams, body }) => ({ id: pathParams.id, name: body.name }));
108
+ builder.register(API, () => ({}));
109
+ const app = await builder.build();
110
+ const res = await app.request('/users/abc', {
111
+ method: 'PUT',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ name: 'Jane' }),
114
+ });
115
+ expect(res.status).toBe(200);
116
+ expect(await res.json()).toEqual({ id: 'abc', name: 'Jane' });
117
+ });
118
+ test('PATCH endpoint', async () => {
119
+ const builder = new HonoAPIAppBuilder();
120
+ const API = Procedures();
121
+ API.Create('PatchUser', {
122
+ path: '/users/:id',
123
+ method: 'patch',
124
+ schema: {
125
+ input: {
126
+ pathParams: Type.Object({ id: Type.String() }),
127
+ body: Type.Object({ name: Type.Optional(Type.String()) }),
128
+ },
129
+ },
130
+ }, async (ctx, { pathParams, body }) => ({ id: pathParams.id, ...body }));
131
+ builder.register(API, () => ({}));
132
+ const app = await builder.build();
133
+ const res = await app.request('/users/abc', {
134
+ method: 'PATCH',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({ name: 'Updated' }),
137
+ });
138
+ expect(res.status).toBe(200);
139
+ expect(await res.json()).toEqual({ id: 'abc', name: 'Updated' });
140
+ });
141
+ test('DELETE endpoint returns 204 by default', async () => {
142
+ const builder = new HonoAPIAppBuilder();
143
+ const API = Procedures();
144
+ API.Create('DeleteUser', {
145
+ path: '/users/:id',
146
+ method: 'delete',
147
+ schema: {
148
+ input: {
149
+ pathParams: Type.Object({ id: Type.String() }),
150
+ },
151
+ },
152
+ }, async (ctx, { pathParams }) => undefined);
153
+ builder.register(API, () => ({}));
154
+ const app = await builder.build();
155
+ const res = await app.request('/users/abc', { method: 'DELETE' });
156
+ expect(res.status).toBe(204);
157
+ // 204 responses have no body
158
+ expect(await res.text()).toBe('');
159
+ });
160
+ test('custom successStatus overrides default', async () => {
161
+ const builder = new HonoAPIAppBuilder();
162
+ const API = Procedures();
163
+ API.Create('CreateUser', {
164
+ path: '/users',
165
+ method: 'post',
166
+ successStatus: 200, // override default 201
167
+ }, async () => ({ id: '123' }));
168
+ builder.register(API, () => ({}));
169
+ const app = await builder.build();
170
+ const res = await app.request('/users', {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify({}),
174
+ });
175
+ expect(res.status).toBe(200);
176
+ });
177
+ test('wrong HTTP method returns 404', async () => {
178
+ const builder = new HonoAPIAppBuilder();
179
+ const API = Procedures();
180
+ API.Create('GetUser', { path: '/users', method: 'get' }, async () => ({ ok: true }));
181
+ builder.register(API, () => ({}));
182
+ const app = await builder.build();
183
+ // POST to a GET-only endpoint
184
+ const res = await app.request('/users', {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({}),
188
+ });
189
+ expect(res.status).toBe(404);
190
+ });
191
+ });
192
+ // --------------------------------------------------------------------------
193
+ // schema.input — Multi-Channel Input
194
+ // --------------------------------------------------------------------------
195
+ describe('schema.input — multi-channel input', () => {
196
+ test('pathParams extraction and validation', async () => {
197
+ const builder = new HonoAPIAppBuilder();
198
+ const API = Procedures();
199
+ API.Create('GetUser', {
200
+ path: '/users/:id',
201
+ method: 'get',
202
+ schema: {
203
+ input: {
204
+ pathParams: Type.Object({ id: Type.String() }),
205
+ },
206
+ },
207
+ }, async (ctx, { pathParams }) => ({ id: pathParams.id }));
208
+ builder.register(API, () => ({}));
209
+ const app = await builder.build();
210
+ const res = await app.request('/users/user-123');
211
+ expect(res.status).toBe(200);
212
+ expect(await res.json()).toEqual({ id: 'user-123' });
213
+ });
214
+ test('query params extraction and validation', async () => {
215
+ const builder = new HonoAPIAppBuilder();
216
+ const API = Procedures();
217
+ API.Create('SearchUsers', {
218
+ path: '/users',
219
+ method: 'get',
220
+ schema: {
221
+ input: {
222
+ query: Type.Object({
223
+ page: Type.Number(),
224
+ limit: Type.Number(),
225
+ }),
226
+ },
227
+ },
228
+ }, async (ctx, { query }) => ({ page: query.page, limit: query.limit }));
229
+ builder.register(API, () => ({}));
230
+ const app = await builder.build();
231
+ // AJV coerceTypes converts string "2" to number 2
232
+ const res = await app.request('/users?page=2&limit=10');
233
+ expect(res.status).toBe(200);
234
+ expect(await res.json()).toEqual({ page: 2, limit: 10 });
235
+ });
236
+ test('body extraction and validation', async () => {
237
+ const builder = new HonoAPIAppBuilder();
238
+ const API = Procedures();
239
+ API.Create('CreateUser', {
240
+ path: '/users',
241
+ method: 'post',
242
+ schema: {
243
+ input: {
244
+ body: Type.Object({
245
+ name: Type.String(),
246
+ email: Type.String(),
247
+ }),
248
+ },
249
+ },
250
+ }, async (ctx, { body }) => ({ id: '1', name: body.name, email: body.email }));
251
+ builder.register(API, () => ({}));
252
+ const app = await builder.build();
253
+ const res = await app.request('/users', {
254
+ method: 'POST',
255
+ headers: { 'Content-Type': 'application/json' },
256
+ body: JSON.stringify({ name: 'John', email: 'john@example.com' }),
257
+ });
258
+ expect(res.status).toBe(201);
259
+ expect(await res.json()).toEqual({ id: '1', name: 'John', email: 'john@example.com' });
260
+ });
261
+ test('headers extraction and validation', async () => {
262
+ const builder = new HonoAPIAppBuilder();
263
+ const API = Procedures();
264
+ API.Create('WebhookReceiver', {
265
+ path: '/webhooks',
266
+ method: 'post',
267
+ schema: {
268
+ input: {
269
+ headers: Type.Object({ 'x-webhook-secret': Type.String() }),
270
+ body: Type.Object({ event: Type.String() }),
271
+ },
272
+ },
273
+ }, async (ctx, { headers, body }) => ({
274
+ received: body.event,
275
+ secret: headers['x-webhook-secret'],
276
+ }));
277
+ builder.register(API, () => ({}));
278
+ const app = await builder.build();
279
+ const res = await app.request('/webhooks', {
280
+ method: 'POST',
281
+ headers: {
282
+ 'Content-Type': 'application/json',
283
+ 'X-Webhook-Secret': 'my-secret',
284
+ },
285
+ body: JSON.stringify({ event: 'user.created' }),
286
+ });
287
+ expect(res.status).toBe(201);
288
+ const body = await res.json();
289
+ expect(body.received).toBe('user.created');
290
+ expect(body.secret).toBe('my-secret');
291
+ });
292
+ test('combined: pathParams + query + body', async () => {
293
+ const builder = new HonoAPIAppBuilder();
294
+ const API = Procedures();
295
+ API.Create('UpdateUserField', {
296
+ path: '/users/:id',
297
+ method: 'put',
298
+ schema: {
299
+ input: {
300
+ pathParams: Type.Object({ id: Type.String() }),
301
+ query: Type.Object({ notify: Type.Optional(Type.Boolean()) }),
302
+ body: Type.Object({ field: Type.String(), value: Type.String() }),
303
+ },
304
+ },
305
+ }, async (ctx, { pathParams, query, body }) => ({
306
+ userId: pathParams.id,
307
+ updated: { [body.field]: body.value },
308
+ notified: query.notify ?? false,
309
+ }));
310
+ builder.register(API, () => ({}));
311
+ const app = await builder.build();
312
+ const res = await app.request('/users/u-42?notify=true', {
313
+ method: 'PUT',
314
+ headers: { 'Content-Type': 'application/json' },
315
+ body: JSON.stringify({ field: 'name', value: 'Jane' }),
316
+ });
317
+ expect(res.status).toBe(200);
318
+ expect(await res.json()).toEqual({
319
+ userId: 'u-42',
320
+ updated: { name: 'Jane' },
321
+ notified: true,
322
+ });
323
+ });
324
+ test('coerceTypes converts query string values to schema types', async () => {
325
+ const builder = new HonoAPIAppBuilder();
326
+ const API = Procedures();
327
+ API.Create('Search', {
328
+ path: '/items',
329
+ method: 'get',
330
+ schema: {
331
+ input: {
332
+ query: Type.Object({
333
+ page: Type.Number(),
334
+ active: Type.Boolean(),
335
+ }),
336
+ },
337
+ },
338
+ }, async (ctx, { query }) => ({
339
+ page: query.page,
340
+ active: query.active,
341
+ pageType: typeof query.page,
342
+ activeType: typeof query.active,
343
+ }));
344
+ builder.register(API, () => ({}));
345
+ const app = await builder.build();
346
+ const res = await app.request('/items?page=3&active=true');
347
+ expect(res.status).toBe(200);
348
+ const body = await res.json();
349
+ expect(body.page).toBe(3);
350
+ expect(body.active).toBe(true);
351
+ expect(body.pageType).toBe('number');
352
+ expect(body.activeType).toBe('boolean');
353
+ });
354
+ test('removeAdditional strips extra fields from body', async () => {
355
+ const builder = new HonoAPIAppBuilder();
356
+ const API = Procedures();
357
+ API.Create('CreateItem', {
358
+ path: '/items',
359
+ method: 'post',
360
+ schema: {
361
+ input: {
362
+ body: Type.Object({ name: Type.String() }, { additionalProperties: false }),
363
+ },
364
+ },
365
+ }, async (ctx, { body }) => body);
366
+ builder.register(API, () => ({}));
367
+ const app = await builder.build();
368
+ const res = await app.request('/items', {
369
+ method: 'POST',
370
+ headers: { 'Content-Type': 'application/json' },
371
+ body: JSON.stringify({ name: 'Widget', extraField: 'should-be-stripped', anotherOne: 42 }),
372
+ });
373
+ expect(res.status).toBe(201);
374
+ const body = await res.json();
375
+ // AJV removeAdditional strips properties not in schema when additionalProperties: false
376
+ expect(body).toEqual({ name: 'Widget' });
377
+ expect(body).not.toHaveProperty('extraField');
378
+ expect(body).not.toHaveProperty('anotherOne');
379
+ });
380
+ test('removeAdditional strips non-declared headers', async () => {
381
+ const builder = new HonoAPIAppBuilder();
382
+ const API = Procedures();
383
+ API.Create('Webhook', {
384
+ path: '/hook',
385
+ method: 'post',
386
+ schema: {
387
+ input: {
388
+ headers: Type.Object({ 'x-signature': Type.String() }, { additionalProperties: false }),
389
+ body: Type.Object({ event: Type.String() }),
390
+ },
391
+ },
392
+ }, async (ctx, { headers }) => ({ keys: Object.keys(headers) }));
393
+ builder.register(API, () => ({}));
394
+ const app = await builder.build();
395
+ const res = await app.request('/hook', {
396
+ method: 'POST',
397
+ headers: {
398
+ 'Content-Type': 'application/json',
399
+ 'X-Signature': 'abc123',
400
+ 'X-Other-Header': 'should-be-stripped',
401
+ },
402
+ body: JSON.stringify({ event: 'test' }),
403
+ });
404
+ expect(res.status).toBe(201);
405
+ const body = await res.json();
406
+ // Only x-signature should remain after removeAdditional
407
+ expect(body.keys).toEqual(['x-signature']);
408
+ });
409
+ test('validation error includes channel name', async () => {
410
+ const builder = new HonoAPIAppBuilder();
411
+ const API = Procedures();
412
+ API.Create('CreateItem', {
413
+ path: '/items',
414
+ method: 'post',
415
+ schema: {
416
+ input: {
417
+ body: Type.Object({
418
+ name: Type.String(),
419
+ price: Type.Number(),
420
+ }),
421
+ },
422
+ },
423
+ }, async (ctx, { body }) => body);
424
+ builder.register(API, () => ({}));
425
+ const app = await builder.build();
426
+ const res = await app.request('/items', {
427
+ method: 'POST',
428
+ headers: { 'Content-Type': 'application/json' },
429
+ body: JSON.stringify({ name: 'Widget' }), // missing required 'price'
430
+ });
431
+ expect(res.status).toBe(500);
432
+ const body = await res.json();
433
+ expect(body.error).toContain('input.body');
434
+ });
435
+ });
436
+ // --------------------------------------------------------------------------
437
+ // schema.params — Flat Input (backwards-compatible path)
438
+ // --------------------------------------------------------------------------
439
+ describe('schema.params — flat input', () => {
440
+ test('POST with schema.params reads from body', async () => {
441
+ const builder = new HonoAPIAppBuilder();
442
+ const API = Procedures();
443
+ API.Create('CreateItem', {
444
+ path: '/items',
445
+ method: 'post',
446
+ schema: { params: Type.Object({ name: Type.String() }) },
447
+ }, async (ctx, params) => ({ name: params.name }));
448
+ builder.register(API, () => ({}));
449
+ const app = await builder.build();
450
+ const res = await app.request('/items', {
451
+ method: 'POST',
452
+ headers: { 'Content-Type': 'application/json' },
453
+ body: JSON.stringify({ name: 'Widget' }),
454
+ });
455
+ expect(res.status).toBe(201);
456
+ expect(await res.json()).toEqual({ name: 'Widget' });
457
+ });
458
+ test('GET with schema.params reads from query', async () => {
459
+ const builder = new HonoAPIAppBuilder();
460
+ const API = Procedures();
461
+ API.Create('SearchItems', {
462
+ path: '/items',
463
+ method: 'get',
464
+ schema: {
465
+ params: Type.Object({ q: Type.String() }),
466
+ },
467
+ }, async (ctx, params) => ({ query: params.q }));
468
+ builder.register(API, () => ({}));
469
+ const app = await builder.build();
470
+ const res = await app.request('/items?q=widget');
471
+ expect(res.status).toBe(200);
472
+ expect(await res.json()).toEqual({ query: 'widget' });
473
+ });
474
+ });
475
+ // --------------------------------------------------------------------------
476
+ // pathPrefix Option
477
+ // --------------------------------------------------------------------------
478
+ describe('pathPrefix', () => {
479
+ test('prepends prefix to all routes', async () => {
480
+ const builder = new HonoAPIAppBuilder({ pathPrefix: '/api/v1' });
481
+ const API = Procedures();
482
+ API.Create('Health', { path: '/health', method: 'get' }, async () => ({ ok: true }));
483
+ builder.register(API, () => ({}));
484
+ const app = await builder.build();
485
+ const res = await app.request('/api/v1/health');
486
+ expect(res.status).toBe(200);
487
+ // Without prefix → 404
488
+ const res2 = await app.request('/health');
489
+ expect(res2.status).toBe(404);
490
+ });
491
+ test('pathPrefix normalizes missing leading slash', async () => {
492
+ const builder = new HonoAPIAppBuilder({ pathPrefix: 'api' });
493
+ const API = Procedures();
494
+ API.Create('Health', { path: '/health', method: 'get' }, async () => ({ ok: true }));
495
+ builder.register(API, () => ({}));
496
+ const app = await builder.build();
497
+ const res = await app.request('/api/health');
498
+ expect(res.status).toBe(200);
499
+ });
500
+ test('pathPrefix appears in docs fullPath', async () => {
501
+ const builder = new HonoAPIAppBuilder({ pathPrefix: '/api' });
502
+ const API = Procedures();
503
+ API.Create('GetUser', { path: '/users/:id', method: 'get' }, async () => ({}));
504
+ builder.register(API, () => ({}));
505
+ await builder.build();
506
+ expect(builder.docs[0].fullPath).toBe('/api/users/:id');
507
+ expect(builder.docs[0].path).toBe('/users/:id');
508
+ });
509
+ });
510
+ // --------------------------------------------------------------------------
511
+ // Lifecycle Hooks
512
+ // --------------------------------------------------------------------------
513
+ describe('lifecycle hooks', () => {
514
+ test('onRequestStart and onRequestEnd are called', async () => {
515
+ const order = [];
516
+ const builder = new HonoAPIAppBuilder({
517
+ onRequestStart: () => order.push('start'),
518
+ onRequestEnd: () => order.push('end'),
519
+ });
520
+ const API = Procedures();
521
+ API.Create('Test', { path: '/test', method: 'get' }, async () => {
522
+ order.push('handler');
523
+ return { ok: true };
524
+ });
525
+ builder.register(API, () => ({}));
526
+ const app = await builder.build();
527
+ await app.request('/test');
528
+ expect(order).toEqual(['start', 'handler', 'end']);
529
+ });
530
+ test('onSuccess is called on successful execution', async () => {
531
+ const onSuccess = vi.fn();
532
+ const builder = new HonoAPIAppBuilder({ onSuccess });
533
+ const API = Procedures();
534
+ API.Create('Test', { path: '/test', method: 'get' }, async () => ({ ok: true }));
535
+ builder.register(API, () => ({}));
536
+ const app = await builder.build();
537
+ await app.request('/test');
538
+ expect(onSuccess).toHaveBeenCalledTimes(1);
539
+ expect(onSuccess.mock.calls[0][0]).toHaveProperty('name', 'Test');
540
+ });
541
+ test('onSuccess is NOT called when handler throws', async () => {
542
+ const onSuccess = vi.fn();
543
+ const builder = new HonoAPIAppBuilder({ onSuccess });
544
+ const API = Procedures();
545
+ API.Create('Test', { path: '/test', method: 'get' }, async () => {
546
+ throw new Error('oops');
547
+ });
548
+ builder.register(API, () => ({}));
549
+ const app = await builder.build();
550
+ await app.request('/test');
551
+ expect(onSuccess).not.toHaveBeenCalled();
552
+ });
553
+ });
554
+ // --------------------------------------------------------------------------
555
+ // Error Handling
556
+ // --------------------------------------------------------------------------
557
+ describe('error handling', () => {
558
+ test('custom error handler receives procedure, context, and error', async () => {
559
+ const errorHandler = vi.fn((procedure, c, error) => {
560
+ return c.json({ customError: error.message }, 400);
561
+ });
562
+ const builder = new HonoAPIAppBuilder({ onError: errorHandler });
563
+ const API = Procedures();
564
+ API.Create('Test', { path: '/test', method: 'get' }, async () => {
565
+ throw new Error('Test error');
566
+ });
567
+ builder.register(API, () => ({}));
568
+ const app = await builder.build();
569
+ const res = await app.request('/test');
570
+ expect(errorHandler).toHaveBeenCalledTimes(1);
571
+ expect(res.status).toBe(400);
572
+ const body = await res.json();
573
+ expect(body.customError).toContain('Test error');
574
+ });
575
+ test('default error handling returns 500 with message', async () => {
576
+ const builder = new HonoAPIAppBuilder();
577
+ const API = Procedures();
578
+ API.Create('Test', { path: '/test', method: 'get' }, async () => {
579
+ throw new Error('Something broke');
580
+ });
581
+ builder.register(API, () => ({}));
582
+ const app = await builder.build();
583
+ const res = await app.request('/test');
584
+ expect(res.status).toBe(500);
585
+ const body = await res.json();
586
+ expect(body.error).toContain('Something broke');
587
+ });
588
+ });
589
+ // --------------------------------------------------------------------------
590
+ // register() Method
591
+ // --------------------------------------------------------------------------
592
+ describe('register() method', () => {
593
+ test('returns this for method chaining', () => {
594
+ const builder = new HonoAPIAppBuilder();
595
+ const API1 = Procedures();
596
+ const API2 = Procedures();
597
+ const result = builder.register(API1, () => ({})).register(API2, () => ({}));
598
+ expect(result).toBe(builder);
599
+ });
600
+ test('supports multiple factories with different contexts', async () => {
601
+ const builder = new HonoAPIAppBuilder();
602
+ const PublicAPI = Procedures();
603
+ const AuthAPI = Procedures();
604
+ PublicAPI.Create('GetHealth', { path: '/health', method: 'get' }, async (ctx) => ({ public: ctx.public }));
605
+ AuthAPI.Create('GetProfile', { path: '/profile', method: 'get' }, async (ctx) => ({ userId: ctx.userId }));
606
+ builder
607
+ .register(PublicAPI, () => ({ public: true }))
608
+ .register(AuthAPI, (c) => ({ userId: c.req.header('x-user-id') || 'anon' }));
609
+ const app = await builder.build();
610
+ const healthRes = await app.request('/health');
611
+ expect(await healthRes.json()).toEqual({ public: true });
612
+ const profileRes = await app.request('/profile', {
613
+ headers: { 'X-User-Id': 'user-42' },
614
+ });
615
+ expect(await profileRes.json()).toEqual({ userId: 'user-42' });
616
+ });
617
+ test('factoryContext can be a static object', async () => {
618
+ const builder = new HonoAPIAppBuilder();
619
+ const API = Procedures();
620
+ API.Create('GetId', { path: '/id', method: 'get' }, async (ctx) => ({ id: ctx.requestId }));
621
+ builder.register(API, { requestId: 'static-123' });
622
+ const app = await builder.build();
623
+ const res = await app.request('/id');
624
+ expect(await res.json()).toEqual({ id: 'static-123' });
625
+ });
626
+ test('factoryContext can be async', async () => {
627
+ const builder = new HonoAPIAppBuilder();
628
+ const API = Procedures();
629
+ API.Create('GetId', { path: '/id', method: 'get' }, async (ctx) => ({ id: ctx.requestId }));
630
+ builder.register(API, async () => ({ requestId: 'async-456' }));
631
+ const app = await builder.build();
632
+ const res = await app.request('/id');
633
+ expect(await res.json()).toEqual({ id: 'async-456' });
634
+ });
635
+ });
636
+ // --------------------------------------------------------------------------
637
+ // Path Param Consistency Validation
638
+ // --------------------------------------------------------------------------
639
+ describe('path param consistency validation', () => {
640
+ test('throws when path has params but schema.input.pathParams is missing', async () => {
641
+ const builder = new HonoAPIAppBuilder();
642
+ const API = Procedures();
643
+ API.Create('GetUser', {
644
+ path: '/users/:id',
645
+ method: 'get',
646
+ schema: {
647
+ input: {
648
+ query: Type.Object({ include: Type.Optional(Type.String()) }),
649
+ },
650
+ },
651
+ }, async () => ({}));
652
+ builder.register(API, () => ({}));
653
+ await expect(builder.build()).rejects.toThrow(/pathParams is not defined/);
654
+ });
655
+ test('throws when schema.input.pathParams defined but path has no params', async () => {
656
+ const builder = new HonoAPIAppBuilder();
657
+ const API = Procedures();
658
+ API.Create('ListUsers', {
659
+ path: '/users',
660
+ method: 'get',
661
+ schema: {
662
+ input: {
663
+ pathParams: Type.Object({ id: Type.String() }),
664
+ },
665
+ },
666
+ }, async () => ({}));
667
+ builder.register(API, () => ({}));
668
+ await expect(builder.build()).rejects.toThrow(/has no path parameters/);
669
+ });
670
+ test('no error when path has params and pathParams schema matches', async () => {
671
+ const builder = new HonoAPIAppBuilder();
672
+ const API = Procedures();
673
+ API.Create('GetUser', {
674
+ path: '/users/:id',
675
+ method: 'get',
676
+ schema: {
677
+ input: {
678
+ pathParams: Type.Object({ id: Type.String() }),
679
+ },
680
+ },
681
+ }, async (ctx, { pathParams }) => ({ id: pathParams.id }));
682
+ builder.register(API, () => ({}));
683
+ await expect(builder.build()).resolves.toBeDefined();
684
+ });
685
+ test('throws when pathParams schema keys do not match path param names', async () => {
686
+ const builder = new HonoAPIAppBuilder();
687
+ const API = Procedures();
688
+ // Path has :id but schema declares userId
689
+ API.Create('GetUser', {
690
+ path: '/users/:id',
691
+ method: 'get',
692
+ schema: {
693
+ input: {
694
+ pathParams: Type.Object({ userId: Type.String() }),
695
+ },
696
+ },
697
+ }, async () => ({}));
698
+ builder.register(API, () => ({}));
699
+ await expect(builder.build()).rejects.toThrow(/Path param mismatch/);
700
+ });
701
+ test('throws when path has multiple params and schema is missing one', async () => {
702
+ const builder = new HonoAPIAppBuilder();
703
+ const API = Procedures();
704
+ API.Create('GetComment', {
705
+ path: '/users/:userId/posts/:postId/comments/:commentId',
706
+ method: 'get',
707
+ schema: {
708
+ input: {
709
+ // Missing commentId
710
+ pathParams: Type.Object({ userId: Type.String(), postId: Type.String() }),
711
+ },
712
+ },
713
+ }, async () => ({}));
714
+ builder.register(API, () => ({}));
715
+ await expect(builder.build()).rejects.toThrow(/commentId/);
716
+ });
717
+ test('no validation when schema.input is not used', async () => {
718
+ const builder = new HonoAPIAppBuilder();
719
+ const API = Procedures();
720
+ // Using schema.params (flat mode) with path params — no consistency check
721
+ API.Create('GetUser', {
722
+ path: '/users/:id',
723
+ method: 'get',
724
+ }, async () => ({ ok: true }));
725
+ builder.register(API, () => ({}));
726
+ await expect(builder.build()).resolves.toBeDefined();
727
+ });
728
+ });
729
+ // --------------------------------------------------------------------------
730
+ // Route Documentation
731
+ // --------------------------------------------------------------------------
732
+ describe('route documentation', () => {
733
+ test('generates docs with per-channel jsonSchema from schema.input', async () => {
734
+ const builder = new HonoAPIAppBuilder();
735
+ const API = Procedures();
736
+ API.Create('UpdateUser', {
737
+ path: '/users/:id',
738
+ method: 'put',
739
+ schema: {
740
+ input: {
741
+ pathParams: Type.Object({ id: Type.String() }),
742
+ body: Type.Object({ name: Type.String() }),
743
+ },
744
+ returnType: Type.Object({ ok: Type.Boolean() }),
745
+ },
746
+ }, async () => ({ ok: true }));
747
+ builder.register(API, () => ({}));
748
+ await builder.build();
749
+ const doc = builder.docs[0];
750
+ expect(doc.name).toBe('UpdateUser');
751
+ expect(doc.path).toBe('/users/:id');
752
+ expect(doc.method).toBe('put');
753
+ expect(doc.fullPath).toBe('/users/:id');
754
+ expect(doc.jsonSchema.pathParams).toBeDefined();
755
+ expect(doc.jsonSchema.body).toBeDefined();
756
+ expect(doc.jsonSchema.response).toBeDefined();
757
+ expect(doc.jsonSchema.query).toBeUndefined();
758
+ });
759
+ test('generates docs from schema.params (flat mode)', async () => {
760
+ const builder = new HonoAPIAppBuilder();
761
+ const API = Procedures();
762
+ API.Create('CreateItem', {
763
+ path: '/items',
764
+ method: 'post',
765
+ schema: {
766
+ params: Type.Object({ name: Type.String() }),
767
+ returnType: Type.Object({ id: Type.String() }),
768
+ },
769
+ }, async (ctx, params) => ({ id: '1' }));
770
+ builder.register(API, () => ({}));
771
+ await builder.build();
772
+ const doc = builder.docs[0];
773
+ // POST with schema.params → body in docs
774
+ expect(doc.jsonSchema.body).toBeDefined();
775
+ expect(doc.jsonSchema.response).toBeDefined();
776
+ });
777
+ test('extendProcedureDoc adds custom properties', async () => {
778
+ const builder = new HonoAPIAppBuilder();
779
+ const API = Procedures();
780
+ API.Create('GetUser', { path: '/users/:id', method: 'get' }, async () => ({}));
781
+ builder.register(API, () => ({}), ({ base, procedure }) => ({
782
+ tags: ['users'],
783
+ summary: `Get user by ID`,
784
+ operationId: procedure.name,
785
+ }));
786
+ await builder.build();
787
+ const doc = builder.docs[0];
788
+ expect(doc).toHaveProperty('tags', ['users']);
789
+ expect(doc).toHaveProperty('summary', 'Get user by ID');
790
+ expect(doc).toHaveProperty('operationId', 'GetUser');
791
+ });
792
+ test('base properties take precedence over extended properties', async () => {
793
+ const builder = new HonoAPIAppBuilder();
794
+ const API = Procedures();
795
+ API.Create('Test', { path: '/test', method: 'get' }, async () => ({}));
796
+ builder.register(API, () => ({}), () => ({
797
+ name: 'Overridden',
798
+ method: 'post',
799
+ customField: 'custom-value',
800
+ }));
801
+ await builder.build();
802
+ const doc = builder.docs[0];
803
+ expect(doc.name).toBe('Test');
804
+ expect(doc.method).toBe('get');
805
+ expect(doc).toHaveProperty('customField', 'custom-value');
806
+ });
807
+ });
808
+ // --------------------------------------------------------------------------
809
+ // Query Parser Override
810
+ // --------------------------------------------------------------------------
811
+ describe('query parser', () => {
812
+ test('custom queryParser is used', async () => {
813
+ const customParser = vi.fn((qs) => {
814
+ const params = new URLSearchParams(qs);
815
+ const result = {};
816
+ for (const [key, value] of params) {
817
+ result[key] = value.toUpperCase(); // custom behavior
818
+ }
819
+ return result;
820
+ });
821
+ const builder = new HonoAPIAppBuilder({ queryParser: customParser });
822
+ const API = Procedures();
823
+ API.Create('Search', {
824
+ path: '/search',
825
+ method: 'get',
826
+ schema: {
827
+ input: {
828
+ query: Type.Object({ q: Type.String() }),
829
+ },
830
+ },
831
+ }, async (ctx, { query }) => ({ query: query.q }));
832
+ builder.register(API, () => ({}));
833
+ const app = await builder.build();
834
+ const res = await app.request('/search?q=hello');
835
+ expect(res.status).toBe(200);
836
+ expect(await res.json()).toEqual({ query: 'HELLO' });
837
+ expect(customParser).toHaveBeenCalledWith('q=hello');
838
+ });
839
+ });
840
+ // --------------------------------------------------------------------------
841
+ // Core schema.input Mutual Exclusivity
842
+ // --------------------------------------------------------------------------
843
+ describe('core schema.input mutual exclusivity', () => {
844
+ test('throws when both schema.params and schema.input are defined', () => {
845
+ const API = Procedures();
846
+ expect(() => {
847
+ API.Create('Bad', {
848
+ path: '/bad',
849
+ method: 'get',
850
+ schema: {
851
+ params: Type.Object({ a: Type.String() }),
852
+ input: {
853
+ query: Type.Object({ b: Type.String() }),
854
+ },
855
+ },
856
+ }, async () => ({}));
857
+ }).toThrow(/mutually exclusive/);
858
+ });
859
+ });
860
+ // --------------------------------------------------------------------------
861
+ // Integration Test
862
+ // --------------------------------------------------------------------------
863
+ describe('integration', () => {
864
+ test('full REST API with multiple endpoints and contexts', async () => {
865
+ const PublicAPI = Procedures();
866
+ const AuthAPI = Procedures();
867
+ PublicAPI.Create('ListProducts', {
868
+ path: '/products',
869
+ method: 'get',
870
+ schema: {
871
+ input: {
872
+ query: Type.Object({
873
+ page: Type.Optional(Type.Number({ default: 1 })),
874
+ }),
875
+ },
876
+ returnType: Type.Object({ products: Type.Array(Type.String()), page: Type.Number() }),
877
+ },
878
+ }, async (ctx, { query }) => ({
879
+ products: ['Widget', 'Gadget'],
880
+ page: query.page ?? 1,
881
+ }));
882
+ AuthAPI.Create('GetProduct', {
883
+ path: '/products/:id',
884
+ method: 'get',
885
+ schema: {
886
+ input: {
887
+ pathParams: Type.Object({ id: Type.String() }),
888
+ },
889
+ returnType: Type.Object({ id: Type.String(), viewer: Type.String() }),
890
+ },
891
+ }, async (ctx, { pathParams }) => ({
892
+ id: pathParams.id,
893
+ viewer: ctx.userId,
894
+ }));
895
+ AuthAPI.Create('CreateProduct', {
896
+ path: '/products',
897
+ method: 'post',
898
+ schema: {
899
+ input: {
900
+ body: Type.Object({
901
+ name: Type.String(),
902
+ price: Type.Number(),
903
+ }),
904
+ },
905
+ returnType: Type.Object({ id: Type.String(), name: Type.String(), createdBy: Type.String() }),
906
+ },
907
+ }, async (ctx, { body }) => ({
908
+ id: 'prod-new',
909
+ name: body.name,
910
+ createdBy: ctx.userId,
911
+ }));
912
+ AuthAPI.Create('DeleteProduct', {
913
+ path: '/products/:id',
914
+ method: 'delete',
915
+ schema: {
916
+ input: {
917
+ pathParams: Type.Object({ id: Type.String() }),
918
+ },
919
+ },
920
+ }, async (ctx, { pathParams }) => undefined);
921
+ const events = [];
922
+ const builder = new HonoAPIAppBuilder({
923
+ pathPrefix: '/api',
924
+ onRequestStart: () => events.push('request-start'),
925
+ onRequestEnd: () => events.push('request-end'),
926
+ onSuccess: (proc) => events.push(`success:${proc.name}`),
927
+ });
928
+ builder
929
+ .register(PublicAPI, () => ({ source: 'public' }))
930
+ .register(AuthAPI, (c) => ({
931
+ source: 'auth',
932
+ userId: c.req.header('x-user-id') || 'anon',
933
+ }));
934
+ const app = await builder.build();
935
+ // GET /api/products?page=2
936
+ const listRes = await app.request('/api/products?page=2');
937
+ expect(listRes.status).toBe(200);
938
+ expect(await listRes.json()).toEqual({ products: ['Widget', 'Gadget'], page: 2 });
939
+ // GET /api/products/prod-1
940
+ const getRes = await app.request('/api/products/prod-1', {
941
+ headers: { 'X-User-Id': 'user-42' },
942
+ });
943
+ expect(getRes.status).toBe(200);
944
+ expect(await getRes.json()).toEqual({ id: 'prod-1', viewer: 'user-42' });
945
+ // POST /api/products
946
+ const createRes = await app.request('/api/products', {
947
+ method: 'POST',
948
+ headers: {
949
+ 'Content-Type': 'application/json',
950
+ 'X-User-Id': 'user-42',
951
+ },
952
+ body: JSON.stringify({ name: 'New Product', price: 29.99 }),
953
+ });
954
+ expect(createRes.status).toBe(201);
955
+ expect(await createRes.json()).toEqual({
956
+ id: 'prod-new',
957
+ name: 'New Product',
958
+ createdBy: 'user-42',
959
+ });
960
+ // DELETE /api/products/prod-1
961
+ const deleteRes = await app.request('/api/products/prod-1', {
962
+ method: 'DELETE',
963
+ headers: { 'X-User-Id': 'user-42' },
964
+ });
965
+ expect(deleteRes.status).toBe(204);
966
+ // Verify docs
967
+ expect(builder.docs).toHaveLength(4);
968
+ const paths = builder.docs.map((d) => `${d.method.toUpperCase()} ${d.fullPath}`);
969
+ expect(paths).toContain('GET /api/products');
970
+ expect(paths).toContain('GET /api/products/:id');
971
+ expect(paths).toContain('POST /api/products');
972
+ expect(paths).toContain('DELETE /api/products/:id');
973
+ // Verify hooks
974
+ expect(events).toContain('request-start');
975
+ expect(events).toContain('success:ListProducts');
976
+ expect(events).toContain('success:GetProduct');
977
+ expect(events).toContain('success:CreateProduct');
978
+ expect(events).toContain('success:DeleteProduct');
979
+ expect(events).toContain('request-end');
980
+ });
981
+ });
982
+ });
983
+ //# sourceMappingURL=index.test.js.map