ts-procedures 1.1.0 → 2.0.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.
- package/README.md +3 -3
- 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/index.d.ts +2 -1
- package/build/implementations/http/express/index.js.map +1 -1
- package/build/implementations/http/express/types.d.ts +17 -0
- package/build/implementations/http/express/types.js +2 -0
- package/build/implementations/http/express/types.js.map +1 -0
- package/build/implementations/http/express-rpc/index.d.ts +82 -0
- package/build/implementations/http/express-rpc/index.js +140 -0
- package/build/implementations/http/express-rpc/index.js.map +1 -0
- package/build/implementations/http/express-rpc/index.test.d.ts +1 -0
- package/build/implementations/http/express-rpc/index.test.js +445 -0
- package/build/implementations/http/express-rpc/index.test.js.map +1 -0
- package/build/implementations/http/express-rpc/types.d.ts +28 -0
- package/build/implementations/http/express-rpc/types.js +2 -0
- package/build/implementations/http/express-rpc/types.js.map +1 -0
- package/build/implementations/types.d.ts +17 -0
- package/build/implementations/types.js +2 -0
- package/build/implementations/types.js.map +1 -0
- package/build/schema/parser.js +2 -1
- package/build/schema/parser.js.map +1 -1
- package/package.json +13 -7
- package/src/implementations/http/express-rpc/README.md +321 -0
- package/src/implementations/http/express-rpc/index.test.ts +614 -0
- package/src/implementations/http/express-rpc/index.ts +180 -0
- package/src/implementations/http/express-rpc/types.ts +29 -0
- package/src/implementations/types.ts +20 -0
- package/src/schema/parser.ts +5 -4
- package/src/schema/types.ts +0 -1
- package/src/implementations/http/express/README.md +0 -351
- package/src/implementations/http/express/example/factories.ts +0 -25
- package/src/implementations/http/express/example/procedures/auth.ts +0 -24
- package/src/implementations/http/express/example/procedures/users.ts +0 -32
- package/src/implementations/http/express/example/server.test.ts +0 -133
- package/src/implementations/http/express/example/server.ts +0 -67
- package/src/implementations/http/express/index.test.ts +0 -526
- package/src/implementations/http/express/index.ts +0 -108
|
@@ -0,0 +1,614 @@
|
|
|
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 `/rpc/{name}/{version}` paths.
|
|
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
|
+
{ name: '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('/rpc/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
|
+
{ name: '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('/rpc/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
|
+
// Lifecycle Hooks Tests
|
|
81
|
+
// --------------------------------------------------------------------------
|
|
82
|
+
describe('lifecycle hooks', () => {
|
|
83
|
+
test('onRequestStart is called with request object', async () => {
|
|
84
|
+
const onRequestStart = vi.fn()
|
|
85
|
+
const builder = new ExpressRPCAppBuilder({ onRequestStart })
|
|
86
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
87
|
+
|
|
88
|
+
RPC.Create('Test', { name: 'test', version: 1 }, async () => ({ ok: true }))
|
|
89
|
+
|
|
90
|
+
builder.register(RPC, () => ({}))
|
|
91
|
+
const app = builder.build()
|
|
92
|
+
|
|
93
|
+
await request(app).post('/rpc/test/1').send({})
|
|
94
|
+
|
|
95
|
+
expect(onRequestStart).toHaveBeenCalledTimes(1)
|
|
96
|
+
expect(onRequestStart.mock.calls[0]![0]).toHaveProperty('method', 'POST')
|
|
97
|
+
expect(onRequestStart.mock.calls[0]![0]).toHaveProperty('path', '/rpc/test/1')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('onRequestEnd is called after response finishes', async () => {
|
|
101
|
+
const onRequestEnd = vi.fn()
|
|
102
|
+
const builder = new ExpressRPCAppBuilder({ onRequestEnd })
|
|
103
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
104
|
+
|
|
105
|
+
RPC.Create('Test', { name: 'test', version: 1 }, async () => ({ ok: true }))
|
|
106
|
+
|
|
107
|
+
builder.register(RPC, () => ({}))
|
|
108
|
+
const app = builder.build()
|
|
109
|
+
|
|
110
|
+
await request(app).post('/rpc/test/1').send({})
|
|
111
|
+
|
|
112
|
+
expect(onRequestEnd).toHaveBeenCalledTimes(1)
|
|
113
|
+
expect(onRequestEnd.mock.calls[0]![0]).toHaveProperty('method', 'POST')
|
|
114
|
+
expect(onRequestEnd.mock.calls[0]![1]).toHaveProperty('statusCode', 200)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('onSuccess is called on successful procedure execution', async () => {
|
|
118
|
+
const onSuccess = vi.fn()
|
|
119
|
+
const builder = new ExpressRPCAppBuilder({ onSuccess })
|
|
120
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
121
|
+
|
|
122
|
+
RPC.Create('Test', { name: 'test', version: 1 }, async () => ({ ok: true }))
|
|
123
|
+
|
|
124
|
+
builder.register(RPC, () => ({}))
|
|
125
|
+
const app = builder.build()
|
|
126
|
+
|
|
127
|
+
await request(app).post('/rpc/test/1').send({})
|
|
128
|
+
|
|
129
|
+
expect(onSuccess).toHaveBeenCalledTimes(1)
|
|
130
|
+
expect(onSuccess.mock.calls[0]![0]).toHaveProperty('name', 'Test')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('onSuccess is NOT called when procedure throws', async () => {
|
|
134
|
+
const onSuccess = vi.fn()
|
|
135
|
+
const builder = new ExpressRPCAppBuilder({ onSuccess })
|
|
136
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
137
|
+
|
|
138
|
+
RPC.Create('Test', { name: 'test', version: 1 }, async () => {
|
|
139
|
+
throw new Error('Handler error')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
builder.register(RPC, () => ({}))
|
|
143
|
+
const app = builder.build()
|
|
144
|
+
|
|
145
|
+
await request(app).post('/rpc/test/1').send({})
|
|
146
|
+
|
|
147
|
+
expect(onSuccess).not.toHaveBeenCalled()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('hooks execute in correct order: start → handler → success → end', async () => {
|
|
151
|
+
const order: string[] = []
|
|
152
|
+
|
|
153
|
+
const builder = new ExpressRPCAppBuilder({
|
|
154
|
+
onRequestStart: () => order.push('start'),
|
|
155
|
+
onRequestEnd: () => order.push('end'),
|
|
156
|
+
onSuccess: () => order.push('success'),
|
|
157
|
+
})
|
|
158
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
159
|
+
|
|
160
|
+
RPC.Create('Test', { name: 'test', version: 1 }, async () => {
|
|
161
|
+
order.push('handler')
|
|
162
|
+
return { ok: true }
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
builder.register(RPC, () => ({}))
|
|
166
|
+
const app = builder.build()
|
|
167
|
+
|
|
168
|
+
await request(app).post('/rpc/test/1').send({})
|
|
169
|
+
|
|
170
|
+
expect(order).toEqual(['start', 'handler', 'success', 'end'])
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// --------------------------------------------------------------------------
|
|
175
|
+
// Error Handling Tests
|
|
176
|
+
// --------------------------------------------------------------------------
|
|
177
|
+
describe('error handling', () => {
|
|
178
|
+
test('custom error handler receives procedure, req, res, and error', async () => {
|
|
179
|
+
const errorHandler = vi.fn((procedure, req, res, error) => {
|
|
180
|
+
res.status(400).json({ customError: error.message })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const builder = new ExpressRPCAppBuilder({ error: errorHandler })
|
|
184
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
185
|
+
|
|
186
|
+
RPC.Create('Test', { name: 'test', version: 1 }, async () => {
|
|
187
|
+
throw new Error('Test error')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
builder.register(RPC, () => ({}))
|
|
191
|
+
const app = builder.build()
|
|
192
|
+
|
|
193
|
+
const res = await request(app).post('/rpc/test/1').send({})
|
|
194
|
+
|
|
195
|
+
expect(errorHandler).toHaveBeenCalledTimes(1)
|
|
196
|
+
expect(errorHandler.mock.calls[0]![0]).toHaveProperty('name', 'Test')
|
|
197
|
+
expect(errorHandler.mock.calls[0]![3]).toBeInstanceOf(Error)
|
|
198
|
+
expect(res.status).toBe(400)
|
|
199
|
+
// Error is wrapped by Procedures with "Error in handler for {name}" prefix
|
|
200
|
+
expect(res.body.customError).toContain('Test error')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('default error handling returns error message in response', async () => {
|
|
204
|
+
const builder = new ExpressRPCAppBuilder()
|
|
205
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
206
|
+
|
|
207
|
+
RPC.Create('Test', { name: 'test', version: 1 }, async () => {
|
|
208
|
+
throw new Error('Something went wrong')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
builder.register(RPC, () => ({}))
|
|
212
|
+
const app = builder.build()
|
|
213
|
+
|
|
214
|
+
const res = await request(app).post('/rpc/test/1').send({})
|
|
215
|
+
|
|
216
|
+
// Default error handler returns error message in JSON body
|
|
217
|
+
expect(res.body).toHaveProperty('error')
|
|
218
|
+
expect(res.body.error).toContain('Something went wrong')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('catches unhandled exceptions in handler', async () => {
|
|
222
|
+
const builder = new ExpressRPCAppBuilder()
|
|
223
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
224
|
+
|
|
225
|
+
RPC.Create('Test', { name: 'test', version: 1 }, async () => {
|
|
226
|
+
// Simulate unhandled exception
|
|
227
|
+
const obj: any = null
|
|
228
|
+
return obj.property // This will throw
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
builder.register(RPC, () => ({}))
|
|
232
|
+
const app = builder.build()
|
|
233
|
+
|
|
234
|
+
const res = await request(app).post('/rpc/test/1').send({})
|
|
235
|
+
|
|
236
|
+
// Unhandled exceptions are caught and returned as error response
|
|
237
|
+
expect(res.body).toHaveProperty('error')
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// --------------------------------------------------------------------------
|
|
242
|
+
// register() Method Tests
|
|
243
|
+
// --------------------------------------------------------------------------
|
|
244
|
+
describe('register() method', () => {
|
|
245
|
+
test('returns this for method chaining', () => {
|
|
246
|
+
const builder = new ExpressRPCAppBuilder()
|
|
247
|
+
const RPC1 = Procedures<{}, RPCConfig>()
|
|
248
|
+
const RPC2 = Procedures<{}, RPCConfig>()
|
|
249
|
+
|
|
250
|
+
const result = builder.register(RPC1, () => ({}))
|
|
251
|
+
expect(result).toBe(builder)
|
|
252
|
+
|
|
253
|
+
// Chain multiple registrations
|
|
254
|
+
const chainResult = builder.register(RPC1, () => ({})).register(RPC2, () => ({}))
|
|
255
|
+
|
|
256
|
+
expect(chainResult).toBe(builder)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('supports registering multiple factories', async () => {
|
|
260
|
+
const builder = new ExpressRPCAppBuilder()
|
|
261
|
+
|
|
262
|
+
const PublicRPC = Procedures<{ public: true }, RPCConfig>()
|
|
263
|
+
const PrivateRPC = Procedures<{ private: true }, RPCConfig>()
|
|
264
|
+
|
|
265
|
+
PublicRPC.Create('PublicMethod', { name: 'public', version: 1 }, async (ctx) => ({
|
|
266
|
+
isPublic: ctx.public,
|
|
267
|
+
}))
|
|
268
|
+
|
|
269
|
+
PrivateRPC.Create('PrivateMethod', { name: 'private', version: 1 }, async (ctx) => ({
|
|
270
|
+
isPrivate: ctx.private,
|
|
271
|
+
}))
|
|
272
|
+
|
|
273
|
+
builder
|
|
274
|
+
.register(PublicRPC, () => ({ public: true as const }))
|
|
275
|
+
.register(PrivateRPC, () => ({ private: true as const }))
|
|
276
|
+
|
|
277
|
+
const app = builder.build()
|
|
278
|
+
|
|
279
|
+
const publicRes = await request(app).post('/rpc/public/1').send({})
|
|
280
|
+
const privateRes = await request(app).post('/rpc/private/1').send({})
|
|
281
|
+
|
|
282
|
+
expect(publicRes.body).toEqual({ isPublic: true })
|
|
283
|
+
expect(privateRes.body).toEqual({ isPrivate: true })
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('context resolver receives Express request object', async () => {
|
|
287
|
+
const contextResolver = vi.fn((req) => ({
|
|
288
|
+
authHeader: req.headers.authorization,
|
|
289
|
+
}))
|
|
290
|
+
|
|
291
|
+
const builder = new ExpressRPCAppBuilder()
|
|
292
|
+
const RPC = Procedures<{ authHeader?: string }, RPCConfig>()
|
|
293
|
+
|
|
294
|
+
RPC.Create('GetAuth', { name: 'get-auth', version: 1 }, async (ctx) => ({
|
|
295
|
+
auth: ctx.authHeader,
|
|
296
|
+
}))
|
|
297
|
+
|
|
298
|
+
builder.register(RPC, contextResolver)
|
|
299
|
+
const app = builder.build()
|
|
300
|
+
|
|
301
|
+
await request(app).post('/rpc/get-auth/1').set('Authorization', 'Bearer token123').send({})
|
|
302
|
+
|
|
303
|
+
expect(contextResolver).toHaveBeenCalledTimes(1)
|
|
304
|
+
expect(contextResolver.mock.calls[0]![0]).toHaveProperty('headers')
|
|
305
|
+
expect(contextResolver.mock.calls[0]![0].headers).toHaveProperty(
|
|
306
|
+
'authorization',
|
|
307
|
+
'Bearer token123'
|
|
308
|
+
)
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// --------------------------------------------------------------------------
|
|
313
|
+
// build() Method Tests
|
|
314
|
+
// --------------------------------------------------------------------------
|
|
315
|
+
describe('build() method', () => {
|
|
316
|
+
test('creates POST routes for all procedures', async () => {
|
|
317
|
+
const builder = new ExpressRPCAppBuilder()
|
|
318
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
319
|
+
|
|
320
|
+
RPC.Create('MethodOne', { name: 'method-one', version: 1 }, async () => ({ m: 1 }))
|
|
321
|
+
RPC.Create('MethodTwo', { name: 'method-two', version: 2 }, async () => ({ m: 2 }))
|
|
322
|
+
|
|
323
|
+
builder.register(RPC, () => ({}))
|
|
324
|
+
const app = builder.build()
|
|
325
|
+
|
|
326
|
+
const res1 = await request(app).post('/rpc/method-one/1').send({})
|
|
327
|
+
const res2 = await request(app).post('/rpc/method-two/2').send({})
|
|
328
|
+
|
|
329
|
+
expect(res1.status).toBe(200)
|
|
330
|
+
expect(res2.status).toBe(200)
|
|
331
|
+
expect(res1.body).toEqual({ m: 1 })
|
|
332
|
+
expect(res2.body).toEqual({ m: 2 })
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('returns the Express application', () => {
|
|
336
|
+
const builder = new ExpressRPCAppBuilder()
|
|
337
|
+
const app = builder.build()
|
|
338
|
+
|
|
339
|
+
expect(app).toBe(builder.app)
|
|
340
|
+
expect(typeof app.listen).toBe('function')
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('populates docs array after build', () => {
|
|
344
|
+
const builder = new ExpressRPCAppBuilder()
|
|
345
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
346
|
+
|
|
347
|
+
RPC.Create('MethodOne', { name: 'method-one', version: 1 }, async () => ({}))
|
|
348
|
+
RPC.Create('MethodTwo', { name: ['nested', 'method'], version: 2 }, async () => ({}))
|
|
349
|
+
|
|
350
|
+
expect(builder.docs).toHaveLength(0)
|
|
351
|
+
|
|
352
|
+
builder.register(RPC, () => ({}))
|
|
353
|
+
builder.build()
|
|
354
|
+
|
|
355
|
+
expect(builder.docs).toHaveLength(2)
|
|
356
|
+
expect(builder.docs[0]!.path).toBe('/rpc/method-one/1')
|
|
357
|
+
expect(builder.docs[1]!.path).toBe('/rpc/nested/method/2')
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('passes request body to handler as params', async () => {
|
|
361
|
+
const builder = new ExpressRPCAppBuilder()
|
|
362
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
363
|
+
|
|
364
|
+
RPC.Create(
|
|
365
|
+
'Echo',
|
|
366
|
+
{ name: 'echo', version: 1, schema: { params: v.object({ data: v.string() }) } },
|
|
367
|
+
async (ctx, params) => ({ received: params.data })
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
builder.register(RPC, () => ({}))
|
|
371
|
+
const app = builder.build()
|
|
372
|
+
|
|
373
|
+
const res = await request(app).post('/rpc/echo/1').send({ data: 'test-data' })
|
|
374
|
+
|
|
375
|
+
expect(res.body).toEqual({ received: 'test-data' })
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
test('GET requests return 404 (RPC uses POST only)', async () => {
|
|
379
|
+
const builder = new ExpressRPCAppBuilder()
|
|
380
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
381
|
+
|
|
382
|
+
RPC.Create('Test', { name: 'test', version: 1 }, async () => ({ ok: true }))
|
|
383
|
+
|
|
384
|
+
builder.register(RPC, () => ({}))
|
|
385
|
+
const app = builder.build()
|
|
386
|
+
|
|
387
|
+
const res = await request(app).get('/rpc/test/1')
|
|
388
|
+
|
|
389
|
+
expect(res.status).toBe(404)
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// --------------------------------------------------------------------------
|
|
394
|
+
// Path Generation Tests (makeRPCHttpRoutePath)
|
|
395
|
+
// --------------------------------------------------------------------------
|
|
396
|
+
describe('makeRPCHttpRoutePath', () => {
|
|
397
|
+
let builder: ExpressRPCAppBuilder
|
|
398
|
+
|
|
399
|
+
beforeEach(() => {
|
|
400
|
+
builder = new ExpressRPCAppBuilder()
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
test("simple string: 'users' → /rpc/users/1", () => {
|
|
404
|
+
const path = builder.makeRPCHttpRoutePath({ name: 'users', version: 1 })
|
|
405
|
+
expect(path).toBe('/rpc/users/1')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
test("array name: ['users', 'get-by-id'] → /rpc/users/get-by-id/1", () => {
|
|
409
|
+
const path = builder.makeRPCHttpRoutePath({ name: ['users', 'get-by-id'], version: 1 })
|
|
410
|
+
expect(path).toBe('/rpc/users/get-by-id/1')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test("camelCase: 'getUserById' → /rpc/get-user-by-id/1", () => {
|
|
414
|
+
const path = builder.makeRPCHttpRoutePath({ name: 'getUserById', version: 1 })
|
|
415
|
+
expect(path).toBe('/rpc/get-user-by-id/1')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test("PascalCase: 'GetUserById' → /rpc/get-user-by-id/1", () => {
|
|
419
|
+
const path = builder.makeRPCHttpRoutePath({ name: 'GetUserById', version: 1 })
|
|
420
|
+
expect(path).toBe('/rpc/get-user-by-id/1')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('version number included in path', () => {
|
|
424
|
+
const pathV1 = builder.makeRPCHttpRoutePath({ name: 'test', version: 1 })
|
|
425
|
+
const pathV2 = builder.makeRPCHttpRoutePath({ name: 'test', version: 2 })
|
|
426
|
+
const pathV99 = builder.makeRPCHttpRoutePath({ name: 'test', version: 99 })
|
|
427
|
+
|
|
428
|
+
expect(pathV1).toBe('/rpc/test/1')
|
|
429
|
+
expect(pathV2).toBe('/rpc/test/2')
|
|
430
|
+
expect(pathV99).toBe('/rpc/test/99')
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('handles mixed case in array segments', () => {
|
|
434
|
+
const path = builder.makeRPCHttpRoutePath({
|
|
435
|
+
name: ['UserModule', 'getActiveUsers'],
|
|
436
|
+
version: 1,
|
|
437
|
+
})
|
|
438
|
+
expect(path).toBe('/rpc/user-module/get-active-users/1')
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
// --------------------------------------------------------------------------
|
|
443
|
+
// Route Documentation Tests (buildRpcHttpRouteDoc)
|
|
444
|
+
// --------------------------------------------------------------------------
|
|
445
|
+
describe('buildRpcHttpRouteDoc', () => {
|
|
446
|
+
let builder: ExpressRPCAppBuilder
|
|
447
|
+
|
|
448
|
+
beforeEach(() => {
|
|
449
|
+
builder = new ExpressRPCAppBuilder()
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('generates complete route documentation', () => {
|
|
453
|
+
const paramsSchema = v.object({ id: v.string() })
|
|
454
|
+
const returnSchema = v.object({ name: v.string() })
|
|
455
|
+
|
|
456
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
457
|
+
const { info } = RPC.Create(
|
|
458
|
+
'GetUser',
|
|
459
|
+
{ name: 'users', version: 1, schema: { params: paramsSchema, returnType: returnSchema } },
|
|
460
|
+
async () => ({ name: 'test' })
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
const procedure = RPC.getProcedures()[0]!
|
|
464
|
+
const doc = builder.buildRpcHttpRouteDoc(procedure)
|
|
465
|
+
|
|
466
|
+
expect(doc.path).toBe('/rpc/users/1')
|
|
467
|
+
expect(doc.method).toBe('post')
|
|
468
|
+
expect(doc.jsonSchema.body).toBeDefined()
|
|
469
|
+
expect(doc.jsonSchema.response).toBeDefined()
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('omits body schema when no params defined', () => {
|
|
473
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
474
|
+
RPC.Create('NoParams', { name: 'no-params', version: 1 }, async () => ({ ok: true }))
|
|
475
|
+
|
|
476
|
+
const procedure = RPC.getProcedures()[0]!
|
|
477
|
+
const doc = builder.buildRpcHttpRouteDoc(procedure)
|
|
478
|
+
|
|
479
|
+
expect(doc.jsonSchema.body).toBeUndefined()
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test('omits response schema when no returnType defined', () => {
|
|
483
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
484
|
+
RPC.Create(
|
|
485
|
+
'NoReturn',
|
|
486
|
+
{ name: 'no-return', version: 1, schema: { params: v.object({ x: v.number() }) } },
|
|
487
|
+
async () => ({})
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
const procedure = RPC.getProcedures()[0]!
|
|
491
|
+
const doc = builder.buildRpcHttpRouteDoc(procedure)
|
|
492
|
+
|
|
493
|
+
expect(doc.jsonSchema.body).toBeDefined()
|
|
494
|
+
expect(doc.jsonSchema.response).toBeUndefined()
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test("method is always 'post'", () => {
|
|
498
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
499
|
+
RPC.Create('Test1', { name: 't1', version: 1 }, async () => ({}))
|
|
500
|
+
RPC.Create('Test2', { name: 't2', version: 2 }, async () => ({}))
|
|
501
|
+
|
|
502
|
+
const procedures = RPC.getProcedures()
|
|
503
|
+
|
|
504
|
+
procedures.forEach((proc) => {
|
|
505
|
+
const doc = builder.buildRpcHttpRouteDoc(proc)
|
|
506
|
+
expect(doc.method).toBe('post')
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
// --------------------------------------------------------------------------
|
|
512
|
+
// Integration Test
|
|
513
|
+
// --------------------------------------------------------------------------
|
|
514
|
+
describe('integration', () => {
|
|
515
|
+
test('full workflow with multiple procedure factories and different contexts', async () => {
|
|
516
|
+
// Define context types
|
|
517
|
+
type PublicContext = { source: 'public' }
|
|
518
|
+
type AuthContext = { source: 'auth'; userId: string }
|
|
519
|
+
|
|
520
|
+
// Create factories
|
|
521
|
+
const PublicRPC = Procedures<PublicContext, RPCConfig>()
|
|
522
|
+
const AuthRPC = Procedures<AuthContext, RPCConfig>()
|
|
523
|
+
|
|
524
|
+
// Create public procedures
|
|
525
|
+
PublicRPC.Create('GetVersion', { name: ['system', 'version'], version: 1 }, async () => ({
|
|
526
|
+
version: '1.0.0',
|
|
527
|
+
}))
|
|
528
|
+
|
|
529
|
+
PublicRPC.Create('HealthCheck', { name: 'health', version: 1 }, async () => ({
|
|
530
|
+
status: 'ok',
|
|
531
|
+
}))
|
|
532
|
+
|
|
533
|
+
// Create authenticated procedures
|
|
534
|
+
AuthRPC.Create(
|
|
535
|
+
'GetProfile',
|
|
536
|
+
{
|
|
537
|
+
name: ['users', 'profile'],
|
|
538
|
+
version: 1,
|
|
539
|
+
schema: { returnType: v.object({ userId: v.string(), source: v.string() }) },
|
|
540
|
+
},
|
|
541
|
+
async (ctx) => ({ userId: ctx.userId, source: ctx.source })
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
AuthRPC.Create(
|
|
545
|
+
'UpdateProfile',
|
|
546
|
+
{
|
|
547
|
+
name: ['users', 'profile'],
|
|
548
|
+
version: 2,
|
|
549
|
+
schema: { params: v.object({ name: v.string() }) },
|
|
550
|
+
},
|
|
551
|
+
async (ctx, params) => ({ userId: ctx.userId, name: params.name })
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
// Build app with lifecycle hooks
|
|
555
|
+
const events: string[] = []
|
|
556
|
+
|
|
557
|
+
const builder = new ExpressRPCAppBuilder({
|
|
558
|
+
onRequestStart: () => events.push('request-start'),
|
|
559
|
+
onRequestEnd: () => events.push('request-end'),
|
|
560
|
+
onSuccess: (proc) => events.push(`success:${proc.name}`),
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
builder
|
|
564
|
+
.register(PublicRPC, () => ({ source: 'public' as const }))
|
|
565
|
+
.register(AuthRPC, (req) => ({
|
|
566
|
+
source: 'auth' as const,
|
|
567
|
+
userId: (req.headers['x-user-id'] as string) || 'anonymous',
|
|
568
|
+
}))
|
|
569
|
+
|
|
570
|
+
const app = builder.build()
|
|
571
|
+
|
|
572
|
+
// Test public endpoints
|
|
573
|
+
const versionRes = await request(app).post('/rpc/system/version/1').send({})
|
|
574
|
+
expect(versionRes.status).toBe(200)
|
|
575
|
+
expect(versionRes.body).toEqual({ version: '1.0.0' })
|
|
576
|
+
|
|
577
|
+
const healthRes = await request(app).post('/rpc/health/1').send({})
|
|
578
|
+
expect(healthRes.status).toBe(200)
|
|
579
|
+
expect(healthRes.body).toEqual({ status: 'ok' })
|
|
580
|
+
|
|
581
|
+
// Test authenticated endpoints
|
|
582
|
+
const profileRes = await request(app)
|
|
583
|
+
.post('/rpc/users/profile/1')
|
|
584
|
+
.set('X-User-Id', 'user-123')
|
|
585
|
+
.send({})
|
|
586
|
+
expect(profileRes.status).toBe(200)
|
|
587
|
+
expect(profileRes.body).toEqual({ userId: 'user-123', source: 'auth' })
|
|
588
|
+
|
|
589
|
+
const updateRes = await request(app)
|
|
590
|
+
.post('/rpc/users/profile/2')
|
|
591
|
+
.set('X-User-Id', 'user-456')
|
|
592
|
+
.send({ name: 'John Doe' })
|
|
593
|
+
expect(updateRes.status).toBe(200)
|
|
594
|
+
expect(updateRes.body).toEqual({ userId: 'user-456', name: 'John Doe' })
|
|
595
|
+
|
|
596
|
+
// Verify documentation
|
|
597
|
+
expect(builder.docs).toHaveLength(4)
|
|
598
|
+
|
|
599
|
+
const paths = builder.docs.map((d) => d.path)
|
|
600
|
+
expect(paths).toContain('/rpc/system/version/1')
|
|
601
|
+
expect(paths).toContain('/rpc/health/1')
|
|
602
|
+
expect(paths).toContain('/rpc/users/profile/1')
|
|
603
|
+
expect(paths).toContain('/rpc/users/profile/2')
|
|
604
|
+
|
|
605
|
+
// Verify hooks were called
|
|
606
|
+
expect(events).toContain('request-start')
|
|
607
|
+
expect(events).toContain('success:GetVersion')
|
|
608
|
+
expect(events).toContain('success:HealthCheck')
|
|
609
|
+
expect(events).toContain('success:GetProfile')
|
|
610
|
+
expect(events).toContain('success:UpdateProfile')
|
|
611
|
+
expect(events).toContain('request-end')
|
|
612
|
+
})
|
|
613
|
+
})
|
|
614
|
+
})
|