@archtx/procedures 1.1.7 → 1.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archtx/procedures",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
4
4
  "description": "Procedure generator for @archtx",
5
5
  "main": "dist/exports.js",
6
6
  "types": "dist/exports.d.ts",
@@ -21,7 +21,8 @@
21
21
  "license": "MIT",
22
22
  "files": [
23
23
  "assets",
24
- "dist"
24
+ "dist",
25
+ "src"
25
26
  ],
26
27
  "dependencies": {
27
28
  "ajv": "^8.17.1",
package/src/errors.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { TSchemaValidationError } from './schema/parser.js'
2
+ import { ProcedureCodes } from './procedure-codes.js'
3
+
4
+ export class ProcedureError extends Error {
5
+ constructor(
6
+ readonly procedureName: string,
7
+ readonly code: ProcedureCodes & number,
8
+ readonly message: string,
9
+ readonly meta?: object,
10
+ ) {
11
+ super(message)
12
+ this.name = 'ProcedureError'
13
+
14
+ // https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
15
+ Object.setPrototypeOf(this, ProcedureError.prototype)
16
+ }
17
+ }
18
+
19
+ export class ProcedureHookError extends ProcedureError {
20
+ constructor(
21
+ readonly procedureName: string,
22
+ message: string,
23
+ ) {
24
+ super(procedureName, ProcedureCodes.PRECONDITION_FAILED, message)
25
+ this.name = 'ProcedureHookError'
26
+
27
+ // https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
28
+ Object.setPrototypeOf(this, ProcedureHookError.prototype)
29
+ }
30
+ }
31
+
32
+ export class ProcedureValidationError extends ProcedureError {
33
+ constructor(
34
+ readonly procedureName: string,
35
+ message: string,
36
+ readonly errors?: TSchemaValidationError[],
37
+ ) {
38
+ super(procedureName, ProcedureCodes.VALIDATION_ERROR, message)
39
+ this.name = 'ProcedureValidationError'
40
+
41
+ // https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
42
+ Object.setPrototypeOf(this, ProcedureValidationError.prototype)
43
+ }
44
+ }
45
+
46
+ export class ProcedureRegistrationError extends Error {
47
+ constructor(readonly procedureName: string, message: string) {
48
+ super(message)
49
+ this.name = 'ProcedureRegistrationError'
50
+
51
+ // https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
52
+ Object.setPrototypeOf(this, ProcedureRegistrationError.prototype)
53
+ }
54
+ }
@@ -0,0 +1,442 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { Procedures } from './index.js'
3
+ import { v } from 'suretype'
4
+ import {
5
+ ProcedureHookError,
6
+ ProcedureError,
7
+ ProcedureValidationError,
8
+ } from './errors.js'
9
+ import { ProcedureCodes } from './procedure-codes.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('Create Single Procedures', () => {
23
+ const { test1, test1_Info } = Procedures().Create(
24
+ 'test1',
25
+ {},
26
+ async (ctx, args) => {
27
+ return '1'
28
+ },
29
+ )
30
+ const { test2, test2_Info } = Procedures().Create(
31
+ 'test2',
32
+ {},
33
+ async (ctx, args) => {
34
+ return '2'
35
+ },
36
+ )
37
+
38
+ expect(test1).toBeDefined()
39
+ expect(test1_Info).toBeDefined()
40
+ expect(test2).toBeDefined()
41
+ expect(test2_Info).toBeDefined
42
+ })
43
+
44
+ test('Procedures - Create call', () =>
45
+ new Promise<void>((done) => {
46
+ let mockHttpCall: any
47
+
48
+ const { Create } = Procedures({
49
+ onCreate: ({ handler, config, name }) => {
50
+ mockHttpCall = handler
51
+ },
52
+ })
53
+
54
+ Create(
55
+ 'Handler',
56
+ {
57
+ schema: {
58
+ args: v.object({ name: v.string() }),
59
+ data: v.string(),
60
+ },
61
+ },
62
+ async (ctx, args) => {
63
+ expect(args).toEqual({ name: 'name' })
64
+ done()
65
+ return 'name'
66
+ },
67
+ )
68
+
69
+ mockHttpCall({}, { name: 'name' })
70
+ }))
71
+
72
+ test('Procedures - Create returns a handler to call/test the Procedure registration', async () => {
73
+ let ProcedureRegisteredCbHandler: any
74
+
75
+ const { Create } = Procedures({
76
+ onCreate: (Procedure) => {
77
+ ProcedureRegisteredCbHandler = Procedure.handler
78
+ },
79
+ })
80
+
81
+ const { Handler, Handler_Info } = Create(
82
+ 'Handler',
83
+ {
84
+ description: 'Handler description',
85
+ schema: {
86
+ args: v.object({ number: v.number() }),
87
+ },
88
+ },
89
+ async (ctx, args) => {
90
+ return args.number
91
+ },
92
+ )
93
+
94
+ expect(Handler).toBeDefined()
95
+ expect(ProcedureRegisteredCbHandler).toEqual(Handler)
96
+
97
+ const result = Handler({}, { number: 1 })
98
+
99
+ expect(result).toBeDefined()
100
+ expect(result).toBeInstanceOf(Promise)
101
+ await expect(result).resolves.toEqual(1)
102
+
103
+ expect(Handler_Info).toBeDefined()
104
+ expect(Handler_Info).toBeInstanceOf(Object)
105
+ expect(Handler_Info.schema).toHaveProperty('args')
106
+ expect(Handler_Info.schema.args).toEqual({
107
+ type: 'object',
108
+ properties: { number: { type: 'number' } },
109
+ })
110
+ expect(Handler_Info).toHaveProperty('description')
111
+ expect(Handler_Info.description).toEqual('Handler description')
112
+ })
113
+
114
+ test('Procedures - Create args validation w/ no args provided', () =>
115
+ new Promise<void>((done) => {
116
+ let mockHttpCall: any
117
+
118
+ const { Create } = Procedures({
119
+ onCreate: ({ handler, config, name }) => {
120
+ mockHttpCall = (callArgs: any) => {
121
+ if (config.validation?.args) {
122
+ const { errors } = config.validation.args(callArgs)
123
+
124
+ if (errors && 'message' in errors[0]) {
125
+ expect(errors[0].message).toEqual('must be object')
126
+ done()
127
+ return
128
+ }
129
+ }
130
+
131
+ handler(callArgs, {})
132
+ }
133
+ },
134
+ })
135
+
136
+ Create(
137
+ 'test',
138
+ {
139
+ schema: {
140
+ args: v.object({}),
141
+ },
142
+ },
143
+ async () => {
144
+ done()
145
+ },
146
+ )
147
+
148
+ mockHttpCall()
149
+ }))
150
+
151
+ test('Procedures - Create args validation w/ missing args', async () =>
152
+ new Promise<void>((done) => {
153
+ let mockHttpCall: any
154
+
155
+ const { Create } = Procedures({
156
+ onCreate: async ({ handler, config, name }) => {
157
+ mockHttpCall = async (callArgs: any) => {
158
+ if (config.validation?.args) {
159
+ const { errors } = config.validation.args(callArgs)
160
+ expect(errors).toBeDefined()
161
+ expect(errors?.length).toEqual(2)
162
+ }
163
+
164
+ try {
165
+ await handler(callArgs, {})
166
+ } catch (e: any) {
167
+ expect(e).instanceof(ProcedureValidationError)
168
+ expect(e.errors.length).toEqual(2)
169
+ done()
170
+ }
171
+ }
172
+ },
173
+ })
174
+
175
+ Create(
176
+ 'test',
177
+ {
178
+ schema: {
179
+ args: v.object({
180
+ name: v.string().required(),
181
+ id: v.number().required(),
182
+ email: v.string(),
183
+ }),
184
+ },
185
+ },
186
+ async () => {
187
+ return
188
+ },
189
+ )
190
+
191
+ mockHttpCall({})
192
+ }))
193
+
194
+ test('Procedures - Create call provides ctx to handler', () =>
195
+ new Promise<void>((done) => {
196
+ let mockHttpCall: any
197
+
198
+ const { Create } = Procedures<{
199
+ testCtx: string
200
+ }>({
201
+ onCreate: ({ handler }) => {
202
+ mockHttpCall = () => handler({ testCtx: 'testCtx' })
203
+ },
204
+ })
205
+
206
+ Create('test', {}, async (ctx, args) => {
207
+ expect(ctx.testCtx).toEqual('testCtx')
208
+ done()
209
+ })
210
+
211
+ mockHttpCall()
212
+ }))
213
+
214
+ test('Procedures - Create call provides ctx, local ctx & hook context to handler', () =>
215
+ new Promise<void>((done) => {
216
+ let mockHttpCall: any
217
+
218
+ const { Create } = Procedures<{
219
+ testCtx: string
220
+ }>({
221
+ onCreate: ({ handler }) => {
222
+ mockHttpCall = () => handler({ testCtx: 'testCtx' })
223
+ },
224
+ })
225
+
226
+ Create(
227
+ 'test',
228
+ {
229
+ hook: async () => ({ localCtx: 'localCtx' }),
230
+ },
231
+ async (ctx, args) => {
232
+ expect(ctx.testCtx).toEqual('testCtx')
233
+ expect(ctx.localCtx).toEqual('localCtx')
234
+ expect(ctx.error(400, 'error')).toBeInstanceOf(ProcedureError)
235
+ done()
236
+ },
237
+ )
238
+
239
+ mockHttpCall()
240
+ }))
241
+
242
+ test('Procedure hook can throw local ctx error and is caught', async () => {
243
+ const { Create } = Procedures()
244
+
245
+ const { TestProcedureHookError } = Create(
246
+ 'TestProcedureHookError',
247
+ {
248
+ hook: async (ctx) => {
249
+ throw ctx.error(400, 'Local context error')
250
+ },
251
+ },
252
+ async () => null,
253
+ )
254
+
255
+ try {
256
+ await TestProcedureHookError({}, {})
257
+ } catch (e: any) {
258
+ expect(e).toBeInstanceOf(ProcedureError)
259
+ expect(e.code).toEqual(400)
260
+ expect(e.message).toEqual('Local context error')
261
+ expect(e.procedureName).toEqual('TestProcedureHookError')
262
+ }
263
+ })
264
+
265
+ test('Procedure hook can throw any error and is caught as ProcedureHookError', async () => {
266
+ const { Create } = Procedures()
267
+
268
+ const { TestProcedureHookError } = Create(
269
+ 'TestProcedureHookError',
270
+ {
271
+ hook: async (ctx) => {
272
+ throw new Error('Local context error')
273
+ },
274
+ },
275
+ async () => null,
276
+ )
277
+
278
+ try {
279
+ await TestProcedureHookError({}, {})
280
+ } catch (e: any) {
281
+ expect(e).toBeInstanceOf(ProcedureHookError)
282
+ expect(e.code).toEqual(ProcedureCodes.PRECONDITION_FAILED)
283
+ expect(e.message).toContain('Local context error')
284
+ expect(e.procedureName).toEqual('TestProcedureHookError')
285
+ }
286
+ })
287
+
288
+ test('Procedure handler can throw local ctx error and is caught', async () => {
289
+ const { Create } = Procedures()
290
+
291
+ const { TestProcedureHandlerError } = Create(
292
+ 'TestProcedureHandlerError',
293
+ {},
294
+ async (ctx) => {
295
+ throw ctx.error(400, 'Local context error')
296
+ },
297
+ )
298
+
299
+ try {
300
+ await TestProcedureHandlerError({}, {})
301
+ } catch (e: any) {
302
+ expect(e).toBeInstanceOf(ProcedureError)
303
+ expect(e.code).toEqual(400)
304
+ expect(e.message).toEqual('Local context error')
305
+ expect(e.procedureName).toEqual('TestProcedureHandlerError')
306
+ }
307
+ })
308
+
309
+ test('Procedure handler can call other Procedures and local ctx is not overwritten', async () => {
310
+ const { Create } = Procedures()
311
+
312
+ const { TestProcedure1 } = Create(
313
+ 'TestProcedure1',
314
+ {
315
+ hook: async (ctx) => {
316
+ return { test: 'test1' }
317
+ },
318
+
319
+ schema: {
320
+ args: v.any(),
321
+ },
322
+ },
323
+ async (ctx, args) => {
324
+ if (args) return { local: ctx.test }
325
+
326
+ // this will throw an error when no args are provided
327
+ throw ctx.error(400, 'Local context error', { local: ctx.test })
328
+ },
329
+ )
330
+
331
+ const { TestProcedure2 } = Create(
332
+ 'TestProcedure2',
333
+ {
334
+ hook: async (ctx) => {
335
+ return { test: 'test2' }
336
+ },
337
+
338
+ schema: {
339
+ args: v.any(),
340
+ },
341
+ },
342
+ async (ctx, args) => {
343
+ return await TestProcedure1(ctx, args)
344
+ },
345
+ )
346
+
347
+ try {
348
+ await TestProcedure2({}, undefined)
349
+ } catch (e: any) {
350
+ expect(e).toBeInstanceOf(ProcedureError)
351
+ expect(e.code).toEqual(400)
352
+ expect(e.message).toEqual('Local context error')
353
+ expect(e.procedureName).toEqual('TestProcedure1')
354
+ expect(e.meta.local).toEqual('test1')
355
+ }
356
+
357
+ const result = await TestProcedure2({}, { any: 'any' })
358
+ expect(result).toEqual({ local: 'test1' })
359
+ })
360
+
361
+ test('Procedures - getRegisteredProcedures', () => {
362
+ const { Create, getProcedures } = Procedures({
363
+ onCreate: () => {
364
+ return undefined
365
+ },
366
+ })
367
+
368
+ Create(
369
+ 'test-docs',
370
+ {
371
+ schema: {
372
+ args: v.object({ name: v.string().required() }),
373
+ data: v.string(),
374
+ },
375
+ },
376
+ async () => {
377
+ return 'test-docs'
378
+ },
379
+ )
380
+
381
+ expect(getProcedures().get('test-docs')).toBeDefined()
382
+ expect(getProcedures().get('test-docs')?.config?.schema).toEqual({
383
+ args: {
384
+ type: 'object',
385
+ properties: {
386
+ name: {
387
+ type: 'string',
388
+ },
389
+ },
390
+ required: ['name'],
391
+ },
392
+ data: {
393
+ type: 'string',
394
+ },
395
+ })
396
+ })
397
+
398
+ test('Procedures - context() throws', async () => {
399
+ interface CustomContext {
400
+ authToken: string
401
+ }
402
+
403
+ const { Create } = Procedures<CustomContext>()
404
+
405
+ function validateAuthToken(token: string) {
406
+ return token === 'valid-token'
407
+ }
408
+
409
+ function getUserFromToken(token: string) {
410
+ return { id: 'user-id' }
411
+ }
412
+
413
+ const { CheckIsAuthenticated } = Create(
414
+ 'CheckIsAuthenticated',
415
+ {
416
+ hook: async (ctx) => {
417
+ if (validateAuthToken(ctx.authToken)) {
418
+ const user = await getUserFromToken(ctx.authToken)
419
+
420
+ return { user }
421
+ }
422
+
423
+ throw ctx.error(ProcedureCodes.UNAUTHORIZED, 'Invalid auth token')
424
+ },
425
+
426
+ schema: {
427
+ data: v.string(),
428
+ },
429
+ },
430
+ async () => {
431
+ return 'User authentication is valid'
432
+ },
433
+ )
434
+
435
+ await expect(
436
+ CheckIsAuthenticated({ authToken: 'valid-token' }, {}),
437
+ ).resolves.toEqual('User authentication is valid')
438
+ await expect(
439
+ CheckIsAuthenticated({ authToken: 'not-valid-token' }, {}),
440
+ ).rejects.toThrowError(ProcedureError)
441
+ })
442
+ })
package/src/index.ts ADDED
@@ -0,0 +1,200 @@
1
+ import {
2
+ ProcedureHookError,
3
+ ProcedureError,
4
+ ProcedureValidationError,
5
+ } from './errors.js'
6
+ import { ProcedureCodes } from './procedure-codes.js'
7
+ import { computeSchema } from './schema/compute-schema.js'
8
+ import { TJSONSchema, TSchemaLib } from './schema/types.js'
9
+
10
+ export type TNoContextProvided = unknown
11
+
12
+ export type TLocalContext = {
13
+ error: (
14
+ code: ProcedureCodes & number,
15
+ message: string,
16
+ meta?: object,
17
+ ) => ProcedureError
18
+ }
19
+
20
+ export type TProcedureRegistration = {
21
+ name: string
22
+ config: {
23
+ description?: string
24
+ hook?: (ctx: any, args?: any) => any
25
+ schema?: {
26
+ args?: TJSONSchema
27
+ data?: TJSONSchema
28
+ }
29
+ validation?: {
30
+ args?: (args: any) => { errors?: any[] }
31
+ }
32
+ }
33
+
34
+ handler: (ctx: any, args?: any) => Promise<any>
35
+ }
36
+
37
+ export function Procedures<TContext = TNoContextProvided>(
38
+ /**
39
+ * Optionally provided builder to register Procedures
40
+ */
41
+ builder?: {
42
+ onCreate?: (config: TProcedureRegistration) => void
43
+ },
44
+ ) {
45
+ const procedures: Map<
46
+ string,
47
+ {
48
+ name: string
49
+ config: TProcedureRegistration['config']
50
+ handler: (ctx: TContext, args: any) => Promise<any>
51
+ }
52
+ > = new Map()
53
+
54
+ function Create<TName extends string, TArgs, TData, TLocalHook>(
55
+ name: TName,
56
+ config: {
57
+ description?: string
58
+ hook?: (
59
+ ctx: TContext & TLocalContext,
60
+ args: TSchemaLib<TArgs>,
61
+ ) => Promise<TLocalHook>
62
+ schema?: {
63
+ args?: TArgs
64
+ data?: TData
65
+ }
66
+ },
67
+ handler: (
68
+ ctx: TContext & TLocalContext & TLocalHook,
69
+ args: TSchemaLib<TArgs>,
70
+ ) => Promise<TSchemaLib<TData>>,
71
+ ): {
72
+ [K in TName | `${TName}_Info`]: K extends TName
73
+ ? (ctx: TContext, args: TSchemaLib<TArgs>) => Promise<TSchemaLib<TData>>
74
+ : {
75
+ description?: string
76
+ schema: {
77
+ args?: TSchemaLib<TArgs> extends unknown ? undefined : TJSONSchema
78
+ data?: TSchemaLib<TData> extends unknown ? undefined : TJSONSchema
79
+ }
80
+ }
81
+ } {
82
+ const { jsonSchema, validations } = computeSchema(name, config.schema)
83
+
84
+ const registeredProcedure = {
85
+ name,
86
+ config: {
87
+ ctx: config.hook,
88
+ description: config.description,
89
+ schema: jsonSchema,
90
+ validation: {
91
+ args: validations.args,
92
+ },
93
+ },
94
+
95
+ handler: async (ctx: TContext, args: TSchemaLib<TArgs>) => {
96
+ try {
97
+ if (validations?.args) {
98
+ const { errors } = validations.args(args)
99
+
100
+ if (errors) {
101
+ throw new ProcedureValidationError(
102
+ name,
103
+ `Validation error for ${name} - ${errors.map((e) => e.message).join(', ')}`,
104
+ errors,
105
+ )
106
+ }
107
+ }
108
+
109
+ const localCtx: TLocalContext = {
110
+ error: (
111
+ code: ProcedureCodes & number,
112
+ message: string,
113
+ meta?: object,
114
+ ) => {
115
+ return new ProcedureError(name, code, message, meta)
116
+ },
117
+ }
118
+
119
+ let computedLocalHook: TLocalHook = {} as TLocalHook
120
+
121
+ if (config.hook) {
122
+ try {
123
+ computedLocalHook = (await config.hook(
124
+ { ...localCtx, ...ctx },
125
+ args,
126
+ )) as TLocalHook
127
+ } catch (error: any) {
128
+ if (error instanceof ProcedureError) {
129
+ throw error
130
+ }
131
+
132
+ const err = new ProcedureHookError(
133
+ name,
134
+ `Error in hook for ${name} - ${error?.message}`,
135
+ )
136
+ err.stack = error.stack
137
+ throw err
138
+ }
139
+ }
140
+
141
+ return handler(
142
+ {
143
+ ...ctx,
144
+ ...localCtx,
145
+ ...computedLocalHook,
146
+ } as TContext & TLocalContext & TLocalHook,
147
+ args,
148
+ )
149
+ } catch (error: any) {
150
+ if (error instanceof ProcedureHookError) {
151
+ throw error
152
+ } else if (error instanceof ProcedureError) {
153
+ throw error
154
+ } else {
155
+ const err = new ProcedureError(
156
+ name,
157
+ ProcedureCodes.HANDLER_ERROR,
158
+ `Error in handler for ${name} - ${error?.message}`,
159
+ )
160
+ err.stack = error.stack
161
+ throw err
162
+ }
163
+ }
164
+ },
165
+ }
166
+
167
+ procedures.set(name, registeredProcedure)
168
+
169
+ if (builder?.onCreate) {
170
+ builder.onCreate(registeredProcedure)
171
+ }
172
+
173
+ // return for testing so that the rpc can be called/tested individually or internally
174
+ return {
175
+ [name]: registeredProcedure.handler,
176
+ [`${name}_Info`]: {
177
+ description: config.description,
178
+ schema: jsonSchema,
179
+ },
180
+ } as {
181
+ [K in TName | `${TName}_Info`]: K extends TName
182
+ ? typeof registeredProcedure.handler
183
+ : {
184
+ description?: string
185
+ schema: {
186
+ args?: TSchemaLib<TArgs> extends unknown ? undefined : TJSONSchema
187
+ data?: TSchemaLib<TData> extends unknown ? undefined : TJSONSchema
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ return {
194
+ getProcedures: () => {
195
+ return procedures
196
+ },
197
+
198
+ Create,
199
+ }
200
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A list of common codes borrowed from HTTP status codes.
3
+ */
4
+ export enum ProcedureCodes {
5
+ OK = 200,
6
+ CREATED = 201,
7
+ ACCEPTED = 202,
8
+ NO_CONTENT = 204,
9
+ RESET_CONTENT = 205,
10
+ PARTIAL_CONTENT = 206,
11
+ BAD_REQUEST = 400,
12
+ UNAUTHORIZED = 401,
13
+ FORBIDDEN = 403,
14
+ NOT_FOUND = 404,
15
+ NOT_ACCEPTABLE = 406,
16
+ PROXY_AUTHENTICATION_REQUIRED = 407,
17
+ TIMEOUT = 408,
18
+ CONFLICT = 409,
19
+ PRECONDITION_FAILED = 412,
20
+ VALIDATION_ERROR = 422,
21
+ TOO_MANY_REQUESTS = 429,
22
+ INTERNAL_ERROR = 500,
23
+ HANDLER_ERROR = 500,
24
+ NOT_IMPLEMENTED = 501,
25
+ SERVICE_UNAVAILABLE = 503,
26
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { Type } from '@sinclair/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: { args: undefined, data: undefined },
13
+ validations: {}
14
+ })
15
+ })
16
+
17
+ describe('with Typebox schema', () => {
18
+ it('should correctly process args schema', () => {
19
+ const schema = {
20
+ args: 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.args).toBeDefined()
29
+ expect(result.validations.args).toBeDefined()
30
+
31
+ // Test validation function
32
+ const validInput = { name: 'John', age: 30 }
33
+ expect(result.validations.args?.(validInput).errors).toBeUndefined()
34
+
35
+ // Test invalid input
36
+ const invalidInput = { name: 123, age: 'invalid' }
37
+ expect(result.validations.args?.(invalidInput).errors).toBeDefined()
38
+ })
39
+
40
+ it('should correctly process data schema', () => {
41
+ const schema = {
42
+ data: Type.Object({
43
+ result: Type.Boolean()
44
+ })
45
+ }
46
+
47
+ const result = computeSchema('test-procedure', schema)
48
+
49
+ expect(result.jsonSchema.data).toBeDefined()
50
+ expect(result.validations.args).toBeUndefined()
51
+ })
52
+ })
53
+
54
+ describe('with Suretype schema', () => {
55
+ it('should correctly process args schema', () => {
56
+ const schema = {
57
+ args: 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.args).toBeDefined()
66
+ expect(result.validations.args).toBeDefined()
67
+
68
+ // Test validation function
69
+ const validInput = { name: 'John', age: 30 }
70
+ expect(result.validations.args?.(validInput).errors).toBeUndefined()
71
+
72
+ // Test invalid input
73
+ const invalidInput = { name: 123, age: 'invalid' }
74
+ expect(result.validations.args?.(invalidInput).errors).toBeDefined()
75
+ })
76
+ })
77
+
78
+ describe('error handling', () => {
79
+ it('should throw ProcedureRegistrationError for invalid schema', () => {
80
+ const invalidSchema = {
81
+ args: {
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
+ args: {
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 args and data schemas', () => {
108
+ const schema = {
109
+ args: Type.Object({
110
+ input: Type.String()
111
+ }),
112
+ data: Type.Object({
113
+ output: Type.Boolean()
114
+ })
115
+ }
116
+
117
+ const result = computeSchema('test-procedure', schema)
118
+
119
+ expect(result.jsonSchema.args).toBeDefined()
120
+ expect(result.jsonSchema.data).toBeDefined()
121
+ expect(result.validations.args).toBeDefined()
122
+
123
+ // Test args validation
124
+ const validInput = { input: 'test' }
125
+ expect(result.validations.args?.(validInput).errors).toBeUndefined()
126
+ })
127
+ })
128
+ })
@@ -0,0 +1,55 @@
1
+ import { schemaParser, TSchemaValidationError } from './parser.js'
2
+ import { ProcedureRegistrationError } from '../errors.js'
3
+ import { TJSONSchema } from './types.js'
4
+
5
+ /**
6
+ * This function is used to compute the JSON schema and validation functions
7
+ * for a given schema.
8
+ *
9
+ * @param name The name of the procedure
10
+ * @param schema Procedure schema
11
+ */
12
+ export function computeSchema<TArgsSchemaType, TDataSchemaType>(
13
+ name: string,
14
+ schema?: {
15
+ args?: TArgsSchemaType
16
+ data?: TDataSchemaType
17
+ },
18
+ ): {
19
+ jsonSchema: {
20
+ args?: TJSONSchema
21
+ data?: TJSONSchema
22
+ }
23
+ validations: {
24
+ args?: (args?: any) => { errors?: TSchemaValidationError[] }
25
+ }
26
+ } {
27
+ const jsonSchema: { args?: TJSONSchema; data?: TJSONSchema } = {
28
+ args: undefined,
29
+ data: undefined,
30
+ }
31
+
32
+ const validations: {
33
+ args?: (args?: any) => { errors?: TSchemaValidationError[] }
34
+ } = {}
35
+
36
+ if (schema) {
37
+ const {
38
+ jsonSchema: { args, data },
39
+ validation,
40
+ } = schemaParser(schema, (errors) => {
41
+ throw new ProcedureRegistrationError(
42
+ name,
43
+ `Error parsing schema for ${name} - ${Object.entries(errors)
44
+ .map(([key, error]) => `${key}: ${error}`)
45
+ .join(', ')}`,
46
+ )
47
+ })
48
+
49
+ jsonSchema.args = args
50
+ jsonSchema.data = data
51
+ validations.args = validation.args
52
+ }
53
+
54
+ return { jsonSchema, validations }
55
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { Type } from '@sinclair/typebox'
3
+ import { v } from 'suretype'
4
+ import { extractJsonSchema } from './extract-json-schema.js'
5
+
6
+ describe('extractJsonSchema()', () => {
7
+ const typebox = Type.Object({ name: Type.String() })
8
+ const suretype = v.object({ name: v.string().required() })
9
+
10
+ test('it extracts TypeBox json-schema', async () => {
11
+ expect(extractJsonSchema(typebox)).toMatchObject({
12
+ type: 'object',
13
+ properties: { name: { type: 'string' } },
14
+ required: ['name'],
15
+ })
16
+ })
17
+
18
+ test('it extracts Suretype json-schema', async () => {
19
+ expect(extractJsonSchema(suretype)).toMatchObject({
20
+ type: 'object',
21
+ properties: { name: { type: 'string' } },
22
+ required: ['name'],
23
+ })
24
+ })
25
+ })
@@ -0,0 +1,15 @@
1
+ import { extractSingleJsonSchema } from 'suretype'
2
+ import { isSuretypeSchema, isTypeboxSchema } from './resolve-schema-lib.js'
3
+ import { TJSONSchema } from './types.js'
4
+
5
+ export function extractJsonSchema(libSchema: unknown): TJSONSchema | undefined {
6
+ if (isTypeboxSchema(libSchema)) {
7
+ return libSchema
8
+ }
9
+
10
+ if (isSuretypeSchema(libSchema)) {
11
+ return extractSingleJsonSchema(libSchema)?.schema
12
+ }
13
+
14
+ return undefined
15
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { extractSingleJsonSchema, v } from 'suretype'
3
+ import { schemaParser } from './parser.js'
4
+ import { Type } from '@sinclair/typebox'
5
+
6
+ describe('schemaParser', () => {
7
+ test('it parses args to json-schema', async () => {
8
+ let done: () => void = () => void 0
9
+ const promise = new Promise<void>((r) => {
10
+ done = r
11
+ })
12
+
13
+ const args = v.object({
14
+ name: v.string(),
15
+ age: v.number(),
16
+ })
17
+
18
+ const result = schemaParser(
19
+ {
20
+ args: args,
21
+ },
22
+ (errors) => {
23
+ throw new Error(JSON.stringify(errors))
24
+ },
25
+ )
26
+
27
+ expect(result.jsonSchema.args).toEqual(
28
+ extractSingleJsonSchema(args)?.schema,
29
+ )
30
+
31
+ done()
32
+
33
+ await promise
34
+ await promise
35
+ })
36
+
37
+ test('it parses args and generates a validator function', async () => {
38
+ let done: () => void = () => void 0
39
+ const promise = new Promise<void>((r) => {
40
+ done = r
41
+ })
42
+
43
+ const args = v.object({
44
+ name: v.string(),
45
+ age: v.number().required(),
46
+ })
47
+
48
+ const result = schemaParser(
49
+ {
50
+ args: args,
51
+ },
52
+ (errors) => {
53
+ throw new Error(JSON.stringify(errors))
54
+ },
55
+ )
56
+
57
+ expect(
58
+ result.validation.args?.({
59
+ name: 'John',
60
+ age: 30,
61
+ })?.errors,
62
+ ).toBeUndefined()
63
+
64
+ expect(
65
+ result.validation.args?.({
66
+ name: { name: '' },
67
+ age: 'poop',
68
+ })?.errors,
69
+ ).toBeDefined()
70
+
71
+ expect(
72
+ result.validation.args?.({
73
+ name: { name: '' },
74
+ age: 'poop',
75
+ })?.errors?.length,
76
+ ).toEqual(2)
77
+
78
+ done()
79
+
80
+ await promise
81
+ })
82
+
83
+ test('it parses data to json-schema', async () => {
84
+ let done: () => void = () => void 0
85
+ const promise = new Promise<void>((r) => {
86
+ done = r
87
+ })
88
+
89
+ const data = v.object({
90
+ name: v.string(),
91
+ age: v.number(),
92
+ })
93
+
94
+ const result = schemaParser(
95
+ {
96
+ data: data,
97
+ },
98
+ (errors) => {
99
+ throw new Error(JSON.stringify(errors))
100
+ },
101
+ )
102
+
103
+ expect(result.jsonSchema.data).toEqual(
104
+ extractSingleJsonSchema(data)?.schema,
105
+ )
106
+
107
+ done()
108
+
109
+ await promise
110
+ })
111
+
112
+ test('it throws a meaningful error to the dev', async () => {
113
+ schemaParser(
114
+ // invalid args schema
115
+ { args: { test: Type.String() } },
116
+ (errors) => {
117
+ expect(errors.args).toMatch(/Error extracting json schema schema.args/)
118
+ },
119
+ )
120
+
121
+ schemaParser(
122
+ // invalid data schema
123
+ { data: 'string value' },
124
+ (errors) => {
125
+ expect(errors.data).toMatch(/Error extracting json schema schema.data/)
126
+ },
127
+ )
128
+ })
129
+
130
+ test('it parses multiple schemas correct', async () => {
131
+ const schema = schemaParser(
132
+ {
133
+ args: Type.Object({ a: Type.String() }),
134
+ data: Type.Object({ b: Type.Null() }),
135
+ },
136
+ (error) => {
137
+ throw new Error(JSON.stringify(error))
138
+ },
139
+ )
140
+
141
+ const schema2= schemaParser(
142
+ {
143
+ args: Type.Object({ c: Type.String() }),
144
+ data: Type.Object({ d: Type.Number() }),
145
+ },
146
+ (error) => {
147
+ throw new Error(JSON.stringify(error))
148
+ },
149
+ )
150
+
151
+ expect(schema.validation.args?.({}).errors?.[0]?.message).toMatch(/must have required property 'a'/)
152
+ expect(schema2.validation.args?.({ c: 'test' })
153
+ ).toMatchObject({})
154
+ expect(schema.validation.args?.({}).errors?.[0]?.message).toMatch(/must have required property 'a'/)
155
+ })
156
+ })
@@ -0,0 +1,92 @@
1
+ import {default as addFormats} from 'ajv-formats'
2
+ import * as AJV from 'ajv'
3
+ import { extractJsonSchema } from './extract-json-schema.js'
4
+ import { TJSONSchema } from './types.js'
5
+
6
+ export type TSchemaParsed = {
7
+ jsonSchema: { args?: TJSONSchema; data?: TJSONSchema }
8
+ validation: { args?: (args: any) => { errors?: TSchemaValidationError[] } }
9
+ }
10
+
11
+ export type TSchemaValidationError = AJV.ErrorObject
12
+
13
+ // @ts-ignore
14
+ const ajv = addFormats(
15
+ new AJV.Ajv({
16
+ allErrors: true,
17
+ coerceTypes: true,
18
+ removeAdditional: true,
19
+ }),
20
+ )
21
+
22
+ export function schemaParser(
23
+ schema: { args?: unknown; data?: unknown },
24
+ onParseError: (errors: { args?: string; data?: string }) => void,
25
+ ): TSchemaParsed {
26
+ const jsonSchema: TSchemaParsed['jsonSchema'] = {}
27
+ const validation: TSchemaParsed['validation'] = {}
28
+
29
+ if (schema.args) {
30
+ try {
31
+ const extracted = extractJsonSchema(schema.args as TJSONSchema)
32
+
33
+ if (extracted) {
34
+ jsonSchema.args = extracted
35
+ }
36
+ } catch (e: any) {
37
+ onParseError({
38
+ args: `Error extracting json schema schema.args - ${e.message}`,
39
+ })
40
+ }
41
+
42
+ if (!jsonSchema.args) {
43
+ onParseError({
44
+ args: `Error extracting json schema schema.args - schema.args might be empty or it is not a valid suretype or typebox type`,
45
+ })
46
+ } else {
47
+ let argsValidator: AJV.ValidateFunction
48
+
49
+ try {
50
+ argsValidator = ajv.compile(jsonSchema.args as TJSONSchema)
51
+ } catch (e: any) {
52
+ onParseError({
53
+ args: `Error compiling schema.args for validator - ${e.message}`,
54
+ })
55
+ }
56
+
57
+ validation.args = (args: any) => {
58
+ const valid = argsValidator(args)
59
+
60
+ if (!valid) {
61
+ const errors = argsValidator.errors
62
+
63
+ return {
64
+ errors: errors?.length ? errors : undefined,
65
+ }
66
+ }
67
+
68
+ return {}
69
+ }
70
+ }
71
+ }
72
+
73
+ if (schema.data) {
74
+ try {
75
+ const extracted = extractJsonSchema(schema.data as TJSONSchema)
76
+
77
+ jsonSchema.data = extracted
78
+ } catch (e: any) {
79
+ onParseError({
80
+ data: `Error extracting json schema schema.data - ${e.message}`,
81
+ })
82
+ }
83
+
84
+ if (!jsonSchema.data) {
85
+ onParseError({
86
+ data: `Error extracting json schema schema.data - schema.data might be empty or it is not a valid suretype or typebox type`,
87
+ })
88
+ }
89
+ }
90
+
91
+ return { jsonSchema, validation }
92
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { isSuretypeSchema, isTypeboxSchema } from './resolve-schema-lib.js'
3
+ import { Type } from '@sinclair/typebox'
4
+ import { v } from 'suretype'
5
+
6
+ describe('lib schema resolvers', () => {
7
+ const typebox = Type.Object({ name: Type.String() })
8
+ const suretype = v.object({ name: v.string() })
9
+
10
+ test('it recognizes TypeBox schema', async () => {
11
+ expect(isTypeboxSchema(typebox)).toBe(true)
12
+ expect(isTypeboxSchema(suretype)).toBe(false)
13
+ })
14
+
15
+ test('it recognizes Suretype schema', async () => {
16
+ expect(isSuretypeSchema(suretype)).toBe(true)
17
+ expect(isSuretypeSchema(typebox)).toBe(false)
18
+ })
19
+ })
@@ -0,0 +1,23 @@
1
+ import { Static } from '@sinclair/typebox'
2
+ import { CoreValidator } from 'suretype'
3
+
4
+ export type IsTypeboxSchema<TSchema> =
5
+ TSchema extends {static: unknown, params: unknown} ? true
6
+ : false;
7
+
8
+ export function isTypeboxSchema(
9
+ schema: any,
10
+ ): schema is Static<any> {
11
+ return typeof schema === 'object' && Symbol.for('TypeBox.Kind') in schema;
12
+ }
13
+
14
+ export type IsSuretypeSchema<TSchema> =
15
+ TSchema extends {required: () => object, nullable?: never} ? true
16
+ : false;
17
+
18
+ export function isSuretypeSchema(
19
+ schema: any,
20
+ ): schema is CoreValidator<any> {
21
+ return typeof schema === 'object' && 'getJsonSchemaObject' in schema;
22
+ }
23
+
@@ -0,0 +1,12 @@
1
+ import { CoreValidator, TypeOf } from 'suretype'
2
+ import { Static, TSchema } from '@sinclair/typebox'
3
+
4
+ // Determine if the generic "SchemaLibType" is Suretype's CoreValidator or Typebox's TSchema
5
+ export type TSchemaLib<SchemaLibType> =
6
+ SchemaLibType extends CoreValidator<any>
7
+ ? TypeOf<SchemaLibType>
8
+ : SchemaLibType extends TSchema
9
+ ? Static<SchemaLibType>
10
+ : unknown
11
+
12
+ export type TJSONSchema = Record<string, any>