ts-procedures 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,15 @@
1
1
  import express from 'express'
2
2
  import { kebabCase } from 'es-toolkit/string'
3
3
  import { Procedures, TProcedureRegistration } from '../../../index.js'
4
- import { RPCConfig, RPCHttpRouteDoc } from '../../types.js'
4
+ import {
5
+ ExtractConfig,
6
+ ExtractContext,
7
+ ProceduresFactory,
8
+ RPCConfig,
9
+ RPCHttpRouteDoc,
10
+ } from '../../types.js'
5
11
  import { castArray } from 'es-toolkit/compat'
6
- import { ExpressFactoryItem, ExtractContext, ProceduresFactory } from './types.js'
12
+ import { ExpressFactoryItem } from './types.js'
7
13
 
8
14
  export type { RPCConfig, RPCHttpRouteDoc }
9
15
 
@@ -122,7 +128,7 @@ export class ExpressRPCAppBuilder {
122
128
  private factories: ExpressFactoryItem<any>[] = []
123
129
 
124
130
  private _app: express.Express = express()
125
- private _docs: RPCHttpRouteDoc[] = []
131
+ private _docs: (RPCHttpRouteDoc & object)[] = []
126
132
 
127
133
  get app(): express.Express {
128
134
  return this._app
@@ -137,14 +143,21 @@ export class ExpressRPCAppBuilder {
137
143
  * @param factory - The procedure factory created by Procedures<Context, RPCConfig>()
138
144
  * @param factoryContext - The context for procedure handlers. Can be a direct value,
139
145
  * a sync function (req) => Context, or an async function (req) => Promise<Context>
146
+ * @param extendProcedureDoc - A custom function to extend the generated RPC route documentation for each procedure.
140
147
  */
141
148
  register<TFactory extends ProceduresFactory>(
142
149
  factory: TFactory,
143
150
  factoryContext:
144
151
  | ExtractContext<TFactory>
145
- | ((req: express.Request) => ExtractContext<TFactory> | Promise<ExtractContext<TFactory>>)
152
+ | ((req: express.Request) => ExtractContext<TFactory> | Promise<ExtractContext<TFactory>>),
153
+ extendProcedureDoc?: (params: {
154
+ /* RPC App builder base http route doc */
155
+ base: RPCHttpRouteDoc
156
+ /* Procedure registration */
157
+ procedure: TProcedureRegistration<any, ExtractConfig<TFactory>>
158
+ }) => Record<string, any>
146
159
  ): this {
147
- this.factories.push({ factory, factoryContext } as ExpressFactoryItem<any>)
160
+ this.factories.push({ factory, factoryContext, extendProcedureDoc } as ExpressFactoryItem<any>)
148
161
  return this
149
162
  }
150
163
 
@@ -153,9 +166,9 @@ export class ExpressRPCAppBuilder {
153
166
  * @return express.Application
154
167
  */
155
168
  build(): express.Application {
156
- this.factories.forEach(({ factory, factoryContext }) => {
169
+ this.factories.forEach(({ factory, factoryContext, extendProcedureDoc }) => {
157
170
  factory.getProcedures().map((procedure: TProcedureRegistration<any, RPCConfig>) => {
158
- const route = this.buildRpcHttpRouteDoc(procedure)
171
+ const route = this.buildRpcHttpRouteDoc(procedure, extendProcedureDoc)
159
172
 
160
173
  this._docs.push(route)
161
174
 
@@ -198,14 +211,17 @@ export class ExpressRPCAppBuilder {
198
211
  * Generates the RPC HTTP route for the given procedure.
199
212
  * @param procedure
200
213
  */
201
- private buildRpcHttpRouteDoc(procedure: TProcedureRegistration<any, RPCConfig>): RPCHttpRouteDoc {
214
+ private buildRpcHttpRouteDoc(
215
+ procedure: TProcedureRegistration<any, RPCConfig>,
216
+ extendProcedureDoc: ExpressFactoryItem['extendProcedureDoc']
217
+ ): RPCHttpRouteDoc {
202
218
  const { config } = procedure
203
219
  const path = ExpressRPCAppBuilder.makeRPCHttpRoutePath({
204
220
  name: procedure.name,
205
221
  config,
206
222
  prefix: this.config?.pathPrefix,
207
223
  })
208
- const method = 'post' // RPCs use POST method
224
+ const method = 'post' as const // RPCs use POST method
209
225
  const jsonSchema: { body?: object; response?: object } = {}
210
226
 
211
227
  if (config.schema?.params) {
@@ -215,10 +231,23 @@ export class ExpressRPCAppBuilder {
215
231
  jsonSchema.response = config.schema.returnType
216
232
  }
217
233
 
218
- return {
234
+ const base = {
235
+ name: procedure.name,
236
+ version: config.version,
237
+ scope: config.scope,
219
238
  path,
220
239
  method,
221
240
  jsonSchema,
222
241
  }
242
+ let extendedDoc: object = {}
243
+
244
+ if (extendProcedureDoc) {
245
+ extendedDoc = extendProcedureDoc({ base, procedure })
246
+ }
247
+
248
+ return {
249
+ ...extendedDoc,
250
+ ...base,
251
+ }
223
252
  }
224
253
  }
@@ -1,33 +1,16 @@
1
- import { RPCConfig } from '../../types.js'
2
- import { Procedures } from '../../../index.js'
1
+ import { ExtractConfig, ExtractContext, RPCConfig, RPCHttpRouteDoc } from '../../types.js'
2
+ import { Procedures, TProcedureRegistration } from '../../../index.js'
3
3
  import express from 'express'
4
4
 
5
- /**
6
- * Extracts the TContext type from a Procedures factory return type.
7
- * Uses the first parameter of the handler function to infer the context type.
8
- */
9
- export type ExtractContext<TFactory> = TFactory extends {
10
- getProcedures: () => Array<{ handler: (ctx: infer TContext, ...args: any[]) => any }>
11
- }
12
- ? TContext
13
- : never
14
-
15
- /**
16
- * Minimal structural type for a Procedures factory.
17
- * Uses explicit `any` types to avoid variance issues with generic constraints.
18
- */
19
- export type ProceduresFactory = {
20
- getProcedures: () => Array<{
21
- name: string
22
- config: any
23
- handler: (ctx: any, params?: any) => Promise<any>
24
- }>
25
- Create: (...args: any[]) => any
26
- }
27
-
28
5
  export type ExpressFactoryItem<TFactory = ReturnType<typeof Procedures<any, RPCConfig>>> = {
29
6
  factory: TFactory
30
7
  factoryContext:
31
8
  | ExtractContext<TFactory>
32
9
  | ((req: express.Request) => ExtractContext<TFactory> | Promise<ExtractContext<TFactory>>)
10
+ extendProcedureDoc?: (params: {
11
+ /* RPC App builder base http route doc */
12
+ base: RPCHttpRouteDoc
13
+ /* Procedure registration */
14
+ procedure: TProcedureRegistration<any, ExtractConfig<TFactory>>
15
+ }) => Record<string, any>
33
16
  }
@@ -30,8 +30,8 @@ RPC.Create(
30
30
  version: 1,
31
31
  schema: {
32
32
  params: v.object({ id: v.string() }),
33
- returnType: v.object({ id: v.string(), name: v.string() })
34
- }
33
+ returnType: v.object({ id: v.string(), name: v.string() }),
34
+ },
35
35
  },
36
36
  async (ctx, params) => {
37
37
  return { id: params.id, name: 'John Doe' }
@@ -39,10 +39,9 @@ RPC.Create(
39
39
  )
40
40
 
41
41
  // Build the Hono app
42
- const builder = new HonoRPCAppBuilder({ pathPrefix: '/rpc' })
43
- .register(RPC, (c) => ({
44
- userId: c.req.header('x-user-id') || 'anonymous'
45
- }))
42
+ const builder = new HonoRPCAppBuilder({ pathPrefix: '/rpc' }).register(RPC, (c) => ({
43
+ userId: c.req.header('x-user-id') || 'anonymous',
44
+ }))
46
45
 
47
46
  const app = builder.build()
48
47
 
@@ -60,23 +59,27 @@ export default app
60
59
 
61
60
  ```typescript
62
61
  type HonoRPCAppBuilderConfig = {
63
- app?: Hono // Existing Hono app (optional)
64
- pathPrefix?: string // Prefix for all routes (e.g., '/rpc/v1')
62
+ app?: Hono // Existing Hono app (optional)
63
+ pathPrefix?: string // Prefix for all routes (e.g., '/rpc/v1')
65
64
  onRequestStart?: (c: Context) => void
66
65
  onRequestEnd?: (c: Context) => void
67
66
  onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
68
- error?: (procedure: TProcedureRegistration, c: Context, error: Error) => Response | Promise<Response>
67
+ error?: (
68
+ procedure: TProcedureRegistration,
69
+ c: Context,
70
+ error: Error
71
+ ) => Response | Promise<Response>
69
72
  }
70
73
  ```
71
74
 
72
- | Option | Type | Description |
73
- |--------|------|---------------------------------------------------|
74
- | `app` | `Hono` | Use existing Hono app instead of creating new one |
75
- | `pathPrefix` | `string` | Prefix all routes (e.g., `/rpc/v1`) |
76
- | `onRequestStart` | `(c) => void` | Called at start of each request |
77
- | `onRequestEnd` | `(c) => void` | Called after handler completes |
78
- | `onSuccess` | `(proc, c) => void` | Called on successful handler execution |
79
- | `error` | `(proc, c, err) => Response` | Custom error handler (must return Response) |
75
+ | Option | Type | Description |
76
+ | ---------------- | ---------------------------- | ------------------------------------------------- |
77
+ | `app` | `Hono` | Use existing Hono app instead of creating new one |
78
+ | `pathPrefix` | `string` | Prefix all routes (e.g., `/rpc/v1`) |
79
+ | `onRequestStart` | `(c) => void` | Called at start of each request |
80
+ | `onRequestEnd` | `(c) => void` | Called after handler completes |
81
+ | `onSuccess` | `(proc, c) => void` | Called on successful handler execution |
82
+ | `error` | `(proc, c, err) => Response` | Custom error handler (must return Response) |
80
83
 
81
84
  ## Context Resolution
82
85
 
@@ -86,7 +89,7 @@ The context resolver receives the Hono `Context` object:
86
89
  builder.register(RPC, (c: Context) => ({
87
90
  userId: c.req.header('x-user-id') || 'anonymous',
88
91
  userAgent: c.req.header('user-agent'),
89
- ip: c.req.raw.headers.get('cf-connecting-ip') // Cloudflare
92
+ ip: c.req.raw.headers.get('cf-connecting-ip'), // Cloudflare
90
93
  }))
91
94
 
92
95
  // Async context resolution
@@ -97,6 +100,41 @@ builder.register(RPC, async (c) => {
97
100
  })
98
101
  ```
99
102
 
103
+ ## Extending Procedure Documentation
104
+
105
+ The `register` method accepts an optional third parameter `extendProcedureDoc` that allows you to add custom fields to each procedure's documentation. This is useful for adding metadata like descriptions, tags, or custom fields for API documentation generators.
106
+
107
+ ```typescript
108
+ // Example of a factory extending the procedure config:
109
+ type ExtendedRPCConfig = {
110
+ description: string
111
+ tags: string[]
112
+ }
113
+
114
+ builder.register(RPC, (c) => ({ userId: c.req.header('x-user-id') || 'anonymous' }), {
115
+ extendProcedureDoc: ({ base, procedure }, { base: RPCHttpRouteDoc, procedure }) =>
116
+ ({
117
+ description: `Procedure: ${procedure.name}`,
118
+ tags: Array.isArray(procedure.config.scope)
119
+ ? procedure.config.scope
120
+ : [procedure.config.scope],
121
+ }),
122
+ })
123
+
124
+ // Access extended docs after build()
125
+ const app = builder.build()
126
+ console.log(builder.docs) // Each doc now includes description and tags
127
+ ```
128
+
129
+ The `extendProcedureDoc` callback receives:
130
+
131
+ | Parameter | Type | Description |
132
+ | ----------- | ------------------------ | ------------------------------------------------------------------------------------------ |
133
+ | `base` | `RPCHttpRouteDoc` | The base documentation with `name`, `path`, `method`, `scope`, `version`, and `jsonSchema` |
134
+ | `procedure` | `TProcedureRegistration` | The full procedure registration including `name`, `config`, and `handler` |
135
+
136
+ This allows you to derive documentation fields from procedure config or add static metadata per factory registration.
137
+
100
138
  ## Error Handling
101
139
 
102
140
  Custom error handler receives the procedure, context, and error. **Must return a Response:**
@@ -115,7 +153,7 @@ const builder = new HonoRPCAppBuilder({
115
153
  }
116
154
 
117
155
  return c.json({ error: 'Internal server error' }, 500)
118
- }
156
+ },
119
157
  })
120
158
  ```
121
159
 
@@ -132,10 +170,9 @@ const app = new Hono()
132
170
  app.use('*', cors())
133
171
  app.get('/custom', (c) => c.json({ custom: true }))
134
172
 
135
- const builder = new HonoRPCAppBuilder({ app })
136
- .register(RPC, contextResolver)
173
+ const builder = new HonoRPCAppBuilder({ app }).register(RPC, contextResolver)
137
174
 
138
- builder.build() // Adds RPC routes to existing app
175
+ builder.build() // Adds RPC routes to existing app
139
176
  ```
140
177
 
141
178
  ## Runtime Compatibility
@@ -184,25 +221,25 @@ new HonoRPCAppBuilder(config?: HonoRPCAppBuilderConfig)
184
221
 
185
222
  ### Methods
186
223
 
187
- | Method | Signature | Description |
188
- |--------|-----------|-------------|
189
- | `register` | `register<T>(factory, context): this` | Register procedure factory with context |
190
- | `build` | `build(): Hono` | Build routes and return app |
191
- | `makeRPCHttpRoutePath` | `makeRPCHttpRoutePath(config: RPCConfig): string` | Generate route path |
224
+ | Method | Signature | Description |
225
+ | ---------------------- | ------------------------------------------------- | ------------------------------------------------------------------ |
226
+ | `register` | `register<T>(factory, context, options?): this` | Register procedure factory with context and optional doc extension |
227
+ | `build` | `build(): Hono` | Build routes and return app |
228
+ | `makeRPCHttpRoutePath` | `makeRPCHttpRoutePath(config: RPCConfig): string` | Generate route path |
192
229
 
193
230
  ### Static Methods
194
231
 
195
- | Method | Signature | Description |
196
- |--------|-----------|-------------|
232
+ | Method | Signature | Description |
233
+ | ---------------------- | --------------------------------------------------------- | -------------------------------------- |
197
234
  | `makeRPCHttpRoutePath` | `static makeRPCHttpRoutePath({ config, prefix }): string` | Generate route path with custom prefix |
198
235
 
199
236
  ### Properties
200
237
 
201
- | Property | Type | Description |
202
- |----------|------|-------------|
203
- | `app` | `Hono` | The Hono application instance |
204
- | `docs` | `RPCHttpRouteDoc[]` | Route documentation (after `build()`) |
205
- | `config` | `HonoRPCAppBuilderConfig` | The configuration object |
238
+ | Property | Type | Description |
239
+ | -------- | ------------------------- | ------------------------------------- |
240
+ | `app` | `Hono` | The Hono application instance |
241
+ | `docs` | `RPCHttpRouteDoc[]` | Route documentation (after `build()`) |
242
+ | `config` | `HonoRPCAppBuilderConfig` | The configuration object |
206
243
 
207
244
  ## TypeScript Types
208
245
 
@@ -211,7 +248,7 @@ import {
211
248
  HonoRPCAppBuilder,
212
249
  HonoRPCAppBuilderConfig,
213
250
  RPCConfig,
214
- RPCHttpRouteDoc
251
+ RPCHttpRouteDoc,
215
252
  } from 'ts-procedures/hono-rpc'
216
253
  ```
217
254
 
@@ -233,11 +270,11 @@ const AuthRPC = Procedures<AuthContext, RPCConfig>()
233
270
 
234
271
  // Public procedures
235
272
  PublicRPC.Create('HealthCheck', { scope: 'health', version: 1 }, async () => ({
236
- status: 'ok'
273
+ status: 'ok',
237
274
  }))
238
275
 
239
276
  PublicRPC.Create('GetVersion', { scope: ['system', 'version'], version: 1 }, async () => ({
240
- version: '1.0.0'
277
+ version: '1.0.0',
241
278
  }))
242
279
 
243
280
  // Authenticated procedures
@@ -246,7 +283,7 @@ AuthRPC.Create(
246
283
  {
247
284
  scope: ['users', 'profile'],
248
285
  version: 1,
249
- schema: { returnType: v.object({ userId: v.string() }) }
286
+ schema: { returnType: v.object({ userId: v.string() }) },
250
287
  },
251
288
  async (ctx) => ({ userId: ctx.userId })
252
289
  )
@@ -256,7 +293,7 @@ AuthRPC.Create(
256
293
  {
257
294
  scope: ['users', 'profile'],
258
295
  version: 2,
259
- schema: { params: v.object({ name: v.string() }) }
296
+ schema: { params: v.object({ name: v.string() }) },
260
297
  },
261
298
  async (ctx, params) => ({ userId: ctx.userId, name: params.name })
262
299
  )
@@ -270,14 +307,14 @@ const builder = new HonoRPCAppBuilder({
270
307
  onError: (proc, c, err) => {
271
308
  console.error(`✗ ${proc.name}:`, err.message)
272
309
  return c.json({ error: err.message }, 500)
273
- }
310
+ },
274
311
  })
275
312
 
276
313
  builder
277
314
  .register(PublicRPC, () => ({ source: 'public' as const }))
278
315
  .register(AuthRPC, (c) => ({
279
316
  source: 'auth' as const,
280
- userId: c.req.header('x-user-id') || 'anonymous'
317
+ userId: c.req.header('x-user-id') || 'anonymous',
281
318
  }))
282
319
 
283
320
  const app = builder.build()
@@ -288,6 +325,9 @@ const app = builder.build()
288
325
  // POST /rpc/users/profile/get-user/1
289
326
  // POST /rpc/users/profile/get-user/2
290
327
 
291
- console.log('Routes:', builder.docs.map(d => d.path))
328
+ console.log(
329
+ 'Routes:',
330
+ builder.docs.map((d) => d.path)
331
+ )
292
332
  export default app
293
333
  ```
@@ -724,6 +724,231 @@ describe('HonoRPCAppBuilder', () => {
724
724
  })
725
725
  })
726
726
 
727
+ // --------------------------------------------------------------------------
728
+ // extendProcedureDoc Tests
729
+ // --------------------------------------------------------------------------
730
+ describe('extendProcedureDoc', () => {
731
+ test('adds custom properties to generated documentation', () => {
732
+ const builder = new HonoRPCAppBuilder()
733
+ const RPC = Procedures<{}, RPCConfig>()
734
+
735
+ RPC.Create('GetUser', { scope: 'users', version: 1 }, async () => ({ name: 'test' }))
736
+
737
+ builder.register(
738
+ RPC,
739
+ () => ({}),
740
+ ({ base, procedure }) => ({
741
+ summary: `Get user endpoint`,
742
+ tags: ['users'],
743
+ operationId: procedure.name,
744
+ })
745
+ )
746
+ builder.build()
747
+
748
+ const doc = builder.docs[0]!
749
+ expect(doc).toHaveProperty('summary', 'Get user endpoint')
750
+ expect(doc).toHaveProperty('tags', ['users'])
751
+ expect(doc).toHaveProperty('operationId', 'GetUser')
752
+ })
753
+
754
+ test('receives correct base and procedure parameters', () => {
755
+ const extendFn = vi.fn(() => ({}))
756
+ const builder = new HonoRPCAppBuilder()
757
+ const RPC = Procedures<{}, RPCConfig>()
758
+
759
+ const paramsSchema = v.object({ id: v.string() })
760
+ RPC.Create(
761
+ 'GetItem',
762
+ { scope: 'items', version: 2, schema: { params: paramsSchema } },
763
+ async () => ({})
764
+ )
765
+
766
+ builder.register(RPC, () => ({}), extendFn)
767
+ builder.build()
768
+
769
+ expect(extendFn).toHaveBeenCalledTimes(1)
770
+ const callArg = extendFn.mock.calls[0]![0 as any] as any
771
+
772
+ // Verify base properties
773
+ expect(callArg.base).toHaveProperty('name', 'GetItem')
774
+ expect(callArg.base).toHaveProperty('version', 2)
775
+ expect(callArg.base).toHaveProperty('scope', 'items')
776
+ expect(callArg.base).toHaveProperty('path', '/items/get-item/2')
777
+ expect(callArg.base).toHaveProperty('method', 'post')
778
+ expect(callArg.base.jsonSchema).toHaveProperty('body')
779
+
780
+ // Verify procedure properties
781
+ expect(callArg.procedure).toHaveProperty('name', 'GetItem')
782
+ expect(callArg.procedure).toHaveProperty('handler')
783
+ expect(callArg.procedure.config).toHaveProperty('scope', 'items')
784
+ expect(callArg.procedure.config).toHaveProperty('version', 2)
785
+ })
786
+
787
+ test('base properties take precedence over extended properties', () => {
788
+ const builder = new HonoRPCAppBuilder()
789
+ const RPC = Procedures<{}, RPCConfig>()
790
+
791
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({}))
792
+
793
+ builder.register(
794
+ RPC,
795
+ () => ({}),
796
+ () => ({
797
+ name: 'OverriddenName',
798
+ path: '/overridden/path',
799
+ method: 'get',
800
+ customField: 'custom-value',
801
+ })
802
+ )
803
+ builder.build()
804
+
805
+ const doc = builder.docs[0]!
806
+ // Base properties should NOT be overridden
807
+ expect(doc.name).toBe('Test')
808
+ expect(doc.path).toBe('/test/test/1')
809
+ expect(doc.method).toBe('post')
810
+ // Custom field should be present
811
+ expect(doc).toHaveProperty('customField', 'custom-value')
812
+ })
813
+
814
+ test('different factories can have different extendProcedureDoc functions', () => {
815
+ const builder = new HonoRPCAppBuilder()
816
+
817
+ const PublicRPC = Procedures<{}, RPCConfig>()
818
+ const AdminRPC = Procedures<{}, RPCConfig>()
819
+
820
+ PublicRPC.Create('GetPublic', { scope: 'public', version: 1 }, async () => ({}))
821
+ AdminRPC.Create('GetAdmin', { scope: 'admin', version: 1 }, async () => ({}))
822
+
823
+ builder
824
+ .register(
825
+ PublicRPC,
826
+ () => ({}),
827
+ () => ({
828
+ security: [],
829
+ tags: ['public'],
830
+ })
831
+ )
832
+ .register(
833
+ AdminRPC,
834
+ () => ({}),
835
+ () => ({
836
+ security: [{ bearerAuth: [] }],
837
+ tags: ['admin'],
838
+ })
839
+ )
840
+
841
+ builder.build()
842
+
843
+ const publicDoc = builder.docs.find((d) => d.name === 'GetPublic')!
844
+ const adminDoc = builder.docs.find((d) => d.name === 'GetAdmin')!
845
+
846
+ expect(publicDoc).toHaveProperty('security', [])
847
+ expect(publicDoc).toHaveProperty('tags', ['public'])
848
+ expect(adminDoc).toHaveProperty('security', [{ bearerAuth: [] }])
849
+ expect(adminDoc).toHaveProperty('tags', ['admin'])
850
+ })
851
+
852
+ test('extendProcedureDoc is called for each procedure in factory', () => {
853
+ const extendFn = vi.fn(() => ({ extended: true }))
854
+ const builder = new HonoRPCAppBuilder()
855
+ const RPC = Procedures<{}, RPCConfig>()
856
+
857
+ RPC.Create('Method1', { scope: 'm1', version: 1 }, async () => ({}))
858
+ RPC.Create('Method2', { scope: 'm2', version: 1 }, async () => ({}))
859
+ RPC.Create('Method3', { scope: 'm3', version: 1 }, async () => ({}))
860
+
861
+ builder.register(RPC, () => ({}), extendFn)
862
+ builder.build()
863
+
864
+ expect(extendFn).toHaveBeenCalledTimes(3)
865
+
866
+ const procedureNames = extendFn.mock.calls.map((call: any) => call[0].procedure.name)
867
+ expect(procedureNames).toContain('Method1')
868
+ expect(procedureNames).toContain('Method2')
869
+ expect(procedureNames).toContain('Method3')
870
+ })
871
+
872
+ test('not providing extendProcedureDoc results in base documentation only', () => {
873
+ const builder = new HonoRPCAppBuilder()
874
+ const RPC = Procedures<{}, RPCConfig>()
875
+
876
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({}))
877
+
878
+ builder.register(RPC, () => ({})) // No extendProcedureDoc
879
+ builder.build()
880
+
881
+ const doc = builder.docs[0]!
882
+ expect(doc.name).toBe('Test')
883
+ expect(doc.path).toBe('/test/test/1')
884
+ expect(doc.method).toBe('post')
885
+ // Should not have any extra properties
886
+ expect(Object.keys(doc)).toEqual(['name', 'version', 'scope', 'path', 'method', 'jsonSchema'])
887
+ })
888
+
889
+ test('extendProcedureDoc can access procedure config for conditional logic', () => {
890
+ const builder = new HonoRPCAppBuilder()
891
+ const RPC = Procedures<{}, RPCConfig>()
892
+
893
+ RPC.Create('PublicEndpoint', { scope: 'public', version: 1 }, async () => ({}))
894
+ RPC.Create('PrivateEndpoint', { scope: 'private', version: 1 }, async () => ({}))
895
+
896
+ builder.register(
897
+ RPC,
898
+ () => ({}),
899
+ ({ procedure }) => ({
900
+ isPublic: procedure.config.scope === 'public',
901
+ description: `This is a ${procedure.config.scope} endpoint`,
902
+ })
903
+ )
904
+ builder.build()
905
+
906
+ const publicDoc = builder.docs.find((d) => d.name === 'PublicEndpoint')!
907
+ const privateDoc = builder.docs.find((d) => d.name === 'PrivateEndpoint')!
908
+
909
+ expect(publicDoc).toHaveProperty('isPublic', true)
910
+ expect(publicDoc).toHaveProperty('description', 'This is a public endpoint')
911
+ expect(privateDoc).toHaveProperty('isPublic', false)
912
+ expect(privateDoc).toHaveProperty('description', 'This is a private endpoint')
913
+ })
914
+
915
+ test('extendProcedureDoc can use base jsonSchema for OpenAPI-style docs', () => {
916
+ const builder = new HonoRPCAppBuilder()
917
+ const RPC = Procedures<{}, RPCConfig>()
918
+
919
+ const paramsSchema = v.object({ userId: v.string() })
920
+ const returnSchema = v.object({ name: v.string(), email: v.string() })
921
+
922
+ RPC.Create(
923
+ 'GetUser',
924
+ { scope: 'users', version: 1, schema: { params: paramsSchema, returnType: returnSchema } },
925
+ async () => ({ name: 'test', email: 'test@example.com' })
926
+ )
927
+
928
+ builder.register(
929
+ RPC,
930
+ () => ({}),
931
+ ({ base }) => ({
932
+ requestBody: base.jsonSchema.body
933
+ ? { content: { 'application/json': { schema: base.jsonSchema.body } } }
934
+ : undefined,
935
+ responses: {
936
+ 200: base.jsonSchema.response
937
+ ? { content: { 'application/json': { schema: base.jsonSchema.response } } }
938
+ : { description: 'Success' },
939
+ },
940
+ })
941
+ )
942
+ builder.build()
943
+
944
+ const doc = builder.docs[0] as any
945
+ expect(doc).toHaveProperty('requestBody')
946
+ expect(doc.requestBody).toHaveProperty('content')
947
+ expect(doc).toHaveProperty('responses')
948
+ expect(doc.responses).toHaveProperty('200')
949
+ })
950
+ })
951
+
727
952
  // --------------------------------------------------------------------------
728
953
  // Integration Test
729
954
  // --------------------------------------------------------------------------