ts-procedures 1.0.0 → 1.1.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 (72) hide show
  1. package/README.md +10 -0
  2. package/build/errors.d.ts +16 -0
  3. package/build/errors.js +37 -0
  4. package/build/errors.js.map +1 -0
  5. package/build/exports.d.ts +6 -0
  6. package/build/exports.js +7 -0
  7. package/build/exports.js.map +1 -0
  8. package/build/implementations/http/express/example/factories.d.ts +97 -0
  9. package/build/implementations/http/express/example/factories.js +4 -0
  10. package/build/implementations/http/express/example/factories.js.map +1 -0
  11. package/build/implementations/http/express/example/procedures/auth.d.ts +1 -0
  12. package/build/implementations/http/express/example/procedures/auth.js +22 -0
  13. package/build/implementations/http/express/example/procedures/auth.js.map +1 -0
  14. package/build/implementations/http/express/example/procedures/users.d.ts +1 -0
  15. package/build/implementations/http/express/example/procedures/users.js +30 -0
  16. package/build/implementations/http/express/example/procedures/users.js.map +1 -0
  17. package/build/implementations/http/express/example/server.d.ts +3 -0
  18. package/build/implementations/http/express/example/server.js +49 -0
  19. package/build/implementations/http/express/example/server.js.map +1 -0
  20. package/build/implementations/http/express/example/server.test.d.ts +1 -0
  21. package/build/implementations/http/express/example/server.test.js +110 -0
  22. package/build/implementations/http/express/example/server.test.js.map +1 -0
  23. package/build/implementations/http/express/index.d.ts +34 -0
  24. package/build/implementations/http/express/index.js +75 -0
  25. package/build/implementations/http/express/index.js.map +1 -0
  26. package/build/implementations/http/express/index.test.d.ts +1 -0
  27. package/build/implementations/http/express/index.test.js +329 -0
  28. package/build/implementations/http/express/index.test.js.map +1 -0
  29. package/build/index.d.ts +71 -0
  30. package/build/index.js +80 -0
  31. package/build/index.js.map +1 -0
  32. package/build/index.test.d.ts +1 -0
  33. package/build/index.test.js +249 -0
  34. package/build/index.test.js.map +1 -0
  35. package/build/schema/compute-schema.d.ts +23 -0
  36. package/build/schema/compute-schema.js +28 -0
  37. package/build/schema/compute-schema.js.map +1 -0
  38. package/build/schema/compute-schema.test.d.ts +1 -0
  39. package/build/schema/compute-schema.test.js +107 -0
  40. package/build/schema/compute-schema.test.js.map +1 -0
  41. package/build/schema/extract-json-schema.d.ts +2 -0
  42. package/build/schema/extract-json-schema.js +12 -0
  43. package/build/schema/extract-json-schema.js.map +1 -0
  44. package/build/schema/extract-json-schema.test.d.ts +1 -0
  45. package/build/schema/extract-json-schema.test.js +23 -0
  46. package/build/schema/extract-json-schema.test.js.map +1 -0
  47. package/build/schema/parser.d.ts +21 -0
  48. package/build/schema/parser.js +70 -0
  49. package/build/schema/parser.js.map +1 -0
  50. package/build/schema/parser.test.d.ts +1 -0
  51. package/build/schema/parser.test.js +102 -0
  52. package/build/schema/parser.test.js.map +1 -0
  53. package/build/schema/resolve-schema-lib.d.ts +12 -0
  54. package/build/schema/resolve-schema-lib.js +11 -0
  55. package/build/schema/resolve-schema-lib.js.map +1 -0
  56. package/build/schema/resolve-schema-lib.test.d.ts +1 -0
  57. package/build/schema/resolve-schema-lib.test.js +17 -0
  58. package/build/schema/resolve-schema-lib.test.js.map +1 -0
  59. package/build/schema/types.d.ts +7 -0
  60. package/build/schema/types.js +2 -0
  61. package/build/schema/types.js.map +1 -0
  62. package/package.json +21 -9
  63. package/src/implementations/http/express/README.md +351 -0
  64. package/src/implementations/http/express/example/factories.ts +25 -0
  65. package/src/implementations/http/express/example/procedures/auth.ts +24 -0
  66. package/src/implementations/http/express/example/procedures/users.ts +32 -0
  67. package/src/implementations/http/express/example/server.test.ts +133 -0
  68. package/src/implementations/http/express/example/server.ts +67 -0
  69. package/src/implementations/http/express/index.test.ts +526 -0
  70. package/src/implementations/http/express/index.ts +108 -0
  71. package/src/index.test.ts +4 -2
  72. package/src/index.ts +9 -17
