ts-procedures 3.0.1 → 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.
@@ -251,7 +251,7 @@ describe('ExpressRPCAppBuilder', () => {
251
251
  res.status(400).json({ customError: error.message })
252
252
  })
253
253
 
254
- const builder = new ExpressRPCAppBuilder({ error: errorHandler })
254
+ const builder = new ExpressRPCAppBuilder({ onError: errorHandler })
255
255
  const RPC = Procedures<{}, RPCConfig>()
256
256
 
257
257
  RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
@@ -407,7 +407,10 @@ describe('ExpressRPCAppBuilder', () => {
407
407
  builder.register(RPC, factoryContext)
408
408
  const app = builder.build()
409
409
 
410
- await request(app).post('/get-auth/get-auth/1').set('Authorization', 'Bearer token123').send({})
410
+ await request(app)
411
+ .post('/get-auth/get-auth/1')
412
+ .set('Authorization', 'Bearer token123')
413
+ .send({})
411
414
 
412
415
  expect(factoryContext).toHaveBeenCalledTimes(1)
413
416
  expect(factoryContext.mock.calls[0]![0]).toHaveProperty('headers')
@@ -515,7 +518,10 @@ describe('ExpressRPCAppBuilder', () => {
515
518
  })
516
519
 
517
520
  test("array scope with procedure name: ['users', 'profile'] + 'GetById' → /users/profile/get-by-id/1", () => {
518
- const path = builder.makeRPCHttpRoutePath('GetById', { scope: ['users', 'profile'], version: 1 })
521
+ const path = builder.makeRPCHttpRoutePath('GetById', {
522
+ scope: ['users', 'profile'],
523
+ version: 1,
524
+ })
519
525
  expect(path).toBe('/users/profile/get-by-id/1')
520
526
  })
521
527
 
@@ -620,6 +626,231 @@ describe('ExpressRPCAppBuilder', () => {
620
626
  })
621
627
  })
622
628
 
