ts-procedures 5.3.0 → 5.4.1

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 (60) 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 -4
  31. package/src/errors.test.ts +0 -163
  32. package/src/errors.ts +0 -107
  33. package/src/exports.ts +0 -7
  34. package/src/implementations/http/README.md +0 -217
  35. package/src/implementations/http/express-rpc/README.md +0 -281
  36. package/src/implementations/http/express-rpc/index.test.ts +0 -957
  37. package/src/implementations/http/express-rpc/index.ts +0 -265
  38. package/src/implementations/http/express-rpc/types.ts +0 -16
  39. package/src/implementations/http/hono-rpc/README.md +0 -358
  40. package/src/implementations/http/hono-rpc/index.test.ts +0 -1075
  41. package/src/implementations/http/hono-rpc/index.ts +0 -237
  42. package/src/implementations/http/hono-rpc/types.ts +0 -16
  43. package/src/implementations/http/hono-stream/README.md +0 -526
  44. package/src/implementations/http/hono-stream/index.test.ts +0 -1676
  45. package/src/implementations/http/hono-stream/index.ts +0 -435
  46. package/src/implementations/http/hono-stream/types.ts +0 -29
  47. package/src/implementations/types.ts +0 -75
  48. package/src/index.test.ts +0 -1194
  49. package/src/index.ts +0 -435
  50. package/src/schema/compute-schema.test.ts +0 -128
  51. package/src/schema/compute-schema.ts +0 -67
  52. package/src/schema/extract-json-schema.test.ts +0 -25
  53. package/src/schema/extract-json-schema.ts +0 -15
  54. package/src/schema/parser.test.ts +0 -182
  55. package/src/schema/parser.ts +0 -148
  56. package/src/schema/resolve-schema-lib.test.ts +0 -19
  57. package/src/schema/resolve-schema-lib.ts +0 -29
  58. package/src/schema/types.ts +0 -20
  59. package/src/stack-utils.test.ts +0 -94
  60. package/src/stack-utils.ts +0 -129