@@ -0,0 +1,526 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import supertest from 'supertest'
3
+ import express, { Express } from 'express'
4
+ import { registerExpressRoutes, mapPathParamsToObject } from './index.js'
5
+ import { Procedures } from '../../../index.js'
6
+ import { Type } from 'typebox'
7
+
8
+ describe('mapPathParamsToObject', () => {
9
+ it('extracts single path param', () => {
10
+ const result = mapPathParamsToObject('/users/:id', '/users/123')
11
+ expect(result).toEqual({ id: '123' })
12
+ })
13
+
14
+ it('extracts multiple path params', () => {
15
+ const result = mapPathParamsToObject('/users/:userId/posts/:postId', '/users/42/posts/99')
16
+ expect(result).toEqual({ userId: '42', postId: '99' })
17
+ })
18
+
19
+ it('returns empty object when no params', () => {
20
+ const result = mapPathParamsToObject('/users', '/users')
21
+ expect(result).toEqual({})
22
+ })
23
+
24
+ it('handles trailing slashes', () => {
25
+ const result = mapPathParamsToObject('/users/:id/', '/users/123/')
26
+ expect(result).toEqual({ id: '123' })
27
+ })
28
+
29
+ it('handles missing segment with empty string', () => {
30
+ const result = mapPathParamsToObject('/users/:id/:name', '/users/123')
31
+ expect(result).toEqual({ id: '123', name: '' })
32
+ })
33
+
34
+ it('ignores query string in URL', () => {
35
+ const result = mapPathParamsToObject('/users/:id', '/users/123?foo=bar')
36
+ expect(result).toEqual({ id: '123' })
37
+ })
38
+
39
+ it('handles nested paths with params', () => {
40
+ const result = mapPathParamsToObject('/api/v1/users/:id/profile', '/api/v1/users/abc/profile')
41
+ expect(result).toEqual({ id: 'abc' })
42
+ })
43
+ })
44
+
45
+ describe('registerExpressRoutes', () => {
46
+ let app: Express
47
+
48
+ beforeEach(() => {
49
+ app = express()
50
+ app.use(express.json())
51
+ })
52
+
53
+ describe('Route Registration', () => {
54
+ it('registers GET route and returns JSON response', async () => {
55
+ const { Create, getProcedures } = Procedures<
56
+ { user: string },
57
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
58
+ >()
59
+
60
+ Create('GetUsers', { method: 'get', path: '/users' }, async () => ({
61
+ users: ['alice', 'bob'],
62
+ }))
63
+
64
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
65
+
66
+ const response = await supertest(app).get('/users')
67
+ expect(response.status).toBe(200)
68
+ expect(response.body).toEqual({ users: ['alice', 'bob'] })
69
+ })
70
+
71
+ it('registers POST route and returns JSON response', async () => {
72
+ const { Create, getProcedures } = Procedures<
73
+ { user: string },
74
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
75
+ >()
76
+
77
+ Create(
78
+ 'CreateUser',
79
+ {
80
+ method: 'post',
81
+ path: '/users',
82
+ schema: {
83
+ params: Type.Object({ name: Type.Optional(Type.String()) }),
84
+ },
85
+ },
86
+ async (_ctx, params) => ({ created: true, name: params?.name })
87
+ )
88
+
89
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
90
+
91
+ const response = await supertest(app).post('/users').send({ name: 'charlie' })
92
+ expect(response.status).toBe(200)
93
+ expect(response.body).toEqual({ created: true, name: 'charlie' })
94
+ })
95
+
96
+ it('registers PUT route', async () => {
97
+ const { Create, getProcedures } = Procedures<
98
+ { user: string },
99
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
100
+ >()
101
+
102
+ Create(
103
+ 'UpdateUser',
104
+ {
105
+ method: 'put',
106
+ path: '/users/:id',
107
+ schema: {
108
+ params: Type.Object({ id: Type.Optional(Type.String()) }),
109
+ },
110
+ },
111
+ async (_ctx, params) => ({ updated: true, id: params?.id })
112
+ )
113
+
114
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
115
+
116
+ const response = await supertest(app).put('/users/123').send({ name: 'updated' })
117
+ expect(response.status).toBe(200)
118
+ expect(response.body).toEqual({ updated: true, id: '123' })
119
+ })
120
+
121
+ it('registers PATCH route', async () => {
122
+ const { Create, getProcedures } = Procedures<
123
+ { user: string },
124
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
125
+ >()
126
+
127
+ Create(
128
+ 'PatchUser',
129
+ {
130
+ method: 'patch',
131
+ path: '/users/:id',
132
+ schema: {
133
+ params: Type.Object({ id: Type.Optional(Type.String()) }),
134
+ },
135
+ },
136
+ async (_ctx, params) => ({ patched: true, id: params?.id })
137
+ )
138
+
139
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
140
+
141
+ const response = await supertest(app).patch('/users/456').send({ status: 'active' })
142
+ expect(response.status).toBe(200)
143
+ expect(response.body).toEqual({ patched: true, id: '456' })
144
+ })
145
+
146
+ it('registers DELETE route', async () => {
147
+ const { Create, getProcedures } = Procedures<
148
+ { user: string },
149
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
150
+ >()
151
+
152
+ Create(
153
+ 'DeleteUser',
154
+ {
155
+ method: 'delete',
156
+ path: '/users/:id',
157
+ schema: {
158
+ params: Type.Object({ id: Type.Optional(Type.String()) }),
159
+ },
160
+ },
161
+ async (_ctx, params) => ({ deleted: true, id: params?.id })
162
+ )
163
+
164
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
165
+
166
+ const response = await supertest(app).delete('/users/789')
167
+ expect(response.status).toBe(200)
168
+ expect(response.body).toEqual({ deleted: true, id: '789' })
169
+ })
170
+ })
171
+
172
+ describe('Parameter Handling', () => {
173
+ it('passes path params to handler', async () => {
174
+ const { Create, getProcedures } = Procedures<
175
+ { user: string },
176
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
177
+ >()
178
+
179
+ Create(
180
+ 'GetUser',
181
+ {
182
+ method: 'get',
183
+ path: '/users/:userId',
184
+ schema: {
185
+ params: Type.Object({ userId: Type.Optional(Type.String()) }),
186
+ },
187
+ },
188
+ async (_ctx, params) => ({ userId: params?.userId })
189
+ )
190
+
191
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
192
+
193
+ const response = await supertest(app).get('/users/abc123')
194
+ expect(response.body).toEqual({ userId: 'abc123' })
195
+ })
196
+
197
+ it('passes query params to handler', async () => {
198
+ const { Create, getProcedures } = Procedures<
199
+ { user: string },
200
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
201
+ >()
202
+
203
+ Create(
204
+ 'SearchUsers',
205
+ {
206
+ method: 'get',
207
+ path: '/users',
208
+ schema: {
209
+ params: Type.Object({
210
+ q: Type.Optional(Type.String()),
211
+ limit: Type.Optional(Type.String()),
212
+ }),
213
+ },
214
+ },
215
+ async (_ctx, params) => ({ query: params?.q, limit: params?.limit })
216
+ )
217
+
218
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
219
+
220
+ const response = await supertest(app).get('/users?q=test&limit=10')
221
+ expect(response.body).toEqual({ query: 'test', limit: '10' })
222
+ })
223
+
224
+ it('passes body params to handler', async () => {
225
+ const { Create, getProcedures } = Procedures<
226
+ { user: string },
227
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
228
+ >()
229
+
230
+ Create(
231
+ 'CreatePost',
232
+ {
233
+ method: 'post',
234
+ path: '/posts',
235
+ schema: {
236
+ params: Type.Object({
237
+ title: Type.Optional(Type.String()),
238
+ content: Type.Optional(Type.String()),
239
+ }),
240
+ },
241
+ },
242
+ async (_ctx, params) => ({ title: params?.title, content: params?.content })
243
+ )
244
+
245
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
246
+
247
+ const response = await supertest(app)
248
+ .post('/posts')
249
+ .send({ title: 'Hello', content: 'World' })
250
+ expect(response.body).toEqual({ title: 'Hello', content: 'World' })
251
+ })
252
+
253
+ it('merges path, query, and body params', async () => {
254
+ const { Create, getProcedures } = Procedures<
255
+ { user: string },
256
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
257
+ >()
258
+
259
+ Create(
260
+ 'UpdatePost',
261
+ {
262
+ method: 'put',
263
+ path: '/users/:userId/posts/:postId',
264
+ schema: {
265
+ params: Type.Object({
266
+ userId: Type.Optional(Type.String()),
267
+ postId: Type.Optional(Type.String()),
268
+ filter: Type.Optional(Type.String()),
269
+ title: Type.Optional(Type.String()),
270
+ }),
271
+ },
272
+ },
273
+ async (_ctx, params) => ({
274
+ userId: params?.userId,
275
+ postId: params?.postId,
276
+ filter: params?.filter,
277
+ title: params?.title,
278
+ })
279
+ )
280
+
281
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
282
+
283
+ const response = await supertest(app)
284
+ .put('/users/u1/posts/p2?filter=active')
285
+ .send({ title: 'Updated Title' })
286
+ expect(response.body).toEqual({
287
+ userId: 'u1',
288
+ postId: 'p2',
289
+ filter: 'active',
290
+ title: 'Updated Title',
291
+ })
292
+ })
293
+ })
294
+
295
+ describe('Context Generation', () => {
296
+ it('calls getContext with req and res', async () => {
297
+ const getContext = vi.fn().mockResolvedValue({ user: 'testuser' })
298
+
299
+ const { Create, getProcedures } = Procedures<
300
+ { user: string },
301
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
302
+ >()
303
+
304
+ Create('GetData', { method: 'get', path: '/data' }, async () => ({ ok: true }))
305
+
306
+ registerExpressRoutes(app, { getContext }, getProcedures())
307
+
308
+ await supertest(app).get('/data')
309
+
310
+ expect(getContext).toHaveBeenCalledTimes(1)
311
+ expect(getContext).toHaveBeenCalledWith(
312
+ expect.objectContaining({ method: 'GET' }),
313
+ expect.objectContaining({ statusCode: 200 })
314
+ )
315
+ })
316
+
317
+ it('passes context to handler', async () => {
318
+ const { Create, getProcedures } = Procedures<
319
+ { user: string; permissions: string[] },
320
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
321
+ >()
322
+
323
+ Create('GetProfile', { method: 'get', path: '/profile' }, async (ctx) => ({
324
+ user: ctx.user,
325
+ permissions: ctx.permissions,
326
+ }))
327
+
328
+ registerExpressRoutes(
329
+ app,
330
+ {
331
+ getContext: async () => ({
332
+ user: 'alice',
333
+ permissions: ['read', 'write'],
334
+ }),
335
+ },
336
+ getProcedures()
337
+ )
338
+
339
+ const response = await supertest(app).get('/profile')
340
+ expect(response.body).toEqual({
341
+ user: 'alice',
342
+ permissions: ['read', 'write'],
343
+ })
344
+ })
345
+ })
346
+
347
+ describe('Validation Error Handling', () => {
348
+ it('returns 422 with default handler when validation fails', async () => {
349
+ const { Create, getProcedures } = Procedures<
350
+ { user: string },
351
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
352
+ >()
353
+
354
+ Create(
355
+ 'CreateItem',
356
+ {
357
+ method: 'post',
358
+ path: '/items',
359
+ schema: {
360
+ params: Type.Object({
361
+ name: Type.String({ minLength: 1 }),
362
+ quantity: Type.Number({ minimum: 1 }),
363
+ }),
364
+ },
365
+ },
366
+ async (_ctx, params) => ({ created: params })
367
+ )
368
+
369
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
370
+
371
+ const response = await supertest(app).post('/items').send({ name: '', quantity: 0 })
372
+
373
+ expect(response.status).toBe(422)
374
+ expect(response.body.error).toBe('Validation Error')
375
+ expect(response.body.details).toBeDefined()
376
+ expect(Array.isArray(response.body.details)).toBe(true)
377
+ })
378
+
379
+ it('calls custom onValidationError callback', async () => {
380
+ const onValidationError = vi.fn((errors, _req, res) => {
381
+ res.status(400).json({ custom: 'validation error', errors })
382
+ })
383
+
384
+ const { Create, getProcedures } = Procedures<
385
+ { user: string },
386
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
387
+ >()
388
+
389
+ Create(
390
+ 'ValidateItem',
391
+ {
392
+ method: 'post',
393
+ path: '/validate',
394
+ schema: {
395
+ params: Type.Object({
396
+ required: Type.String(),
397
+ }),
398
+ },
399
+ },
400
+ async () => ({ ok: true })
401
+ )
402
+
403
+ registerExpressRoutes(
404
+ app,
405
+ { getContext: async () => ({ user: 'test' }), onValidationError },
406
+ getProcedures()
407
+ )
408
+
409
+ const response = await supertest(app).post('/validate').send({})
410
+
411
+ expect(onValidationError).toHaveBeenCalledTimes(1)
412
+ expect(response.status).toBe(400)
413
+ expect(response.body.custom).toBe('validation error')
414
+ })
415
+
416
+ it('does not call handler when validation fails', async () => {
417
+ const handlerFn = vi.fn().mockResolvedValue({ ok: true })
418
+
419
+ const { Create, getProcedures } = Procedures<
420
+ { user: string },
421
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
422
+ >()
423
+
424
+ Create(
425
+ 'NoCall',
426
+ {
427
+ method: 'post',
428
+ path: '/nocall',
429
+ schema: {
430
+ params: Type.Object({
431
+ required: Type.String(),
432
+ }),
433
+ },
434
+ },
435
+ handlerFn
436
+ )
437
+
438
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
439
+
440
+ await supertest(app).post('/nocall').send({})
441
+
442
+ expect(handlerFn).not.toHaveBeenCalled()
443
+ })
444
+ })
445
+
446
+ describe('Handler Error Handling', () => {
447
+ it('returns 500 with default handler when handler throws', async () => {
448
+ const { Create, getProcedures } = Procedures<
449
+ { user: string },
450
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
451
+ >()
452
+
453
+ Create('ThrowError', { method: 'get', path: '/error' }, async () => {
454
+ throw new Error('Something went wrong')
455
+ })
456
+
457
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
458
+
459
+ const response = await supertest(app).get('/error')
460
+
461
+ expect(response.status).toBe(500)
462
+ expect(response.body.error).toContain('Something went wrong')
463
+ })
464
+
465
+ it('calls custom onHandlerError callback', async () => {
466
+ const onHandlerError = vi.fn((error, _req, res) => {
467
+ res.status(503).json({ custom: 'error handler', message: error.message })
468
+ })
469
+
470
+ const { Create, getProcedures } = Procedures<
471
+ { user: string },
472
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
473
+ >()
474
+
475
+ Create('CustomError', { method: 'get', path: '/custom-error' }, async () => {
476
+ throw new Error('Custom failure')
477
+ })
478
+
479
+ registerExpressRoutes(
480
+ app,
481
+ { getContext: async () => ({ user: 'test' }), onHandlerError },
482
+ getProcedures()
483
+ )
484
+
485
+ const response = await supertest(app).get('/custom-error')
486
+
487
+ expect(onHandlerError).toHaveBeenCalledTimes(1)
488
+ expect(response.status).toBe(503)
489
+ expect(response.body).toEqual({
490
+ custom: 'error handler',
491
+ message: 'Custom failure',
492
+ })
493
+ })
494
+ })
495
+
496
+ describe('Success Response', () => {
497
+ it('returns handler result as JSON', async () => {
498
+ const { Create, getProcedures } = Procedures<
499
+ { user: string },
500
+ { method: 'get' | 'post' | 'patch' | 'delete' | 'put'; path: string }
501
+ >()
502
+
503
+ Create('ComplexResult', { method: 'get', path: '/complex' }, async () => ({
504
+ nested: {
505
+ data: [1, 2, 3],
506
+ metadata: { count: 3 },
507
+ },
508
+ status: 'success',
509
+ }))
510
+
511
+ registerExpressRoutes(app, { getContext: async () => ({ user: 'test' }) }, getProcedures())
512
+
513
+ const response = await supertest(app).get('/complex')
514
+
515
+ expect(response.status).toBe(200)
516
+ expect(response.headers['content-type']).toMatch(/application\/json/)
517
+ expect(response.body).toEqual({
518
+ nested: {
519
+ data: [1, 2, 3],
520
+ metadata: { count: 3 },
521
+ },
522
+ status: 'success',
523
+ })
524
+ })
525
+ })
526
+ })
@@ -0,0 +1,108 @@
1
+ import express from 'express'
2
+ import { TProcedureRegistration } from '../../../index.js'
3
+
4
+ /**
5
+ * Maps path parameters from Express route to an object
6
+ *
7
+ * Path /users/:id with /users/123 => { id: '123' }
8
+ *
9
+ * @param path
10
+ * @param url
11
+ */
12
+ export function mapPathParamsToObject(path: string, url: string): Record<string, string> {
13
+ const urlObj = new URL(url, 'http://localhost') // Base URL is required but irrelevant here
14
+ const pathSegments = path.split('/').filter(Boolean)
15
+ const urlSegments = urlObj.pathname.split('/').filter(Boolean)
16
+
17
+ const params: Record<string, string> = {}
18
+ pathSegments.forEach((segment, index) => {
19
+ if (segment.startsWith(':')) {
20
+ const paramName = segment.slice(1)
21
+ params[paramName] = urlSegments[index] || ''
22
+ }
23
+ })
24
+
25
+ return params
26
+ }
27
+
28
+ /**
29
+ * Combines path, query, and body parameters from an Express request into a single object
30
+ * @param req
31
+ */
32
+ function getAllParamsFromExpressRequest(req: express.Request): Record<string, any> {
33
+ const pathParams = mapPathParamsToObject(req.route.path, req.url)
34
+ const queryParams = req.query || {}
35
+ const bodyParams = req.body || {}
36
+
37
+ return { ...pathParams, ...queryParams, ...bodyParams }
38
+ }
39
+
40
+ /**
41
+ * A convenience function to register multiple procedures as Express routes.
42
+ *
43
+ * Provide the Express app, a context generator function, and an array of procedure registrations.
44
+ *
45
+ * @param app
46
+ * @param callbacks
47
+ * @param procedures
48
+ */
49
+ export function registerExpressRoutes<ProceduresContext>(
50
+ app: express.Application,
51
+ callbacks: {
52
+ /** Get procedure factory context for handler */
53
+ getContext: (req: express.Request, res: express.Response) => Promise<ProceduresContext>
54
+ /** Optional error handler for procedure handler errors */
55
+ onHandlerError?: (error: Error, req: express.Request, res: express.Response) => void
56
+ /** Optional validation error handler */
57
+ onValidationError?: (
58
+ errors: Array<{ message: string; path: string[] }>,
59
+ req: express.Request,
60
+ res: express.Response
61
+ ) => void
62
+ },
63
+ // The procedure extended config must include method and path
64
+ procedures: Array<
65
+ TProcedureRegistration<
66
+ ProceduresContext,
67
+ {
68
+ method: 'get' | 'post' | 'patch' | 'delete' | 'put'
69
+ path: string
70
+ }
71
+ >
72
+ >
73
+ ): void {
74
+ procedures.forEach(({ handler, config }) => {
75
+ const { method, path } = config
76
+
77
+ app[method](path, async (req, res) => {
78
+ const context = await callbacks.getContext(req, res)
79
+
80
+ const allParams = getAllParamsFromExpressRequest(req)
81
+
82
+ if (config.validation?.params) {
83
+ const { errors } = config.validation.params(allParams)
84
+
85
+ if (errors && errors.length > 0) {
86
+ if (callbacks.onValidationError) {
87
+ callbacks.onValidationError(errors, req, res)
88
+ } else {
89
+ res.status(422).json({ error: 'Validation Error', details: errors })
90
+ }
91
+ return
92
+ }
93
+ }
94
+
95
+ try {
96
+ const result = await handler(context as any, allParams)
97
+
98
+ res.json(result)
99
+ } catch (error) {
100
+ if (callbacks.onHandlerError) {
101
+ callbacks.onHandlerError(error as Error, req, res)
102
+ return
103
+ }
104
+ res.status(500).json({ error: (error as Error).message })
105
+ }
106
+ })
107
+ })
108
+ }
package/src/index.test.ts CHANGED
@@ -310,8 +310,10 @@ describe('Procedures', () => {
310
310
  },
311
311
  )
