@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 +3 -2
- package/src/errors.ts +54 -0
- package/src/index.test.ts +442 -0
- package/src/index.ts +200 -0
- package/src/procedure-codes.ts +26 -0
- package/src/schema/compute-schema.test.ts +128 -0
- package/src/schema/compute-schema.ts +55 -0
- package/src/schema/extract-json-schema.test.ts +25 -0
- package/src/schema/extract-json-schema.ts +15 -0
- package/src/schema/parser.test.ts +156 -0
- package/src/schema/parser.ts +92 -0
- package/src/schema/resolve-schema-lib.test.ts +19 -0
- package/src/schema/resolve-schema-lib.ts +23 -0
- package/src/schema/types.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@archtx/procedures",
|
|
3
|
-
"version": "1.1.
|
|
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>
|