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