312
312
 
313
- expect(getProcedures().get('test-docs')).toBeDefined()
314
- expect(getProcedures().get('test-docs')?.config?.schema).toEqual({
313
+ const procedures = getProcedures()
314
+ const testDocsProcedure = procedures.find(p => p.name === 'test-docs')
315
+ expect(testDocsProcedure).toBeDefined()
316
+ expect(testDocsProcedure?.config?.schema).toEqual({
315
317
  params: {
316
318
  type: 'object',
317
319
  properties: {
package/src/index.ts CHANGED
@@ -51,22 +51,7 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
51
51
  ) {
52
52
  const procedures: Map<
53
53
  string,
54
- {
55
- name: string
56
- config: Prettify<
57
- {
58
- description?: string
59
- schema?: {
60
- params?: TJSONSchema
61
- returnType?: TJSONSchema
62
- }
63
- validation?: {
64
- params?: (params: any) => { errors?: any[] }
65
- }
66
- } & TExtendedConfig
67
- >
68
- handler: (ctx: Prettify<TContext>, params: any) => Promise<any>
69
- }
54
+ TProcedureRegistration<TContext, TExtendedConfig>
70
55
  > = new Map()
71
56
 
72
57
  function Create<TName extends string, TParams, TReturnType>(
@@ -136,6 +121,10 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
136
121
  },
137
122
  }
138
123
 
124
+ if (procedures.has(name)) {
125
+ throw new Error(`Procedure with name ${name} is already registered`)
126
+ }
127
+
139
128
  procedures.set(name, registeredProcedure)
140
129
 
141
130
  if (builder?.onCreate) {
@@ -171,8 +160,11 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
171
160
  }
172
161
 
173
162
  return {
163
+ /**
164
+ * Get all registered procedures
165
+ */
174
166
  getProcedures: () => {
175
- return procedures
167
+ return Array.from(procedures.values())
176
168
  },
177
169
 
178
170
  Create,