629
+ // --------------------------------------------------------------------------
630
+ // extendProcedureDoc Tests
631
+ // --------------------------------------------------------------------------
632
+ describe('extendProcedureDoc', () => {
633
+ test('adds custom properties to generated documentation', () => {
634
+ const builder = new ExpressRPCAppBuilder()
635
+ const RPC = Procedures<{}, RPCConfig>()
636
+
637
+ RPC.Create('GetUser', { scope: 'users', version: 1 }, async () => ({ name: 'test' }))
638
+
639
+ builder.register(
640
+ RPC,
641
+ () => ({}),
642
+ ({ base, procedure }) => ({
643
+ summary: `Get user endpoint`,
644
+ tags: ['users'],
645
+ operationId: procedure.name,
646
+ })
647
+ )
648
+ builder.build()
649
+
650
+ const doc = builder.docs[0]!
651
+ expect(doc).toHaveProperty('summary', 'Get user endpoint')
652
+ expect(doc).toHaveProperty('tags', ['users'])
653
+ expect(doc).toHaveProperty('operationId', 'GetUser')
654
+ })
655
+
656
+ test('receives correct base and procedure parameters', () => {
657
+ const extendFn = vi.fn(() => ({}))
658
+ const builder = new ExpressRPCAppBuilder()
659
+ const RPC = Procedures<{}, RPCConfig>()
660
+
661
+ const paramsSchema = v.object({ id: v.string() })
662
+ RPC.Create(
663
+ 'GetItem',
664
+ { scope: 'items', version: 2, schema: { params: paramsSchema } },
665
+ async () => ({})
666
+ )
667
+
668
+ builder.register(RPC, () => ({}), extendFn)
669
+ builder.build()
670
+
671
+ expect(extendFn).toHaveBeenCalledTimes(1)
672
+ const callArg = extendFn.mock.calls[0]![0 as any] as any
673
+
674
+ // Verify base properties
675
+ expect(callArg.base).toHaveProperty('name', 'GetItem')
676
+ expect(callArg.base).toHaveProperty('version', 2)
677
+ expect(callArg.base).toHaveProperty('scope', 'items')
678
+ expect(callArg.base).toHaveProperty('path', '/items/get-item/2')
679
+ expect(callArg.base).toHaveProperty('method', 'post')
680
+ expect(callArg.base.jsonSchema).toHaveProperty('body')
681
+
682
+ // Verify procedure properties
683
+ expect(callArg.procedure).toHaveProperty('name', 'GetItem')
684
+ expect(callArg.procedure).toHaveProperty('handler')
685
+ expect(callArg.procedure.config).toHaveProperty('scope', 'items')
686
+ expect(callArg.procedure.config).toHaveProperty('version', 2)
687
+ })
688
+
689
+ test('base properties take precedence over extended properties', () => {
690
+ const builder = new ExpressRPCAppBuilder()
691
+ const RPC = Procedures<{}, RPCConfig>()
692
+
693
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({}))
694
+
695
+ builder.register(
696
+ RPC,
697
+ () => ({}),
698
+ () => ({
699
+ name: 'OverriddenName',
700
+ path: '/overridden/path',
701
+ method: 'get',
702
+ customField: 'custom-value',
703
+ })
704
+ )
705
+ builder.build()
706
+
707
+ const doc = builder.docs[0]!
708
+ // Base properties should NOT be overridden
709
+ expect(doc.name).toBe('Test')
710
+ expect(doc.path).toBe('/test/test/1')
711
+ expect(doc.method).toBe('post')
712
+ // Custom field should be present
713
+ expect(doc).toHaveProperty('customField', 'custom-value')
714
+ })
715
+
716
+ test('different factories can have different extendProcedureDoc functions', () => {
717
+ const builder = new ExpressRPCAppBuilder()
718
+
719
+ const PublicRPC = Procedures<{}, RPCConfig>()
720
+ const AdminRPC = Procedures<{}, RPCConfig>()
721
+
722
+ PublicRPC.Create('GetPublic', { scope: 'public', version: 1 }, async () => ({}))
723
+ AdminRPC.Create('GetAdmin', { scope: 'admin', version: 1 }, async () => ({}))
724
+
725
+ builder
726
+ .register(
727
+ PublicRPC,
728
+ () => ({}),
729
+ () => ({
730
+ security: [],
731
+ tags: ['public'],
732
+ })
733
+ )
734
+ .register(
735
+ AdminRPC,
736
+ () => ({}),
737
+ () => ({
738
+ security: [{ bearerAuth: [] }],
739
+ tags: ['admin'],
740
+ })
741
+ )
742
+
743
+ builder.build()
744
+
745
+ const publicDoc = builder.docs.find((d) => d.name === 'GetPublic')!
746
+ const adminDoc = builder.docs.find((d) => d.name === 'GetAdmin')!
747
+
748
+ expect(publicDoc).toHaveProperty('security', [])
749
+ expect(publicDoc).toHaveProperty('tags', ['public'])
750
+ expect(adminDoc).toHaveProperty('security', [{ bearerAuth: [] }])
751
+ expect(adminDoc).toHaveProperty('tags', ['admin'])
752
+ })
753
+
754
+ test('extendProcedureDoc is called for each procedure in factory', () => {
755
+ const extendFn = vi.fn(() => ({ extended: true }))
756
+ const builder = new ExpressRPCAppBuilder()
757
+ const RPC = Procedures<{}, RPCConfig>()
758
+
759
+ RPC.Create('Method1', { scope: 'm1', version: 1 }, async () => ({}))
760
+ RPC.Create('Method2', { scope: 'm2', version: 1 }, async () => ({}))
761
+ RPC.Create('Method3', { scope: 'm3', version: 1 }, async () => ({}))
762
+
763
+ builder.register(RPC, () => ({}), extendFn)
764
+ builder.build()
765
+
766
+ expect(extendFn).toHaveBeenCalledTimes(3)
767
+
768
+ const procedureNames = extendFn.mock.calls.map((call: any) => call[0].procedure.name)
769
+ expect(procedureNames).toContain('Method1')
770
+ expect(procedureNames).toContain('Method2')
771
+ expect(procedureNames).toContain('Method3')
772
+ })
773
+
774
+ test('not providing extendProcedureDoc results in base documentation only', () => {
775
+ const builder = new ExpressRPCAppBuilder()
776
+ const RPC = Procedures<{}, RPCConfig>()
777
+
778
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({}))
779
+
780
+ builder.register(RPC, () => ({})) // No extendProcedureDoc
781
+ builder.build()
782
+
783
+ const doc = builder.docs[0]!
784
+ expect(doc.name).toBe('Test')
785
+ expect(doc.path).toBe('/test/test/1')
786
+ expect(doc.method).toBe('post')
787
+ // Should not have any extra properties
788
+ expect(Object.keys(doc)).toEqual(['name', 'version', 'scope', 'path', 'method', 'jsonSchema'])
789
+ })
790
+
791
+ test('extendProcedureDoc can access procedure config for conditional logic', () => {
792
+ const builder = new ExpressRPCAppBuilder()
793
+ const RPC = Procedures<{}, RPCConfig>()
794
+
795
+ RPC.Create('PublicEndpoint', { scope: 'public', version: 1 }, async () => ({}))
796
+ RPC.Create('PrivateEndpoint', { scope: 'private', version: 1 }, async () => ({}))
797
+
798
+ builder.register(
799
+ RPC,
800
+ () => ({}),
801
+ ({ procedure }) => ({
802
+ isPublic: procedure.config.scope === 'public',
803
+ description: `This is a ${procedure.config.scope} endpoint`,
804
+ })
805
+ )
806
+ builder.build()
807
+
808
+ const publicDoc = builder.docs.find((d) => d.name === 'PublicEndpoint')!
809
+ const privateDoc = builder.docs.find((d) => d.name === 'PrivateEndpoint')!
810
+
811
+ expect(publicDoc).toHaveProperty('isPublic', true)
812
+ expect(publicDoc).toHaveProperty('description', 'This is a public endpoint')
813
+ expect(privateDoc).toHaveProperty('isPublic', false)
814
+ expect(privateDoc).toHaveProperty('description', 'This is a private endpoint')
815
+ })
816
+
817
+ test('extendProcedureDoc can use base jsonSchema for OpenAPI-style docs', () => {
818
+ const builder = new ExpressRPCAppBuilder()
819
+ const RPC = Procedures<{}, RPCConfig>()
820
+
821
+ const paramsSchema = v.object({ userId: v.string() })
822
+ const returnSchema = v.object({ name: v.string(), email: v.string() })
823
+
824
+ RPC.Create(
825
+ 'GetUser',
826
+ { scope: 'users', version: 1, schema: { params: paramsSchema, returnType: returnSchema } },
827
+ async () => ({ name: 'test', email: 'test@example.com' })
828
+ )
829
+
830
+ builder.register(
831
+ RPC,
832
+ () => ({}),
833
+ ({ base }) => ({
834
+ requestBody: base.jsonSchema.body
835
+ ? { content: { 'application/json': { schema: base.jsonSchema.body } } }
836
+ : undefined,
837
+ responses: {
838
+ 200: base.jsonSchema.response
839
+ ? { content: { 'application/json': { schema: base.jsonSchema.response } } }
840
+ : { description: 'Success' },
841
+ },
842
+ })
843
+ )
844
+ builder.build()
845
+
846
+ const doc = builder.docs[0] as any
847
+ expect(doc).toHaveProperty('requestBody')
848
+ expect(doc.requestBody).toHaveProperty('content')
849
+ expect(doc).toHaveProperty('responses')
850
+ expect(doc.responses).toHaveProperty('200')
851
+ })
852
+ })
853
+
623
854
  // --------------------------------------------------------------------------
