ts-procedures 1.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.
@@ -0,0 +1,364 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
2
+ import { describe, expect, test } from 'vitest'
3
+ import { Procedures } from './index.js'
4
+ import { v } from 'suretype'
5
+ import { Type } from 'typebox'
6
+ import {
7
+ ProcedureError,
8
+ ProcedureValidationError,
9
+ } from './errors.js'
10
+
11
+ describe('Procedures', () => {
12
+ test('Procedures', () => {
13
+ const result = Procedures({
14
+ onCreate: () => {
15
+ return undefined
16
+ },
17
+ })
18
+
19
+ expect(result).toHaveProperty('Create')
20
+ })
21
+
22
+ test('Procedures generic context & extended config', () => {
23
+ interface CustomContext {
24
+ authToken: string
25
+ }
26
+
27
+ interface ExtendedConfig {
28
+ customProp: string
29
+ optionalProp?: number
30
+ }
31
+
32
+ const { Create } = Procedures<CustomContext, ExtendedConfig>()
33
+
34
+ const {info} = Create(
35
+ 'TestProcedure',
36
+ {
37
+ // should not throw type errors
38
+ customProp: 'customProp',
39
+ },
40
+ async (ctx) => {
41
+ // should not throw type errors
42
+ return ctx.authToken
43
+ },
44
+ )
45
+
46
+ expect(info.customProp).toEqual('customProp')
47
+ expect(info.optionalProp).toEqual(undefined)
48
+ })
49
+
50
+ test('Create Single Procedures', () => {
51
+ const { procedure: procedure1, info: info1 } = Procedures().Create(
52
+ 'test1',
53
+ {},
54
+ async () => {
55
+ return '1'
56
+ },
57
+ )
58
+ const { procedure: procedure2, info: info2 } = Procedures().Create(
59
+ 'test2',
60
+ {},
61
+ async () => {
62
+ return '2'
63
+ },
64
+ )
65
+
66
+ expect(procedure1).toBeDefined()
67
+ expect(info1).toBeDefined()
68
+ expect(procedure2).toBeDefined()
69
+ expect(info2).toBeDefined()
70
+ })
71
+
72
+ test('Procedures - Create call', () =>
73
+ new Promise<void>((done) => {
74
+ let mockHttpCall: any
75
+
76
+ const { Create } = Procedures({
77
+ onCreate: ({ handler }) => {
78
+ mockHttpCall = handler
79
+ },
80
+ })
81
+
82
+ Create(
83
+ 'Handler',
84
+ {
85
+ schema: {
86
+ params: v.object({ name: v.string() }),
87
+ returnType: v.string(),
88
+ },
89
+ },
90
+ async (ctx, params) => {
91
+ expect(params).toEqual({ name: 'name' })
92
+ done()
93
+ return 'name'
94
+ },
95
+ )
96
+
97
+ mockHttpCall({}, { name: 'name' })
98
+ }))
99
+
100
+ test('Procedures - Create call w/ Typebox', () =>
101
+ new Promise<void>((done) => {
102
+ let mockHttpCall: any
103
+
104
+ const { Create } = Procedures({
105
+ onCreate: ({ handler, config, name }) => {
106
+ mockHttpCall = handler
107
+ },
108
+ })
109
+
110
+ Create(
111
+ 'Handler',
112
+ {
113
+ schema: {
114
+ params: Type.Object({ name: Type.Optional(Type.String()) }),
115
+ returnType: Type.String(),
116
+ },
117
+ },
118
+ async (ctx, params) => {
119
+ expect(params).toEqual({ name: 'name' })
120
+ done()
121
+ return 'name'
122
+ },
123
+ )
124
+
125
+ mockHttpCall({}, { name: 'name' })
126
+ }))
127
+
128
+ test('Procedures - Create returns a handler to call/test the Procedure registration', async () => {
129
+ let ProcedureRegisteredCbHandler: any
130
+
131
+ const { Create } = Procedures({
132
+ onCreate: (Procedure) => {
133
+ ProcedureRegisteredCbHandler = Procedure.handler
134
+ },
135
+ })
136
+
137
+ const { NamedExportHandler, procedure, info } = Create(
138
+ 'NamedExportHandler',
139
+ {
140
+ description: 'Handler description',
141
+ schema: {
142
+ params: v.object({ number: v.number() }),
143
+ },
144
+ },
145
+ async (ctx, params) => {
146
+ return params.number
147
+ },
148
+ )
149
+
150
+ expect(NamedExportHandler).toBeDefined()
151
+ expect(procedure).toBeDefined()
152
+ expect(ProcedureRegisteredCbHandler).toEqual(NamedExportHandler)
153
+ expect(ProcedureRegisteredCbHandler).toEqual(procedure)
154
+
155
+ const result = NamedExportHandler({}, { number: 1 })
156
+
157
+ expect(result).toBeDefined()
158
+ expect(result).toBeInstanceOf(Promise)
159
+ await expect(result).resolves.toEqual(1)
160
+
161
+ expect(info).toBeDefined()
162
+ expect(info).toBeInstanceOf(Object)
163
+ expect(info.schema).toHaveProperty('params')
164
+ expect(info.schema.params).toEqual({
165
+ type: 'object',
166
+ properties: { number: { type: 'number' } },
167
+ })
168
+ expect(info).toHaveProperty('description')
169
+ expect(info.description).toEqual('Handler description')
170
+ })
171
+
172
+ test('Procedures - Create params validation w/ no params provided', () =>
173
+ new Promise<void>((done) => {
174
+ let mockHttpCall: any
175
+
176
+ const { Create } = Procedures({
177
+ onCreate: ({ handler, config, name }) => {
178
+ mockHttpCall = (callParams: any) => {
179
+ if (config.validation?.params) {
180
+ const { errors } = config.validation.params(callParams)
181
+
182
+ if (errors && 'message' in errors[0]) {
183
+ expect(errors[0].message).toEqual('must be object')
184
+ done()
185
+ return
186
+ }
187
+ }
188
+
189
+ handler(callParams, {})
190
+ }
191
+ },
192
+ })
193
+
194
+ Create(
195
+ 'test',
196
+ {
197
+ schema: {
198
+ params: v.object({}),
199
+ },
200
+ },
201
+ async () => {
202
+ done()
203
+ },
204
+ )
205
+
206
+ mockHttpCall()
207
+ }))
208
+
209
+ test('Procedures - Create params validation w/ missing params', async () =>
210
+ new Promise<void>((done) => {
211
+ let mockHttpCall: any
212
+
213
+ const { Create } = Procedures({
214
+ onCreate: async ({ handler, config, name }) => {
215
+ mockHttpCall = async (callParams: any) => {
216
+ if (config.validation?.params) {
217
+ const { errors } = config.validation.params(callParams)
218
+ expect(errors).toBeDefined()
219
+ expect(errors?.length).toEqual(2)
220
+ }
221
+
222
+ try {
223
+ await handler(callParams, {})
224
+ } catch (e: any) {
225
+ expect(e).instanceof(ProcedureValidationError)
226
+ expect(e.errors.length).toEqual(2)
227
+ done()
228
+ }
229
+ }
230
+ },
231
+ })
232
+
233
+ Create(
234
+ 'test',
235
+ {
236
+ schema: {
237
+ params: v.object({
238
+ name: v.string().required(),
239
+ id: v.number().required(),
240
+ email: v.string(),
241
+ }),
242
+ },
243
+ },
244
+ async () => {
245
+ return
246
+ },
247
+ )
248
+
249
+ mockHttpCall({})
250
+ }))
251
+
252
+ test('Procedures - Create call provides ctx to handler', () =>
253
+ new Promise<void>((done) => {
254
+ let mockHttpCall: any
255
+
256
+ const { Create } = Procedures<{
257
+ testCtx: string
258
+ }>({
259
+ onCreate: ({ handler }) => {
260
+ mockHttpCall = () => handler({ testCtx: 'testCtx' })
261
+ },
262
+ })
263
+
264
+ Create('test', {}, async (ctx, params) => {
265
+ expect(ctx.testCtx).toEqual('testCtx')
266
+ done()
267
+ })
268
+
269
+ mockHttpCall()
270
+ }))
271
+
272
+ test('Procedure handler can throw local ctx error and is caught', async () => {
273
+ const { Create } = Procedures()
274
+
275
+ const { TestProcedureHandlerError } = Create(
276
+ 'TestProcedureHandlerError',
277
+ {},
278
+ async (ctx) => {
279
+ throw ctx.error( 'Local context error')
280
+ },
281
+ )
282
+
283
+ try {
284
+ await TestProcedureHandlerError({}, {})
285
+ } catch (e: any) {
286
+ expect(e).toBeInstanceOf(ProcedureError)
287
+
288
+ expect(e.message).toEqual('Local context error')
289
+ expect(e.procedureName).toEqual('TestProcedureHandlerError')
290
+ }
291
+ })
292
+
293
+ test('Procedures - getRegisteredProcedures', () => {
294
+ const { Create, getProcedures } = Procedures({
295
+ onCreate: () => {
296
+ return undefined
297
+ },
298
+ })
299
+
300
+ Create(
301
+ 'test-docs',
302
+ {
303
+ schema: {
304
+ params: v.object({ name: v.string().required() }),
305
+ returnType: v.string(),
306
+ },
307
+ },
308
+ async () => {
309
+ return 'test-docs'
310
+ },
311
+ )
312
+
313
+ expect(getProcedures().get('test-docs')).toBeDefined()
314
+ expect(getProcedures().get('test-docs')?.config?.schema).toEqual({
315
+ params: {
316
+ type: 'object',
317
+ properties: {
318
+ name: {
319
+ type: 'string',
320
+ },
321
+ },
322
+ required: ['name'],
323
+ },
324
+ returnType: {
325
+ type: 'string',
326
+ },
327
+ })
328
+ })
329
+
330
+ test('Procedures - context() throws', async () => {
331
+ interface CustomContext {
332
+ authToken: string
333
+ }
334
+
335
+ const { Create } = Procedures<CustomContext>()
336
+
337
+ function validateAuthToken(token: string) {
338
+ return token === 'valid-token'
339
+ }
340
+
341
+ const { CheckIsAuthenticated } = Create(
342
+ 'CheckIsAuthenticated',
343
+ {
344
+ schema: {
345
+ returnType: v.string(),
346
+ },
347
+ },
348
+ async (ctx) => {
349
+ if (!validateAuthToken(ctx.authToken)) {
350
+ throw ctx.error('Invalid auth token')
351
+ }
352
+
353
+ return 'User authentication is valid'
354
+ },
355
+ )
356
+
357
+ await expect(
358
+ CheckIsAuthenticated({ authToken: 'valid-token' }, {}),
359
+ ).resolves.toEqual('User authentication is valid')
360
+ await expect(
361
+ CheckIsAuthenticated({ authToken: 'not-valid-token' }, {}),
362
+ ).rejects.toThrowError(ProcedureError)
363
+ })
364
+ })
package/src/index.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { ProcedureError, ProcedureValidationError } from './errors.js'
2
+ import { computeSchema } from './schema/compute-schema.js'
3
+ import { Prettify, TJSONSchema, TSchemaLib } from './schema/types.js'
4
+
5
+ export type TNoContextProvided = unknown
6
+
7
+ export type TLocalContext = {
8
+ error: (message: string, meta?: object) => ProcedureError
9
+ }
10
+
11
+ export type TProcedureRegistration<TContext = unknown, TExtendedConfig = unknown> = {
12
+ name: string
13
+ config: {
14
+ description?: string
15
+ schema?: {
16
+ params?: TJSONSchema
17
+ returnType?: TJSONSchema
18
+ }
19
+ validation?: {
20
+ params?: (params: any) => { errors?: any[] }
21
+ }
22
+ } & TExtendedConfig
23
+
24
+ handler: (ctx: TContext, params?: any) => Promise<any>
25
+ }
26
+
27
+ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unknown>(
28
+ /**
29
+ * Optionally provided builder to register Procedures
30
+ */
31
+ builder?: {
32
+ onCreate?: (
33
+ procedure: Prettify<{
34
+ name: string
35
+ handler: (ctx: Prettify<TContext>, params?: any) => Promise<any>
36
+ config: Prettify<
37
+ {
38
+ description?: string
39
+ schema?: {
40
+ params?: TJSONSchema
41
+ returnType?: TJSONSchema
42
+ }
43
+ validation?: {
44
+ params?: (params: any) => { errors?: any[] }
45
+ }
46
+ } & TExtendedConfig
47
+ >
48
+ }>
49
+ ) => void
50
+ }
51
+ ) {
52
+ const procedures: Map<
53
+ string,
54
+ {
55
+ name: string
56
+ config: Prettify<
57
+ {
58
+ description?: string
59
+ schema?: {
60
+ params?: TJSONSchema
61
+ returnType?: TJSONSchema
62
+ }
63
+ validation?: {
64
+ params?: (params: any) => { errors?: any[] }
65
+ }
66
+ } & TExtendedConfig
67
+ >
68
+ handler: (ctx: Prettify<TContext>, params: any) => Promise<any>
69
+ }
70
+ > = new Map()
71
+
72
+ function Create<TName extends string, TParams, TReturnType>(
73
+ name: TName,
74
+ config: {
75
+ description?: string
76
+ schema?: {
77
+ params?: TParams
78
+ returnType?: TReturnType
79
+ }
80
+ } & TExtendedConfig,
81
+ handler: (
82
+ ctx: Prettify<TContext & TLocalContext>,
83
+ params: TSchemaLib<TParams>
84
+ ) => Promise<TSchemaLib<TReturnType>>
85
+ ) {
86
+ const { jsonSchema, validations } = computeSchema(name, config.schema)
87
+
88
+ const registeredProcedure = {
89
+ name,
90
+ config: {
91
+ // ctx: config.hook, ??? why was this here
92
+ ...config,
93
+ description: config.description,
94
+ schema: jsonSchema,
95
+ validation: {
96
+ params: validations.params,
97
+ },
98
+ },
99
+
100
+ handler: async (ctx: Prettify<TContext>, params: TSchemaLib<TParams>) => {
101
+ try {
102
+ if (validations?.params) {
103
+ const { errors } = validations.params(params)
104
+
105
+ if (errors) {
106
+ throw new ProcedureValidationError(
107
+ name,
108
+ `Validation error for ${name} - ${errors.map((e) => e.message).join(', ')}`,
109
+ errors
110
+ )
111
+ }
112
+ }
113
+
114
+ const localCtx: TLocalContext = {
115
+ error: (message: string, meta?: object) => {
116
+ return new ProcedureError(name, message, meta)
117
+ },
118
+ }
119
+
120
+ return handler(
121
+ {
122
+ ...ctx,
123
+ ...localCtx,
124
+ } as Prettify<TContext & TLocalContext>,
125
+ params
126
+ )
127
+ } catch (error: any) {
128
+ if (error instanceof ProcedureError) {
129
+ throw error
130
+ } else {
131
+ const err = new ProcedureError(name, `Error in handler for ${name} - ${error?.message}`)
132
+ err.stack = error.stack
133
+ throw err
134
+ }
135
+ }
136
+ },
137
+ }
138
+
139
+ procedures.set(name, registeredProcedure)
140
+
141
+ if (builder?.onCreate) {
142
+ builder.onCreate(registeredProcedure)
143
+ }
144
+
145
+ const info = {
146
+ name,
147
+ ...registeredProcedure.config,
148
+ }
149
+
150
+ // return so can be called directly (ie: int/unit tests)
151
+ return {
152
+ [name]: registeredProcedure.handler,
153
+ procedure: registeredProcedure.handler,
154
+ info,
155
+ } as {
156
+ [K in TName]: (ctx: Prettify<TContext>, params: TSchemaLib<TParams>) => Promise<TSchemaLib<TReturnType>>
157
+ } & {
158
+ procedure: (ctx: Prettify<TContext>, params: TSchemaLib<TParams>) => Promise<TSchemaLib<TReturnType>>
159
+ info: {
160
+ name: TName
161
+ description?: string
162
+ schema: {
163
+ params?: TParams
164
+ returnType?: TReturnType
165
+ }
166
+ validation?: {
167
+ params?: (params: any) => { errors?: any[] }
168
+ }
169
+ } & TExtendedConfig
170
+ }
171
+ }
172
+
173
+ return {
174
+ getProcedures: () => {
175
+ return procedures
176
+ },
177
+
178
+ Create,
179
+ }
180
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { Type } from 'typebox'
3
+ import { v } from 'suretype'
4
+ import { computeSchema } from './compute-schema.js'
5
+ import { ProcedureRegistrationError } from '../errors.js'
6
+
7
+ describe('computeSchema', () => {
8
+ it('should return empty schema and validations when no schema provided', () => {
9
+ const result = computeSchema('test-procedure')
10
+
11
+ expect(result).toEqual({
12
+ jsonSchema: { params: undefined, returnType: undefined },
13
+ validations: {}
14
+ })
15
+ })
16
+
17
+ describe('with Typebox schema', () => {
18
+ it('should correctly process params schema', () => {
19
+ const schema = {
20
+ params: Type.Object({
21
+ name: Type.String(),
22
+ age: Type.Number()
23
+ })
24
+ }
25
+
26
+ const result = computeSchema('test-procedure', schema)
27
+
28
+ expect(result.jsonSchema.params).toBeDefined()
29
+ expect(result.validations.params).toBeDefined()
30
+
31
+ // Test validation function
32
+ const validInput = { name: 'John', age: 30 }
33
+ expect(result.validations.params?.(validInput).errors).toBeUndefined()
34
+
35
+ // Test invalid input
36
+ const invalidInput = { name: 123, age: 'invalid' }
37
+ expect(result.validations.params?.(invalidInput).errors).toBeDefined()
38
+ })
39
+
40
+ it('should correctly process returnType schema', () => {
41
+ const schema = {
42
+ returnType: Type.Object({
43
+ result: Type.Boolean()
44
+ })
45
+ }
46
+
47
+ const result = computeSchema('test-procedure', schema)
48
+
49
+ expect(result.jsonSchema.returnType).toBeDefined()
50
+ expect(result.validations.params).toBeUndefined()
51
+ })
52
+ })
53
+
54
+ describe('with Suretype schema', () => {
55
+ it('should correctly process params schema', () => {
56
+ const schema = {
57
+ params: v.object({
58
+ name: v.string(),
59
+ age: v.number()
60
+ })
61
+ }
62
+
63
+ const result = computeSchema('test-procedure', schema)
64
+
65
+ expect(result.jsonSchema.params).toBeDefined()
66
+ expect(result.validations.params).toBeDefined()
67
+
68
+ // Test validation function
69
+ const validInput = { name: 'John', age: 30 }
70
+ expect(result.validations.params?.(validInput).errors).toBeUndefined()
71
+
72
+ // Test invalid input
73
+ const invalidInput = { name: 123, age: 'invalid' }
74
+ expect(result.validations.params?.(invalidInput).errors).toBeDefined()
75
+ })
76
+ })
77
+
78
+ describe('error handling', () => {
79
+ it('should throw ProcedureRegistrationError for invalid schema', () => {
80
+ const invalidSchema = {
81
+ params: {
82
+ type: 'invalid-schema-type'
83
+ }
84
+ }
85
+
86
+ expect(() => computeSchema('test-procedure', invalidSchema))
87
+ .toThrow(ProcedureRegistrationError)
88
+ })
89
+
90
+ it('should include procedure name in error message', () => {
91
+ const invalidSchema = {
92
+ params: {
93
+ type: 'invalid-schema-type'
94
+ }
95
+ }
96
+
97
+ try {
98
+ computeSchema('test-procedure', invalidSchema)
99
+ } catch (error: any) {
100
+ expect(error instanceof ProcedureRegistrationError).toBe(true)
101
+ expect(error.message).toContain('test-procedure')
102
+ }
103
+ })
104
+ })
105
+
106
+ describe('combined schemas', () => {
107
+ it('should handle both params and returnType schemas', () => {
108
+ const schema = {
109
+ params: Type.Object({
110
+ input: Type.String()
111
+ }),
112
+ returnType: Type.Object({
113
+ output: Type.Boolean()
114
+ })
115
+ }
116
+
117
+ const result = computeSchema('test-procedure', schema)
118
+
119
+ expect(result.jsonSchema.params).toBeDefined()
120
+ expect(result.jsonSchema.returnType).toBeDefined()
121
+ expect(result.validations.params).toBeDefined()
122
+
123
+ // Test params validation
124
+ const validInput = { input: 'test' }
125
+ expect(result.validations.params?.(validInput).errors).toBeUndefined()
126
+ })
127
+ })
128
+ })