@@ -1,957 +0,0 @@
1
- import { describe, expect, test, vi, beforeEach } from 'vitest'
2
- import request from 'supertest'
3
- import express from 'express'
4
- import { v } from 'suretype'
5
- import { Procedures } from '../../../index.js'
6
- import { ExpressRPCAppBuilder } from './index.js'
7
- import { RPCConfig } from '../../types.js'
8
-
9
- /**
10
- * ExpressRPCAppBuilder Test Suite
11
- *
12
- * Tests the RPC-style Express integration for ts-procedures.
13
- * This builder creates POST routes at `/{name}/{version}` paths (with optional pathPrefix).
14
- */
15
- describe('ExpressRPCAppBuilder', () => {
16
- // --------------------------------------------------------------------------
17
- // Constructor Tests
18
- // --------------------------------------------------------------------------
19
- describe('constructor', () => {
20
- test('creates default Express app with json middleware', async () => {
21
- const builder = new ExpressRPCAppBuilder()
22
- const RPC = Procedures<{ userId: string }, RPCConfig>()
23
-
24
- RPC.Create(
25
- 'Echo',
26
- { scope: 'echo', version: 1, schema: { params: v.object({ message: v.string() }) } },
27
- async (ctx, params) => params
28
- )
29
-
30
- builder.register(RPC, () => ({ userId: '123' }))
31
- const app = builder.build()
32
-
33
- // JSON body should be parsed automatically
34
- const res = await request(app).post('/echo/echo/1').send({ message: 'hello' })
35
-
36
- expect(res.status).toBe(200)
37
- expect(res.body).toEqual({ message: 'hello' })
38
- })
39
-
40
- test('uses provided Express app without adding middleware', async () => {
41
- const customApp = express()
42
- // Intentionally NOT adding json middleware
43
- const builder = new ExpressRPCAppBuilder({ app: customApp })
44
- const RPC = Procedures<{ userId: string }, RPCConfig>()
45
-
46
- RPC.Create(
47
- 'Echo',
48
- { scope: 'echo', version: 1, schema: { params: v.object({ message: v.string() }) } },
49
- async (ctx, params) => ({ received: params })
50
- )
51
-
52
- builder.register(RPC, () => ({ userId: '123' }))
53
- const app = builder.build()
54
-
55
- // Without json middleware, body won't be parsed (req.body is undefined)
56
- const res = await request(app)
57
- .post('/echo/echo/1')
58
- .set('Content-Type', 'application/json')
59
- .send(JSON.stringify({ message: 'hello' }))
60
-
61
- // Request body is undefined since json middleware wasn't added
62
- // Handler receives undefined params
63
- expect(res.body.received).toBeUndefined()
64
- })
65
-
66
- test('handles empty config', () => {
67
- const builder = new ExpressRPCAppBuilder({})
68
- expect(builder.app).toBeDefined()
69
- expect(builder.docs).toEqual([])
70
- })
71
-
72
- test('handles undefined config', () => {
73
- const builder = new ExpressRPCAppBuilder(undefined)
74
- expect(builder.app).toBeDefined()
75
- expect(builder.docs).toEqual([])
76
- })
77
- })
78
-
79
- // --------------------------------------------------------------------------
80
- // pathPrefix Option Tests
81
- // --------------------------------------------------------------------------
82
- describe('pathPrefix option', () => {
83
- test('uses custom pathPrefix for all routes', async () => {
84
- const builder = new ExpressRPCAppBuilder({ pathPrefix: '/api/v1' })
85
- const RPC = Procedures<{}, RPCConfig>()
86
-
87
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
88
-
89
- builder.register(RPC, () => ({}))
90
- const app = builder.build()
91
-
92
- const res = await request(app).post('/api/v1/test/test/1').send({})
93
- expect(res.status).toBe(200)
94
- expect(res.body).toEqual({ ok: true })
95
- })
96
-
97
- test('pathPrefix without leading slash gets normalized', async () => {
98
- const builder = new ExpressRPCAppBuilder({ pathPrefix: 'custom' })
99
- const RPC = Procedures<{}, RPCConfig>()
100
-
101
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
102
-
103
- builder.register(RPC, () => ({}))
104
- const app = builder.build()
105
-
106
- const res = await request(app).post('/custom/test/test/1').send({})
107
- expect(res.status).toBe(200)
108
- })
109
-
110
- test('no prefix when pathPrefix not specified', async () => {
111
- const builder = new ExpressRPCAppBuilder()
112
- const RPC = Procedures<{}, RPCConfig>()
113
-
114
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
115
-
116
- builder.register(RPC, () => ({}))
117
- const app = builder.build()
118
-
119
- const res = await request(app).post('/test/test/1').send({})
120
- expect(res.status).toBe(200)
121
- })
122
-
123
- test('pathPrefix appears in generated docs', () => {
124
- const builder = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
125
- const RPC = Procedures<{}, RPCConfig>()
126
-
127
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({}))
128
-
129
- builder.register(RPC, () => ({}))
130
- builder.build()
131
-
132
- expect(builder.docs[0]!.path).toBe('/api/test/test/1')
133
- })
134
-
135
- test('pathPrefix /rpc restores original behavior', async () => {
136
- const builder = new ExpressRPCAppBuilder({ pathPrefix: '/rpc' })
137
- const RPC = Procedures<{}, RPCConfig>()
138
-
139
- RPC.Create('Users', { scope: 'users', version: 1 }, async () => ({ users: [] }))
140
-
141
- builder.register(RPC, () => ({}))
142
- const app = builder.build()
143
-
144
- const res = await request(app).post('/rpc/users/users/1').send({})
145
- expect(res.status).toBe(200)
146
- expect(builder.docs[0]!.path).toBe('/rpc/users/users/1')
147
- })
148
- })
149
-
150
- // --------------------------------------------------------------------------
151
- // Lifecycle Hooks Tests
152
- // --------------------------------------------------------------------------
153
- describe('lifecycle hooks', () => {
154
- test('onRequestStart is called with request object', async () => {
155
- const onRequestStart = vi.fn()
156
- const builder = new ExpressRPCAppBuilder({ onRequestStart })
157
- const RPC = Procedures<{}, RPCConfig>()
158
-
159
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
160
-
161
- builder.register(RPC, () => ({}))
162
- const app = builder.build()
163
-
164
- await request(app).post('/test/test/1').send({})
165
-
166
- expect(onRequestStart).toHaveBeenCalledTimes(1)
167
- expect(onRequestStart.mock.calls[0]![0]).toHaveProperty('method', 'POST')
168
- expect(onRequestStart.mock.calls[0]![0]).toHaveProperty('path', '/test/test/1')
169
- })
170
-
171
- test('onRequestEnd is called after response finishes', async () => {
172
- const onRequestEnd = vi.fn()
173
- const builder = new ExpressRPCAppBuilder({ onRequestEnd })
174
- const RPC = Procedures<{}, RPCConfig>()
175
-
176
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
177
-
178
- builder.register(RPC, () => ({}))
179
- const app = builder.build()
180
-
181
- await request(app).post('/test/test/1').send({})
182
-
183
- expect(onRequestEnd).toHaveBeenCalledTimes(1)
184
- expect(onRequestEnd.mock.calls[0]![0]).toHaveProperty('method', 'POST')
185
- expect(onRequestEnd.mock.calls[0]![1]).toHaveProperty('statusCode', 200)
186
- })
187
-
188
- test('onSuccess is called on successful procedure execution', async () => {
189
- const onSuccess = vi.fn()
190
- const builder = new ExpressRPCAppBuilder({ onSuccess })
191
- const RPC = Procedures<{}, RPCConfig>()
192
-
193
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
194
-
195
- builder.register(RPC, () => ({}))
196
- const app = builder.build()
197
-
198
- await request(app).post('/test/test/1').send({})
199
-
200
- expect(onSuccess).toHaveBeenCalledTimes(1)
201
- expect(onSuccess.mock.calls[0]![0]).toHaveProperty('name', 'Test')
202
- })
203
-
204
- test('onSuccess is NOT called when procedure throws', async () => {
205
- const onSuccess = vi.fn()
206
- const builder = new ExpressRPCAppBuilder({ onSuccess })
207
- const RPC = Procedures<{}, RPCConfig>()
208
-
209
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
210
- throw new Error('Handler error')
211
- })
212
-
213
- builder.register(RPC, () => ({}))
214
- const app = builder.build()
215
-
216
- await request(app).post('/test/test/1').send({})
217
-
218
- expect(onSuccess).not.toHaveBeenCalled()
219
- })
220
-
221
- test('hooks execute in correct order: start → handler → success → end', async () => {
222
- const order: string[] = []
223
-
224
- const builder = new ExpressRPCAppBuilder({
225
- onRequestStart: () => order.push('start'),
226
- onRequestEnd: () => order.push('end'),
227
- onSuccess: () => order.push('success'),
228
- })
229
- const RPC = Procedures<{}, RPCConfig>()
230
-
231
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
232
- order.push('handler')
233
- return { ok: true }
234
- })
235
-
236
- builder.register(RPC, () => ({}))
237
- const app = builder.build()
238
-
239
- await request(app).post('/test/test/1').send({})
240
-
241
- expect(order).toEqual(['start', 'handler', 'success', 'end'])
242
- })
243
- })
244
-
245
- // --------------------------------------------------------------------------
246
- // Error Handling Tests
247
- // --------------------------------------------------------------------------
248
- describe('error handling', () => {
249
- test('custom error handler receives procedure, req, res, and error', async () => {
250
- const errorHandler = vi.fn((procedure, req, res, error) => {
251
- res.status(400).json({ customError: error.message })
252
- })
253
-
254
- const builder = new ExpressRPCAppBuilder({ onError: errorHandler })
255
- const RPC = Procedures<{}, RPCConfig>()
256
-
257
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
258
- throw new Error('Test error')
259
- })
260
-
261
- builder.register(RPC, () => ({}))
262
- const app = builder.build()
263
-
264
- const res = await request(app).post('/test/test/1').send({})
265
-
266
- expect(errorHandler).toHaveBeenCalledTimes(1)
267
- expect(errorHandler.mock.calls[0]![0]).toHaveProperty('name', 'Test')
268
- expect(errorHandler.mock.calls[0]![3]).toBeInstanceOf(Error)
269
- expect(res.status).toBe(400)
270
- // Error is wrapped by Procedures with "Error in handler for {name}" prefix
271
- expect(res.body.customError).toContain('Test error')
272
- })
273
-
274
- test('default error handling returns error message in response', async () => {
275
- const builder = new ExpressRPCAppBuilder()
276
- const RPC = Procedures<{}, RPCConfig>()
277
-
278
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
279
- throw new Error('Something went wrong')
280
- })
281
-
282
- builder.register(RPC, () => ({}))
283
- const app = builder.build()
284
-
285
- const res = await request(app).post('/test/test/1').send({})
286
-
287
- // Default error handler returns error message in JSON body
288
- expect(res.body).toHaveProperty('error')
289
- expect(res.body.error).toContain('Something went wrong')
290
- })
291
-
292
- test('catches unhandled exceptions in handler', async () => {
293
- const builder = new ExpressRPCAppBuilder()
294
- const RPC = Procedures<{}, RPCConfig>()
295
-
296
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
297
- // Simulate unhandled exception
298
- const obj: any = null
299
- return obj.property // This will throw
300
- })
301
-
302
- builder.register(RPC, () => ({}))
303
- const app = builder.build()
304
-
305
- const res = await request(app).post('/test/test/1').send({})
306
-
307
- // Unhandled exceptions are caught and returned as error response
308
- expect(res.body).toHaveProperty('error')
309
- })
310
- })
311
-
312
- // --------------------------------------------------------------------------
313
- // register() Method Tests
314
- // --------------------------------------------------------------------------
315
- describe('register() method', () => {
316
- test('returns this for method chaining', () => {
317
- const builder = new ExpressRPCAppBuilder()
318
- const RPC1 = Procedures<{}, RPCConfig>()
319
- const RPC2 = Procedures<{}, RPCConfig>()
320
-
321
- const result = builder.register(RPC1, () => ({}))
322
- expect(result).toBe(builder)
323
-
324
- // Chain multiple registrations
325
- const chainResult = builder.register(RPC1, () => ({})).register(RPC2, () => ({}))
326
-
327
- expect(chainResult).toBe(builder)
328
- })
329
-
330
- test('supports registering multiple factories', async () => {
331
- const builder = new ExpressRPCAppBuilder()
332
-
333
- const PublicRPC = Procedures<{ public: true }, RPCConfig>()
334
- const PrivateRPC = Procedures<{ private: true }, RPCConfig>()
335
-
336
- PublicRPC.Create('PublicMethod', { scope: 'public', version: 1 }, async (ctx) => ({
337
- isPublic: ctx.public,
338
- }))
339
-
340
- PrivateRPC.Create('PrivateMethod', { scope: 'private', version: 1 }, async (ctx) => ({
341
- isPrivate: ctx.private,
342
- }))
343
-
344
- builder
345
- .register(PublicRPC, () => ({ public: true as const }))
346
- .register(PrivateRPC, () => ({ private: true as const }))
347
-
348
- const app = builder.build()
349
-
350
- const publicRes = await request(app).post('/public/public-method/1').send({})
351
- const privateRes = await request(app).post('/private/private-method/1').send({})
352
-
353
- expect(publicRes.body).toEqual({ isPublic: true })
354
- expect(privateRes.body).toEqual({ isPrivate: true })
355
- })
356
-
357
- test('context can be a static object', async () => {
358
- const factoryContext = { requestId: 'req-123' }
359
-
360
- const builder = new ExpressRPCAppBuilder()
361
- const RPC = Procedures<{ requestId: string }, RPCConfig>()
362
-
363
- RPC.Create('GetRequestId', { scope: 'get-request-id', version: 1 }, async (ctx) => ({
364
- id: ctx.requestId,
365
- }))
366
-
367
- builder.register(RPC, factoryContext)
368
- const app = builder.build()
369
-
370
- const res = await request(app).post('/get-request-id/get-request-id/1').send({})
371
-
372
- expect(res.body).toEqual({ id: 'req-123' })
373
- })
374
-
375
- test('factoryContext can be async function', async () => {
376
- const factoryContext = vi.fn(async () => {
377
- return { requestId: 'req-456' }
378
- })
379
-
380
- const builder = new ExpressRPCAppBuilder()
381
- const RPC = Procedures<{ requestId: string }, RPCConfig>()
382
-
383
- RPC.Create('GetRequestId', { scope: 'get-request-id', version: 1 }, async (ctx) => ({
384
- id: ctx.requestId,
385
- }))
386
-
387
- builder.register(RPC, factoryContext)
388
- const app = builder.build()
389
-
390
- await request(app).post('/get-request-id/get-request-id/1').send({})
391
-
392
- expect(factoryContext).toHaveBeenCalledTimes(1)
393
- })
394
-
395
- test('factoryContext function receives Express request object', async () => {
396
- const factoryContext = vi.fn((req) => ({
397
- authHeader: req.headers.authorization,
398
- }))
399
-
400
- const builder = new ExpressRPCAppBuilder()
401
- const RPC = Procedures<{ authHeader?: string }, RPCConfig>()
402
-
403
- RPC.Create('GetAuth', { scope: 'get-auth', version: 1 }, async (ctx) => ({
404
- auth: ctx.authHeader,
405
- }))
406
-
407
- builder.register(RPC, factoryContext)
408
- const app = builder.build()
409
-
410
- await request(app)
411
- .post('/get-auth/get-auth/1')
412
- .set('Authorization', 'Bearer token123')
413
- .send({})
414
-
415
- expect(factoryContext).toHaveBeenCalledTimes(1)
416
- expect(factoryContext.mock.calls[0]![0]).toHaveProperty('headers')
417
- expect(factoryContext.mock.calls[0]![0].headers).toHaveProperty(
418
- 'authorization',
419
- 'Bearer token123'
420
- )
421
- })
422
- })
423
-
424
- // --------------------------------------------------------------------------
425
- // build() Method Tests
426
- // --------------------------------------------------------------------------
427
- describe('build() method', () => {
428
- test('creates POST routes for all procedures', async () => {
429
- const builder = new ExpressRPCAppBuilder()
430
- const RPC = Procedures<{}, RPCConfig>()
431
-
432
- RPC.Create('MethodOne', { scope: 'method-one', version: 1 }, async () => ({ m: 1 }))
433
- RPC.Create('MethodTwo', { scope: 'method-two', version: 2 }, async () => ({ m: 2 }))
434
-
435
- builder.register(RPC, () => ({}))
436
- const app = builder.build()
437
-
438
- const res1 = await request(app).post('/method-one/method-one/1').send({})
439
- const res2 = await request(app).post('/method-two/method-two/2').send({})
440
-
441
- expect(res1.status).toBe(200)
442
- expect(res2.status).toBe(200)
443
- expect(res1.body).toEqual({ m: 1 })
444
- expect(res2.body).toEqual({ m: 2 })
445
- })
446
-
447
- test('returns the Express application', () => {
448
- const builder = new ExpressRPCAppBuilder()
449
- const app = builder.build()
450
-
451
- expect(app).toBe(builder.app)
452
- expect(typeof app.listen).toBe('function')
453
- })
454
-
455
- test('populates docs array after build', () => {
456
- const builder = new ExpressRPCAppBuilder()
457
- const RPC = Procedures<{}, RPCConfig>()
458
-
459
- RPC.Create('MethodOne', { scope: 'method-one', version: 1 }, async () => ({}))
460
- RPC.Create('MethodTwo', { scope: ['nested', 'method'], version: 2 }, async () => ({}))
461
-
462
- expect(builder.docs).toHaveLength(0)
463
-
464
- builder.register(RPC, () => ({}))
465
- builder.build()
466
-
467
- expect(builder.docs).toHaveLength(2)
468
- expect(builder.docs[0]!.path).toBe('/method-one/method-one/1')
469
- expect(builder.docs[1]!.path).toBe('/nested/method/method-two/2')
470
- })
471
-
472
- test('passes request body to handler as params', async () => {
473
- const builder = new ExpressRPCAppBuilder()
474
- const RPC = Procedures<{}, RPCConfig>()
475
-
476
- RPC.Create(
477
- 'Echo',
478
- { scope: 'echo', version: 1, schema: { params: v.object({ data: v.string() }) } },
479
- async (ctx, params) => ({ received: params.data })
480
- )
481
-
482
- builder.register(RPC, () => ({}))
483
- const app = builder.build()
484
-
485
- const res = await request(app).post('/echo/echo/1').send({ data: 'test-data' })
486
-
487
- expect(res.body).toEqual({ received: 'test-data' })
488
- })
489
-
490
- test('GET requests return 404 (RPC uses POST only)', async () => {
491
- const builder = new ExpressRPCAppBuilder()
492
- const RPC = Procedures<{}, RPCConfig>()
493
-
494
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
495
-
496
- builder.register(RPC, () => ({}))
497
- const app = builder.build()
498
-
499
- const res = await request(app).get('/test/test/1')
500
-
501
- expect(res.status).toBe(404)
502
- })
503
- })
504
-
505
- // --------------------------------------------------------------------------
506
- // Path Generation Tests (makeRPCHttpRoutePath)
507
- // --------------------------------------------------------------------------
508
- describe('makeRPCHttpRoutePath', () => {
509
- let builder: ExpressRPCAppBuilder
510
-
511
- beforeEach(() => {
512
- builder = new ExpressRPCAppBuilder()
513
- })
514
-
515
- test("simple scope with procedure name: 'users' + 'GetUser' → /users/get-user/1", () => {
516
- const path = builder.makeRPCHttpRoutePath('GetUser', { scope: 'users', version: 1 })
517
- expect(path).toBe('/users/get-user/1')
518
- })
519
-
520
- test("array scope with procedure name: ['users', 'profile'] + 'GetById' → /users/profile/get-by-id/1", () => {
521
- const path = builder.makeRPCHttpRoutePath('GetById', {
522
- scope: ['users', 'profile'],
523
- version: 1,
524
- })
525
- expect(path).toBe('/users/profile/get-by-id/1')
526
- })
527
-
528
- test("camelCase procedure name: 'users' + 'getProfile' → /users/get-profile/1", () => {
529
- const path = builder.makeRPCHttpRoutePath('getProfile', { scope: 'users', version: 1 })
530
- expect(path).toBe('/users/get-profile/1')
531
- })
532
-
533
- test("PascalCase procedure name: 'users' + 'UpdateProfile' → /users/update-profile/1", () => {
534
- const path = builder.makeRPCHttpRoutePath('UpdateProfile', { scope: 'users', version: 1 })
535
- expect(path).toBe('/users/update-profile/1')
536
- })
537
-
538
- test('version number included in path', () => {
539
- const pathV1 = builder.makeRPCHttpRoutePath('Test', { scope: 'test', version: 1 })
540
- const pathV2 = builder.makeRPCHttpRoutePath('Test', { scope: 'test', version: 2 })
541
- const pathV99 = builder.makeRPCHttpRoutePath('Test', { scope: 'test', version: 99 })
542
-
543
- expect(pathV1).toBe('/test/test/1')
544
- expect(pathV2).toBe('/test/test/2')
545
- expect(pathV99).toBe('/test/test/99')
546
- })
547
-
548
- test('handles mixed case in array segments', () => {
549
- const path = builder.makeRPCHttpRoutePath('ListUsers', {
550
- scope: ['UserModule', 'getActiveUsers'],
551
- version: 1,
552
- })
553
- expect(path).toBe('/user-module/get-active-users/list-users/1')
554
- })
555
- })
556
-
557
- // --------------------------------------------------------------------------
558
- // Route Documentation Tests (buildRpcHttpRouteDoc)
559
- // --------------------------------------------------------------------------
560
- describe('buildRpcHttpRouteDoc', () => {
561
- let builder: ExpressRPCAppBuilder
562
-
563
- beforeEach(() => {
564
- builder = new ExpressRPCAppBuilder()
565
- })
566
-
567
- test('generates complete route documentation', () => {
568
- const paramsSchema = v.object({ id: v.string() })
569
- const returnSchema = v.object({ name: v.string() })
570
-
571
- const RPC = Procedures<{}, RPCConfig>()
572
- RPC.Create(
573
- 'GetUser',
574
- { scope: 'users', version: 1, schema: { params: paramsSchema, returnType: returnSchema } },
575
- async () => ({ name: 'test' })
576
- )
577
-
578
- builder.register(RPC, () => ({}))
579
- builder.build()
580
-
581
- const doc = builder.docs[0]!
582
- expect(doc.path).toBe('/users/get-user/1')
583
- expect(doc.method).toBe('post')
584
- expect(doc.jsonSchema.body).toBeDefined()
585
- expect(doc.jsonSchema.response).toBeDefined()
586
- })
587
-
588
- test('omits body schema when no params defined', () => {
589
- const RPC = Procedures<{}, RPCConfig>()
590
- RPC.Create('NoParams', { scope: 'no-params', version: 1 }, async () => ({ ok: true }))
591
-
592
- builder.register(RPC, () => ({}))
593
- builder.build()
594
-
595
- const doc = builder.docs[0]!
596
- expect(doc.jsonSchema.body).toBeUndefined()
597
- })
598
-
599
- test('omits response schema when no returnType defined', () => {
600
- const RPC = Procedures<{}, RPCConfig>()
601
- RPC.Create(
602
- 'NoReturn',
603
- { scope: 'no-return', version: 1, schema: { params: v.object({ x: v.number() }) } },
604
- async () => ({})
605
- )
606
-
607
- builder.register(RPC, () => ({}))
608
- builder.build()
609
-
610
- const doc = builder.docs[0]!
611
- expect(doc.jsonSchema.body).toBeDefined()
612
- expect(doc.jsonSchema.response).toBeUndefined()
613
- })
614
-
615
- test("method is always 'post'", () => {
616
- const RPC = Procedures<{}, RPCConfig>()
617
- RPC.Create('Test1', { scope: 't1', version: 1 }, async () => ({}))
618
- RPC.Create('Test2', { scope: 't2', version: 2 }, async () => ({}))
619
-
620
- builder.register(RPC, () => ({}))
621
- builder.build()
622
-
623
- builder.docs.forEach((doc) => {
624
- expect(doc.method).toBe('post')
625
- })
626
- })
627
- })
628
-
629
- // --------------------------------------------------------------------------
630
- // extendProcedureDoc Tests
631
- // --------------------------------------------------------------------------
632
- describe('extendProcedureDoc', () => {
633
- test('adds custom properties to generated documentation', () => {
634
- const builder = new ExpressRPCAppBuilder()
635
- const RPC = Procedures<{}, RPCConfig>()
636
-
637
- RPC.Create('GetUser', { scope: 'users', version: 1 }, async () => ({ name: 'test' }))
638
-
639
- builder.register(
640
- RPC,
641
- () => ({}),
642
- ({ base, procedure }) => ({
643
- summary: `Get user endpoint`,
644
- tags: ['users'],
645
- operationId: procedure.name,
646
- })
647
- )
648
- builder.build()
649
-
650
- const doc = builder.docs[0]!
651
- expect(doc).toHaveProperty('summary', 'Get user endpoint')
652
- expect(doc).toHaveProperty('tags', ['users'])
653
- expect(doc).toHaveProperty('operationId', 'GetUser')
654
- })
655
-
656
- test('receives correct base and procedure parameters', () => {
657
- const extendFn = vi.fn(() => ({}))
658
- const builder = new ExpressRPCAppBuilder()
659
- const RPC = Procedures<{}, RPCConfig>()
660
-
661
- const paramsSchema = v.object({ id: v.string() })
662
- RPC.Create(
663
- 'GetItem',
664
- { scope: 'items', version: 2, schema: { params: paramsSchema } },
665
- async () => ({})
666
- )
667
-
668
- builder.register(RPC, () => ({}), extendFn)
669
- builder.build()
670
-
671
- expect(extendFn).toHaveBeenCalledTimes(1)
672
- const callArg = extendFn.mock.calls[0]![0 as any] as any
673
-
674
- // Verify base properties
675
- expect(callArg.base).toHaveProperty('name', 'GetItem')
676
- expect(callArg.base).toHaveProperty('version', 2)
677
- expect(callArg.base).toHaveProperty('scope', 'items')
678
- expect(callArg.base).toHaveProperty('path', '/items/get-item/2')
679
- expect(callArg.base).toHaveProperty('method', 'post')
680
- expect(callArg.base.jsonSchema).toHaveProperty('body')
681
-
682
- // Verify procedure properties
683
- expect(callArg.procedure).toHaveProperty('name', 'GetItem')
684
- expect(callArg.procedure).toHaveProperty('handler')
685
- expect(callArg.procedure.config).toHaveProperty('scope', 'items')
686
- expect(callArg.procedure.config).toHaveProperty('version', 2)
687
- })
688
-
689
- test('base properties take precedence over extended properties', () => {
690
- const builder = new ExpressRPCAppBuilder()
691
- const RPC = Procedures<{}, RPCConfig>()
692
-
693
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({}))
694
-
695
- builder.register(
696
- RPC,
697
- () => ({}),
698
- () => ({
699
- name: 'OverriddenName',
700
- path: '/overridden/path',
701
- method: 'get',
702
- customField: 'custom-value',
703
- })
704
- )
705
- builder.build()
706
-
707
- const doc = builder.docs[0]!
708
- // Base properties should NOT be overridden
709
- expect(doc.name).toBe('Test')
710
- expect(doc.path).toBe('/test/test/1')
711
- expect(doc.method).toBe('post')
712
- // Custom field should be present
713
- expect(doc).toHaveProperty('customField', 'custom-value')
714
- })
715
-
716
- test('different factories can have different extendProcedureDoc functions', () => {
717
- const builder = new ExpressRPCAppBuilder()
718
-
719
- const PublicRPC = Procedures<{}, RPCConfig>()
720
- const AdminRPC = Procedures<{}, RPCConfig>()
721
-
722
- PublicRPC.Create('GetPublic', { scope: 'public', version: 1 }, async () => ({}))
723
- AdminRPC.Create('GetAdmin', { scope: 'admin', version: 1 }, async () => ({}))
724
-
725
- builder
726
- .register(
727
- PublicRPC,
728
- () => ({}),
729
- () => ({
730
- security: [],
731
- tags: ['public'],
732
- })
733
- )
734
- .register(
735
- AdminRPC,
736
- () => ({}),
737
- () => ({
738
- security: [{ bearerAuth: [] }],
739
- tags: ['admin'],
740
- })
741
- )
742
-
743
- builder.build()
744
-
745
- const publicDoc = builder.docs.find((d) => d.name === 'GetPublic')!
746
- const adminDoc = builder.docs.find((d) => d.name === 'GetAdmin')!
747
-
748
- expect(publicDoc).toHaveProperty('security', [])
749
- expect(publicDoc).toHaveProperty('tags', ['public'])
750
- expect(adminDoc).toHaveProperty('security', [{ bearerAuth: [] }])
751
- expect(adminDoc).toHaveProperty('tags', ['admin'])
752
- })
753
-
754
- test('extendProcedureDoc is called for each procedure in factory', () => {
755
- const extendFn = vi.fn(() => ({ extended: true }))
756
- const builder = new ExpressRPCAppBuilder()
757
- const RPC = Procedures<{}, RPCConfig>()
758
-
759
- RPC.Create('Method1', { scope: 'm1', version: 1 }, async () => ({}))
760
- RPC.Create('Method2', { scope: 'm2', version: 1 }, async () => ({}))
761
- RPC.Create('Method3', { scope: 'm3', version: 1 }, async () => ({}))
762
-
763
- builder.register(RPC, () => ({}), extendFn)
764
- builder.build()
765
-
766
- expect(extendFn).toHaveBeenCalledTimes(3)
767
-
768
- const procedureNames = extendFn.mock.calls.map((call: any) => call[0].procedure.name)
769
- expect(procedureNames).toContain('Method1')
770
- expect(procedureNames).toContain('Method2')
771
- expect(procedureNames).toContain('Method3')
772
- })
773
-
774
- test('not providing extendProcedureDoc results in base documentation only', () => {
775
- const builder = new ExpressRPCAppBuilder()
776
- const RPC = Procedures<{}, RPCConfig>()
777
-
778
- RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({}))
779
-
780
- builder.register(RPC, () => ({})) // No extendProcedureDoc
781
- builder.build()
782
-
783
- const doc = builder.docs[0]!
784
- expect(doc.name).toBe('Test')
785
- expect(doc.path).toBe('/test/test/1')
786
- expect(doc.method).toBe('post')
787
- // Should not have any extra properties
788
- expect(Object.keys(doc)).toEqual(['name', 'version', 'scope', 'path', 'method', 'jsonSchema'])
789
- })
790
-
791
- test('extendProcedureDoc can access procedure config for conditional logic', () => {
792
- const builder = new ExpressRPCAppBuilder()
793
- const RPC = Procedures<{}, RPCConfig>()
794
-
795
- RPC.Create('PublicEndpoint', { scope: 'public', version: 1 }, async () => ({}))
796
- RPC.Create('PrivateEndpoint', { scope: 'private', version: 1 }, async () => ({}))
797
-
798
- builder.register(
799
- RPC,
800
- () => ({}),
801
- ({ procedure }) => ({
802
- isPublic: procedure.config.scope === 'public',
803
- description: `This is a ${procedure.config.scope} endpoint`,
804
- })
805
- )
806
- builder.build()
807
-
808
- const publicDoc = builder.docs.find((d) => d.name === 'PublicEndpoint')!
809
- const privateDoc = builder.docs.find((d) => d.name === 'PrivateEndpoint')!
810
-
811
- expect(publicDoc).toHaveProperty('isPublic', true)
812
- expect(publicDoc).toHaveProperty('description', 'This is a public endpoint')
813
- expect(privateDoc).toHaveProperty('isPublic', false)
814
- expect(privateDoc).toHaveProperty('description', 'This is a private endpoint')
815
- })
816
-
817
- test('extendProcedureDoc can use base jsonSchema for OpenAPI-style docs', () => {
818
- const builder = new ExpressRPCAppBuilder()
819
- const RPC = Procedures<{}, RPCConfig>()
820
-
821
- const paramsSchema = v.object({ userId: v.string() })
822
- const returnSchema = v.object({ name: v.string(), email: v.string() })
823
-
824
- RPC.Create(
825
- 'GetUser',
826
- { scope: 'users', version: 1, schema: { params: paramsSchema, returnType: returnSchema } },
827
- async () => ({ name: 'test', email: 'test@example.com' })
828
- )
829
-
830
- builder.register(
831
- RPC,
832
- () => ({}),
833
- ({ base }) => ({
834
- requestBody: base.jsonSchema.body
835
- ? { content: { 'application/json': { schema: base.jsonSchema.body } } }
836
- : undefined,
837
- responses: {
838
- 200: base.jsonSchema.response
839
- ? { content: { 'application/json': { schema: base.jsonSchema.response } } }
840
- : { description: 'Success' },
841
- },
842
- })
843
- )
844
- builder.build()
845
-
846
- const doc = builder.docs[0] as any
847
- expect(doc).toHaveProperty('requestBody')
848
- expect(doc.requestBody).toHaveProperty('content')
849
- expect(doc).toHaveProperty('responses')
850
- expect(doc.responses).toHaveProperty('200')
851
- })
852
- })
853
-
854
- // --------------------------------------------------------------------------
855
- // Integration Test
856
- // --------------------------------------------------------------------------
857
- describe('integration', () => {
858
- test('full workflow with multiple procedure factories and different contexts', async () => {
859
- // Define context types
860
- type PublicContext = { source: 'public' }
861
- type AuthContext = { source: 'auth'; userId: string }
862
-
863
- // Create factories
864
- const PublicRPC = Procedures<PublicContext, RPCConfig>()
865
- const AuthRPC = Procedures<AuthContext, RPCConfig>()
866
-
867
- // Create public procedures
868
- PublicRPC.Create('GetVersion', { scope: ['system', 'version'], version: 1 }, async () => ({
869
- version: '1.0.0',
870
- }))
871
-
872
- PublicRPC.Create('HealthCheck', { scope: 'health', version: 1 }, async () => ({
873
- status: 'ok',
874
- }))
875
-
876
- // Create authenticated procedures
877
- AuthRPC.Create(
878
- 'GetProfile',
879
- {
880
- scope: ['users', 'profile'],
881
- version: 1,
882
- schema: { returnType: v.object({ userId: v.string(), source: v.string() }) },
883
- },
884
- async (ctx) => ({ userId: ctx.userId, source: ctx.source })
885
- )
886
-
887
- AuthRPC.Create(
888
- 'UpdateProfile',
889
- {
890
- scope: ['users', 'profile'],
891
- version: 2,
892
- schema: { params: v.object({ name: v.string() }) },
893
- },
894
- async (ctx, params) => ({ userId: ctx.userId, name: params.name })
895
- )
896
-
897
- // Build app with lifecycle hooks
898
- const events: string[] = []
899
-
900
- const builder = new ExpressRPCAppBuilder({
901
- onRequestStart: () => events.push('request-start'),
902
- onRequestEnd: () => events.push('request-end'),
903
- onSuccess: (proc) => events.push(`success:${proc.name}`),
904
- })
905
-
906
- builder
907
- .register(PublicRPC, () => ({ source: 'public' as const }))
908
- .register(AuthRPC, (req) => ({
909
- source: 'auth' as const,
910
- userId: (req.headers['x-user-id'] as string) || 'anonymous',
911
- }))
912
-
913
- const app = builder.build()
914
-
915
- // Test public endpoints
916
- const versionRes = await request(app).post('/system/version/get-version/1').send({})
917
- expect(versionRes.status).toBe(200)
918
- expect(versionRes.body).toEqual({ version: '1.0.0' })
919
-
920
- const healthRes = await request(app).post('/health/health-check/1').send({})
921
- expect(healthRes.status).toBe(200)
922
- expect(healthRes.body).toEqual({ status: 'ok' })
923
-
924
- // Test authenticated endpoints
925
- const profileRes = await request(app)
926
- .post('/users/profile/get-profile/1')
927
- .set('X-User-Id', 'user-123')
928
- .send({})
929
- expect(profileRes.status).toBe(200)
930
- expect(profileRes.body).toEqual({ userId: 'user-123', source: 'auth' })
931
-
932
- const updateRes = await request(app)
933
- .post('/users/profile/update-profile/2')
934
- .set('X-User-Id', 'user-456')
935
- .send({ name: 'John Doe' })
936
- expect(updateRes.status).toBe(200)
937
- expect(updateRes.body).toEqual({ userId: 'user-456', name: 'John Doe' })
938
-
939
- // Verify documentation
940
- expect(builder.docs).toHaveLength(4)
941
-
942
- const paths = builder.docs.map((d) => d.path)
943
- expect(paths).toContain('/system/version/get-version/1')
944
- expect(paths).toContain('/health/health-check/1')
945
- expect(paths).toContain('/users/profile/get-profile/1')
946
- expect(paths).toContain('/users/profile/update-profile/2')
947
-
948
- // Verify hooks were called
949
- expect(events).toContain('request-start')
950
- expect(events).toContain('success:GetVersion')
951
- expect(events).toContain('success:HealthCheck')
952
- expect(events).toContain('success:GetProfile')
953
- expect(events).toContain('success:UpdateProfile')
954
- expect(events).toContain('request-end')
955
- })
956
- })
957
- })