ts-procedures 1.0.0 → 1.1.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.
- package/README.md +13 -3
- package/build/errors.d.ts +16 -0
- package/build/errors.js +37 -0
- package/build/errors.js.map +1 -0
- package/build/exports.d.ts +6 -0
- package/build/exports.js +7 -0
- package/build/exports.js.map +1 -0
- package/build/implementations/http/client/index.d.ts +1 -0
- package/build/implementations/http/client/index.js +2 -0
- package/build/implementations/http/client/index.js.map +1 -0
- package/build/implementations/http/express/example/factories.d.ts +97 -0
- package/build/implementations/http/express/example/factories.js +4 -0
- package/build/implementations/http/express/example/factories.js.map +1 -0
- package/build/implementations/http/express/example/procedures/auth.d.ts +1 -0
- package/build/implementations/http/express/example/procedures/auth.js +22 -0
- package/build/implementations/http/express/example/procedures/auth.js.map +1 -0
- package/build/implementations/http/express/example/procedures/users.d.ts +1 -0
- package/build/implementations/http/express/example/procedures/users.js +30 -0
- package/build/implementations/http/express/example/procedures/users.js.map +1 -0
- package/build/implementations/http/express/example/server.d.ts +3 -0
- package/build/implementations/http/express/example/server.js +49 -0
- package/build/implementations/http/express/example/server.js.map +1 -0
- package/build/implementations/http/express/example/server.test.d.ts +1 -0
- package/build/implementations/http/express/example/server.test.js +110 -0
- package/build/implementations/http/express/example/server.test.js.map +1 -0
- package/build/implementations/http/express/index.d.ts +34 -0
- package/build/implementations/http/express/index.js +75 -0
- package/build/implementations/http/express/index.js.map +1 -0
- package/build/implementations/http/express/index.test.d.ts +1 -0
- package/build/implementations/http/express/index.test.js +329 -0
- package/build/implementations/http/express/index.test.js.map +1 -0
- package/build/index.d.ts +71 -0
- package/build/index.js +80 -0
- package/build/index.js.map +1 -0
- package/build/index.test.d.ts +1 -0
- package/build/index.test.js +249 -0
- package/build/index.test.js.map +1 -0
- package/build/schema/compute-schema.d.ts +23 -0
- package/build/schema/compute-schema.js +28 -0
- package/build/schema/compute-schema.js.map +1 -0
- package/build/schema/compute-schema.test.d.ts +1 -0
- package/build/schema/compute-schema.test.js +107 -0
- package/build/schema/compute-schema.test.js.map +1 -0
- package/build/schema/extract-json-schema.d.ts +2 -0
- package/build/schema/extract-json-schema.js +12 -0
- package/build/schema/extract-json-schema.js.map +1 -0
- package/build/schema/extract-json-schema.test.d.ts +1 -0
- package/build/schema/extract-json-schema.test.js +23 -0
- package/build/schema/extract-json-schema.test.js.map +1 -0
- package/build/schema/parser.d.ts +21 -0
- package/build/schema/parser.js +71 -0
- package/build/schema/parser.js.map +1 -0
- package/build/schema/parser.test.d.ts +1 -0
- package/build/schema/parser.test.js +102 -0
- package/build/schema/parser.test.js.map +1 -0
- package/build/schema/resolve-schema-lib.d.ts +12 -0
- package/build/schema/resolve-schema-lib.js +11 -0
- package/build/schema/resolve-schema-lib.js.map +1 -0
- package/build/schema/resolve-schema-lib.test.d.ts +1 -0
- package/build/schema/resolve-schema-lib.test.js +17 -0
- package/build/schema/resolve-schema-lib.test.js.map +1 -0
- package/build/schema/types.d.ts +7 -0
- package/build/schema/types.js +2 -0
- package/build/schema/types.js.map +1 -0
- package/package.json +22 -9
- package/src/implementations/http/client/index.ts +0 -0
- package/src/implementations/http/express/README.md +351 -0
- package/src/implementations/http/express/example/factories.ts +25 -0
- package/src/implementations/http/express/example/procedures/auth.ts +24 -0
- package/src/implementations/http/express/example/procedures/users.ts +32 -0
- package/src/implementations/http/express/example/server.test.ts +133 -0
- package/src/implementations/http/express/example/server.ts +67 -0
- package/src/implementations/http/express/index.test.ts +526 -0
- package/src/implementations/http/express/index.ts +108 -0
- package/src/index.test.ts +4 -2
- package/src/index.ts +9 -17
- package/src/schema/parser.ts +5 -4
- package/src/schema/types.ts +0 -1
|
@@ -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
|
-
|
|
314
|
-
|
|
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,
|