624
855
  // Integration Test
625
856
  // --------------------------------------------------------------------------
@@ -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
 
@@ -23,7 +29,14 @@ export type ExpressRPCAppBuilderConfig = {
23
29
  req: express.Request,
24
30
  res: express.Response
25
31
  ) => void
26
- error?: (
32
+ /**
33
+ * Error handler called when a procedure throws an error.
34
+ * @param procedure
35
+ * @param req
36
+ * @param res
37
+ * @param error
38
+ */
39
+ onError?: (
27
40
  procedure: TProcedureRegistration,
28
41
  req: express.Request,
29
42
  res: express.Response,
@@ -115,7 +128,7 @@ export class ExpressRPCAppBuilder {
115
128
  private factories: ExpressFactoryItem<any>[] = []
116
129
 
117
130
  private _app: express.Express = express()
118
- private _docs: RPCHttpRouteDoc[] = []
131
+ private _docs: (RPCHttpRouteDoc & object)[] = []
119
132
 
120
133
  get app(): express.Express {
121
134
  return this._app
@@ -130,14 +143,21 @@ export class ExpressRPCAppBuilder {
130
143
  * @param factory - The procedure factory created by Procedures<Context, RPCConfig>()
131
144
  * @param factoryContext - The context for procedure handlers. Can be a direct value,
132
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.
133
147
  */
134
148
  register<TFactory extends ProceduresFactory>(
135
149
  factory: TFactory,
136
150
  factoryContext:
137
151
  | ExtractContext<TFactory>
138
- | ((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>
139
159
  ): this {
140
- this.factories.push({ factory, factoryContext } as ExpressFactoryItem<any>)
160
+ this.factories.push({ factory, factoryContext, extendProcedureDoc } as ExpressFactoryItem<any>)
141
161
  return this
142
162
  }
143
163
 
@@ -146,9 +166,9 @@ export class ExpressRPCAppBuilder {
146
166
  * @return express.Application
147
167
  */
148
168
  build(): express.Application {
149
- this.factories.forEach(({ factory, factoryContext }) => {
169
+ this.factories.forEach(({ factory, factoryContext, extendProcedureDoc }) => {
150
170
  factory.getProcedures().map((procedure: TProcedureRegistration<any, RPCConfig>) => {
151
- const route = this.buildRpcHttpRouteDoc(procedure)
171
+ const route = this.buildRpcHttpRouteDoc(procedure, extendProcedureDoc)
152
172
 
153
173
  this._docs.push(route)
154
174
 
@@ -168,8 +188,8 @@ export class ExpressRPCAppBuilder {
168
188
  res.status(200)
169
189
  }
170
190
  } catch (error) {
171
- if (this.config?.error) {
172
- this.config.error(procedure, req, res, error as Error)
191
+ if (this.config?.onError) {
192
+ this.config.onError(procedure, req, res, error as Error)
173
193
  return
174
194
  }
175
195
  if (!res.status) {
@@ -191,14 +211,17 @@ export class ExpressRPCAppBuilder {
191
211
  * Generates the RPC HTTP route for the given procedure.
192
212
  * @param procedure
193
213
  */
194
- private buildRpcHttpRouteDoc(procedure: TProcedureRegistration<any, RPCConfig>): RPCHttpRouteDoc {
214
+ private buildRpcHttpRouteDoc(
215
+ procedure: TProcedureRegistration<any, RPCConfig>,
216
+ extendProcedureDoc: ExpressFactoryItem['extendProcedureDoc']
217
+ ): RPCHttpRouteDoc {
195
218
  const { config } = procedure
196
219
  const path = ExpressRPCAppBuilder.makeRPCHttpRoutePath({
197
220
  name: procedure.name,
198
221
  config,
199
222
  prefix: this.config?.pathPrefix,
200
223
  })
201
- const method = 'post' // RPCs use POST method
224
+ const method = 'post' as const // RPCs use POST method
202
225
  const jsonSchema: { body?: object; response?: object } = {}
203
226
 
204
227
  if (config.schema?.params) {
@@ -208,10 +231,23 @@ export class ExpressRPCAppBuilder {
208
231
  jsonSchema.response = config.schema.returnType
209
232
  }
210
233
 
211
- return {
234
+ const base = {
235
+ name: procedure.name,
236
+ version: config.version,
237
+ scope: config.scope,
212
238
  path,
213
239
  method,
214
240
  jsonSchema,
215
241
  }
242
+ let extendedDoc: object = {}
243
+
244
+ if (extendProcedureDoc) {
245
+ extendedDoc = extendProcedureDoc({ base, procedure })
246
+ }
247
+
248
+ return {
249
+ ...extendedDoc,
250
+ ...base,
251
+ }
216
252
  }
217
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
  }