@naturalcycles/nodejs-lib 15.90.2 → 15.92.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.
- package/dist/fs/fs2.d.ts +6 -5
- package/dist/fs/kpy.js +1 -1
- package/dist/stream/pipeline.d.ts +1 -2
- package/dist/stream/pipeline.js +1 -3
- package/dist/validation/ajv/ajvSchema.d.ts +644 -20
- package/dist/validation/ajv/ajvSchema.js +1179 -8
- package/dist/validation/ajv/from-data/generateJsonSchemaFromData.d.ts +1 -1
- package/dist/validation/ajv/getAjv.js +6 -0
- package/dist/validation/ajv/index.d.ts +0 -1
- package/dist/validation/ajv/index.js +0 -1
- package/dist/validation/ajv/jsonSchemaBuilder.util.d.ts +1 -1
- package/dist/zip/zip.util.d.ts +1 -2
- package/package.json +2 -2
- package/src/validation/ajv/ajvSchema.ts +1930 -50
- package/src/validation/ajv/from-data/generateJsonSchemaFromData.ts +1 -1
- package/src/validation/ajv/getAjv.ts +10 -3
- package/src/validation/ajv/index.ts +0 -1
- package/src/validation/ajv/jsonSchemaBuilder.util.ts +1 -1
- package/dist/validation/ajv/jsonSchemaBuilder.d.ts +0 -653
- package/dist/validation/ajv/jsonSchemaBuilder.js +0 -1128
- package/src/validation/ajv/jsonSchemaBuilder.ts +0 -1975
|
@@ -1,24 +1,69 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint-disable id-denylist */
|
|
2
|
+
// oxlint-disable max-lines
|
|
3
|
+
|
|
2
4
|
import type { ValidationFunction, ValidationFunctionResult } from '@naturalcycles/js-lib'
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
+
import {
|
|
6
|
+
_isObject,
|
|
7
|
+
_isUndefined,
|
|
8
|
+
_lazyValue,
|
|
9
|
+
_numberEnumValues,
|
|
10
|
+
_stringEnumValues,
|
|
11
|
+
getEnumType,
|
|
12
|
+
} from '@naturalcycles/js-lib'
|
|
13
|
+
import { _uniq } from '@naturalcycles/js-lib/array'
|
|
14
|
+
import { _assert, _try } from '@naturalcycles/js-lib/error'
|
|
15
|
+
import type { Set2 } from '@naturalcycles/js-lib/object'
|
|
16
|
+
import { _deepCopy, _filterNullishValues, _sortObject } from '@naturalcycles/js-lib/object'
|
|
5
17
|
import { _substringBefore } from '@naturalcycles/js-lib/string'
|
|
6
|
-
import {
|
|
7
|
-
|
|
18
|
+
import type {
|
|
19
|
+
AnyObject,
|
|
20
|
+
BaseDBEntity,
|
|
21
|
+
IANATimezone,
|
|
22
|
+
Inclusiveness,
|
|
23
|
+
IsoDate,
|
|
24
|
+
IsoDateTime,
|
|
25
|
+
IsoMonth,
|
|
26
|
+
NumberEnum,
|
|
27
|
+
StringEnum,
|
|
28
|
+
StringMap,
|
|
29
|
+
UnixTimestamp,
|
|
30
|
+
UnixTimestampMillis,
|
|
31
|
+
} from '@naturalcycles/js-lib/types'
|
|
32
|
+
import { _objectAssign, _typeCast, JWT_REGEX } from '@naturalcycles/js-lib/types'
|
|
8
33
|
import type { Ajv, ErrorObject } from 'ajv'
|
|
9
34
|
import { _inspect } from '../../string/inspect.js'
|
|
35
|
+
import {
|
|
36
|
+
BASE64URL_REGEX,
|
|
37
|
+
COUNTRY_CODE_REGEX,
|
|
38
|
+
CURRENCY_REGEX,
|
|
39
|
+
IPV4_REGEX,
|
|
40
|
+
IPV6_REGEX,
|
|
41
|
+
LANGUAGE_TAG_REGEX,
|
|
42
|
+
SEMVER_REGEX,
|
|
43
|
+
SLUG_REGEX,
|
|
44
|
+
URL_REGEX,
|
|
45
|
+
UUID_REGEX,
|
|
46
|
+
} from '../regexes.js'
|
|
47
|
+
import { TIMEZONES } from '../timezones.js'
|
|
10
48
|
import { AjvValidationError } from './ajvValidationError.js'
|
|
11
49
|
import { getAjv } from './getAjv.js'
|
|
12
|
-
import {
|
|
13
|
-
|
|
50
|
+
import {
|
|
51
|
+
isEveryItemNumber,
|
|
52
|
+
isEveryItemPrimitive,
|
|
53
|
+
isEveryItemString,
|
|
54
|
+
JSON_SCHEMA_ORDER,
|
|
55
|
+
mergeJsonSchemaObjects,
|
|
56
|
+
} from './jsonSchemaBuilder.util.js'
|
|
57
|
+
|
|
58
|
+
// ==== AJV =====
|
|
14
59
|
|
|
15
60
|
/**
|
|
16
61
|
* On creation - compiles ajv validation function.
|
|
17
62
|
* Provides convenient methods, error reporting, etc.
|
|
18
63
|
*/
|
|
19
|
-
export class AjvSchema<
|
|
64
|
+
export class AjvSchema<OUT> {
|
|
20
65
|
private constructor(
|
|
21
|
-
public schema: JsonSchema<
|
|
66
|
+
public schema: JsonSchema<OUT>,
|
|
22
67
|
cfg: Partial<AjvSchemaCfg> = {},
|
|
23
68
|
) {
|
|
24
69
|
this.cfg = {
|
|
@@ -37,10 +82,10 @@ export class AjvSchema<IN = unknown, OUT = IN> {
|
|
|
37
82
|
/**
|
|
38
83
|
* Shortcut for AjvSchema.create(schema, { lazy: true })
|
|
39
84
|
*/
|
|
40
|
-
static createLazy<
|
|
41
|
-
schema: SchemaHandledByAjv<
|
|
85
|
+
static createLazy<OUT>(
|
|
86
|
+
schema: SchemaHandledByAjv<OUT>,
|
|
42
87
|
cfg?: Partial<AjvSchemaCfg>,
|
|
43
|
-
): AjvSchema<
|
|
88
|
+
): AjvSchema<OUT> {
|
|
44
89
|
return AjvSchema.create(schema, {
|
|
45
90
|
lazy: true,
|
|
46
91
|
...cfg,
|
|
@@ -56,21 +101,18 @@ export class AjvSchema<IN = unknown, OUT = IN> {
|
|
|
56
101
|
* Implementation note: JsonSchemaBuilder goes first in the union type, otherwise TypeScript fails to infer <T> type
|
|
57
102
|
* correctly for some reason.
|
|
58
103
|
*/
|
|
59
|
-
static create<
|
|
60
|
-
schema: SchemaHandledByAjv<IN, OUT>,
|
|
61
|
-
cfg?: Partial<AjvSchemaCfg>,
|
|
62
|
-
): AjvSchema<IN, OUT> {
|
|
104
|
+
static create<OUT>(schema: SchemaHandledByAjv<OUT>, cfg?: Partial<AjvSchemaCfg>): AjvSchema<OUT> {
|
|
63
105
|
if (schema instanceof AjvSchema) return schema
|
|
64
106
|
|
|
65
|
-
if (AjvSchema.isSchemaWithCachedAjvSchema<typeof schema,
|
|
66
|
-
return AjvSchema.requireCachedAjvSchema<typeof schema,
|
|
107
|
+
if (AjvSchema.isSchemaWithCachedAjvSchema<typeof schema, OUT>(schema)) {
|
|
108
|
+
return AjvSchema.requireCachedAjvSchema<typeof schema, OUT>(schema)
|
|
67
109
|
}
|
|
68
110
|
|
|
69
|
-
let jsonSchema: JsonSchema<
|
|
111
|
+
let jsonSchema: JsonSchema<OUT>
|
|
70
112
|
|
|
71
113
|
if (AjvSchema.isJsonSchemaBuilder(schema)) {
|
|
72
114
|
// oxlint-disable typescript-eslint(no-unnecessary-type-assertion)
|
|
73
|
-
jsonSchema = (schema as JsonSchemaTerminal<
|
|
115
|
+
jsonSchema = (schema as JsonSchemaTerminal<OUT, any>).build()
|
|
74
116
|
AjvSchema.requireValidJsonSchema(jsonSchema)
|
|
75
117
|
} else {
|
|
76
118
|
jsonSchema = schema
|
|
@@ -83,13 +125,13 @@ export class AjvSchema<IN = unknown, OUT = IN> {
|
|
|
83
125
|
// really upsets Ajv.
|
|
84
126
|
delete jsonSchema.optionalField
|
|
85
127
|
|
|
86
|
-
const ajvSchema = new AjvSchema<
|
|
128
|
+
const ajvSchema = new AjvSchema<OUT>(jsonSchema, cfg)
|
|
87
129
|
AjvSchema.cacheAjvSchema(schema, ajvSchema)
|
|
88
130
|
|
|
89
131
|
return ajvSchema
|
|
90
132
|
}
|
|
91
133
|
|
|
92
|
-
static isJsonSchemaBuilder<
|
|
134
|
+
static isJsonSchemaBuilder<OUT>(schema: unknown): schema is JsonSchemaTerminal<OUT, any> {
|
|
93
135
|
return schema instanceof JsonSchemaTerminal
|
|
94
136
|
}
|
|
95
137
|
|
|
@@ -103,13 +145,13 @@ export class AjvSchema<IN = unknown, OUT = IN> {
|
|
|
103
145
|
*
|
|
104
146
|
* Returned object is always the same object (`===`) that was passed, so it is returned just for convenience.
|
|
105
147
|
*/
|
|
106
|
-
validate(input:
|
|
148
|
+
validate(input: unknown, opt: AjvValidationOptions = {}): OUT {
|
|
107
149
|
const [err, output] = this.getValidationResult(input, opt)
|
|
108
150
|
if (err) throw err
|
|
109
151
|
return output
|
|
110
152
|
}
|
|
111
153
|
|
|
112
|
-
isValid(input:
|
|
154
|
+
isValid(input: unknown, opt?: AjvValidationOptions): boolean {
|
|
113
155
|
// todo: we can make it both fast and non-mutating by using Ajv
|
|
114
156
|
// with "removeAdditional" and "useDefaults" disabled.
|
|
115
157
|
const [err] = this.getValidationResult(input, opt)
|
|
@@ -117,8 +159,8 @@ export class AjvSchema<IN = unknown, OUT = IN> {
|
|
|
117
159
|
}
|
|
118
160
|
|
|
119
161
|
getValidationResult(
|
|
120
|
-
input:
|
|
121
|
-
opt: AjvValidationOptions
|
|
162
|
+
input: unknown,
|
|
163
|
+
opt: AjvValidationOptions = {},
|
|
122
164
|
): ValidationFunctionResult<OUT, AjvValidationError> {
|
|
123
165
|
const fn = this.getAJVValidateFunction()
|
|
124
166
|
|
|
@@ -127,14 +169,31 @@ export class AjvSchema<IN = unknown, OUT = IN> {
|
|
|
127
169
|
? input // mutate
|
|
128
170
|
: _deepCopy(input) // not mutate
|
|
129
171
|
|
|
130
|
-
|
|
172
|
+
let valid = fn(item) // mutates item, but not input
|
|
131
173
|
_typeCast<OUT>(item)
|
|
132
|
-
|
|
174
|
+
|
|
175
|
+
let output: OUT = item
|
|
176
|
+
if (valid && this.schema.postValidation) {
|
|
177
|
+
const [err, result] = _try(() => this.schema.postValidation!(output))
|
|
178
|
+
if (err) {
|
|
179
|
+
valid = false
|
|
180
|
+
;(fn as any).errors = [
|
|
181
|
+
{
|
|
182
|
+
instancePath: '',
|
|
183
|
+
message: err.message,
|
|
184
|
+
},
|
|
185
|
+
]
|
|
186
|
+
} else {
|
|
187
|
+
output = result
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (valid) return [null, output]
|
|
133
192
|
|
|
134
193
|
const errors = fn.errors!
|
|
135
194
|
|
|
136
195
|
const {
|
|
137
|
-
inputId = _isObject(input) ? input['id'
|
|
196
|
+
inputId = _isObject(input) ? (input as any)['id'] : undefined,
|
|
138
197
|
inputName = this.cfg.inputName || 'Object',
|
|
139
198
|
} = opt
|
|
140
199
|
const dataVar = [inputName, inputId].filter(Boolean).join('.')
|
|
@@ -160,10 +219,10 @@ export class AjvSchema<IN = unknown, OUT = IN> {
|
|
|
160
219
|
inputId,
|
|
161
220
|
}),
|
|
162
221
|
)
|
|
163
|
-
return [err,
|
|
222
|
+
return [err, output]
|
|
164
223
|
}
|
|
165
224
|
|
|
166
|
-
getValidationFunction(): ValidationFunction<
|
|
225
|
+
getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
|
|
167
226
|
return (input, opt) => {
|
|
168
227
|
return this.getValidationResult(input, {
|
|
169
228
|
mutateInput: opt?.mutateInput,
|
|
@@ -173,22 +232,20 @@ export class AjvSchema<IN = unknown, OUT = IN> {
|
|
|
173
232
|
}
|
|
174
233
|
}
|
|
175
234
|
|
|
176
|
-
static isSchemaWithCachedAjvSchema<Base,
|
|
235
|
+
static isSchemaWithCachedAjvSchema<Base, OUT>(
|
|
177
236
|
schema: Base,
|
|
178
|
-
): schema is WithCachedAjvSchema<Base,
|
|
237
|
+
): schema is WithCachedAjvSchema<Base, OUT> {
|
|
179
238
|
return !!(schema as any)?.[HIDDEN_AJV_SCHEMA]
|
|
180
239
|
}
|
|
181
240
|
|
|
182
|
-
static cacheAjvSchema<Base extends AnyObject,
|
|
241
|
+
static cacheAjvSchema<Base extends AnyObject, OUT>(
|
|
183
242
|
schema: Base,
|
|
184
|
-
ajvSchema: AjvSchema<
|
|
185
|
-
): WithCachedAjvSchema<Base,
|
|
243
|
+
ajvSchema: AjvSchema<OUT>,
|
|
244
|
+
): WithCachedAjvSchema<Base, OUT> {
|
|
186
245
|
return Object.assign(schema, { [HIDDEN_AJV_SCHEMA]: ajvSchema })
|
|
187
246
|
}
|
|
188
247
|
|
|
189
|
-
static requireCachedAjvSchema<Base,
|
|
190
|
-
schema: WithCachedAjvSchema<Base, IN, OUT>,
|
|
191
|
-
): AjvSchema<IN, OUT> {
|
|
248
|
+
static requireCachedAjvSchema<Base, OUT>(schema: WithCachedAjvSchema<Base, OUT>): AjvSchema<OUT> {
|
|
192
249
|
return schema[HIDDEN_AJV_SCHEMA]
|
|
193
250
|
}
|
|
194
251
|
|
|
@@ -229,7 +286,7 @@ export class AjvSchema<IN = unknown, OUT = IN> {
|
|
|
229
286
|
}
|
|
230
287
|
|
|
231
288
|
private getErrorMessageForInstancePath(
|
|
232
|
-
schema: JsonSchema<
|
|
289
|
+
schema: JsonSchema<OUT> | undefined,
|
|
233
290
|
instancePath: string,
|
|
234
291
|
keyword: string,
|
|
235
292
|
): string | undefined {
|
|
@@ -239,8 +296,8 @@ export class AjvSchema<IN = unknown, OUT = IN> {
|
|
|
239
296
|
return this.traverseSchemaPath(schema, segments, keyword)
|
|
240
297
|
}
|
|
241
298
|
|
|
242
|
-
private traverseSchemaPath<
|
|
243
|
-
schema: JsonSchema<
|
|
299
|
+
private traverseSchemaPath<T>(
|
|
300
|
+
schema: JsonSchema<T>,
|
|
244
301
|
segments: string[],
|
|
245
302
|
keyword: string,
|
|
246
303
|
): string | undefined {
|
|
@@ -290,11 +347,11 @@ const separator = '\n'
|
|
|
290
347
|
|
|
291
348
|
export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA')
|
|
292
349
|
|
|
293
|
-
export type WithCachedAjvSchema<Base,
|
|
294
|
-
[HIDDEN_AJV_SCHEMA]: AjvSchema<
|
|
350
|
+
export type WithCachedAjvSchema<Base, OUT> = Base & {
|
|
351
|
+
[HIDDEN_AJV_SCHEMA]: AjvSchema<OUT>
|
|
295
352
|
}
|
|
296
353
|
|
|
297
|
-
export interface AjvValidationOptions
|
|
354
|
+
export interface AjvValidationOptions {
|
|
298
355
|
/**
|
|
299
356
|
* Defaults to true,
|
|
300
357
|
* because that's how AJV works by default,
|
|
@@ -322,7 +379,7 @@ export interface AjvValidationOptions<IN> {
|
|
|
322
379
|
* can include it "how it was" in an error message. So, for that reason we'll use
|
|
323
380
|
* `getOriginalInput()`, if it's provided.
|
|
324
381
|
*/
|
|
325
|
-
getOriginalInput?: () =>
|
|
382
|
+
getOriginalInput?: () => unknown
|
|
326
383
|
}
|
|
327
384
|
|
|
328
385
|
export interface AjvSchemaCfg {
|
|
@@ -350,7 +407,1830 @@ export interface AjvSchemaCfg {
|
|
|
350
407
|
lazy?: boolean
|
|
351
408
|
}
|
|
352
409
|
|
|
353
|
-
export type SchemaHandledByAjv<
|
|
354
|
-
| JsonSchemaTerminal<
|
|
355
|
-
| JsonSchema<
|
|
356
|
-
| AjvSchema<
|
|
410
|
+
export type SchemaHandledByAjv<OUT> =
|
|
411
|
+
| JsonSchemaTerminal<OUT, any>
|
|
412
|
+
| JsonSchema<OUT>
|
|
413
|
+
| AjvSchema<OUT>
|
|
414
|
+
|
|
415
|
+
// ===== JsonSchemaBuilders ===== //
|
|
416
|
+
|
|
417
|
+
export const j = {
|
|
418
|
+
/**
|
|
419
|
+
* Matches literally any value - equivalent to TypeScript's `any` type.
|
|
420
|
+
* Use sparingly, as it bypasses type validation entirely.
|
|
421
|
+
*/
|
|
422
|
+
any(): JsonSchemaAnyBuilder<any, false> {
|
|
423
|
+
return new JsonSchemaAnyBuilder({})
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
string(): JsonSchemaStringBuilder<string, false> {
|
|
427
|
+
return new JsonSchemaStringBuilder()
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
number(): JsonSchemaNumberBuilder<number, false> {
|
|
431
|
+
return new JsonSchemaNumberBuilder()
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
boolean(): JsonSchemaBooleanBuilder<boolean, false> {
|
|
435
|
+
return new JsonSchemaBooleanBuilder()
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
object: Object.assign(object, {
|
|
439
|
+
dbEntity: objectDbEntity,
|
|
440
|
+
infer: objectInfer,
|
|
441
|
+
any() {
|
|
442
|
+
return j.object<AnyObject>({}).allowAdditionalProperties()
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
stringMap<S extends JsonSchemaTerminal<any, any>>(
|
|
446
|
+
schema: S,
|
|
447
|
+
): JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>> {
|
|
448
|
+
const isValueOptional = schema.getSchema().optionalField
|
|
449
|
+
const builtSchema = schema.build()
|
|
450
|
+
const finalValueSchema: JsonSchema = isValueOptional
|
|
451
|
+
? { anyOf: [{ isUndefined: true }, builtSchema] }
|
|
452
|
+
: builtSchema
|
|
453
|
+
|
|
454
|
+
return new JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>>(
|
|
455
|
+
{},
|
|
456
|
+
{
|
|
457
|
+
hasIsOfTypeCheck: false,
|
|
458
|
+
patternProperties: {
|
|
459
|
+
'^.+$': finalValueSchema,
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
)
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* @experimental Look around, maybe you find a rule that is better for your use-case.
|
|
467
|
+
*
|
|
468
|
+
* For Record<K, V> type of validations.
|
|
469
|
+
* ```ts
|
|
470
|
+
* const schema = j.object
|
|
471
|
+
* .record(
|
|
472
|
+
* j
|
|
473
|
+
* .string()
|
|
474
|
+
* .regex(/^\d{3,4}$/)
|
|
475
|
+
* .branded<B>(),
|
|
476
|
+
* j.number().nullable(),
|
|
477
|
+
* )
|
|
478
|
+
* .isOfType<Record<B, number | null>>()
|
|
479
|
+
* ```
|
|
480
|
+
*
|
|
481
|
+
* When the keys of the Record are values from an Enum, prefer `j.object.withEnumKeys`!
|
|
482
|
+
*
|
|
483
|
+
* Non-matching keys will be stripped from the object, i.e. they will not cause an error.
|
|
484
|
+
*
|
|
485
|
+
* Caveat: This rule first validates values of every properties of the object, and only then validates the keys.
|
|
486
|
+
* A consequence of that is that the validation will throw when there is an unexpected property with a value not matching the value schema.
|
|
487
|
+
*/
|
|
488
|
+
record,
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* For Record<ENUM, V> type of validations.
|
|
492
|
+
*
|
|
493
|
+
* When the keys of the Record are values from an Enum,
|
|
494
|
+
* this helper is more performant and behaves in a more conventional manner than `j.object.record` would.
|
|
495
|
+
*
|
|
496
|
+
*
|
|
497
|
+
*/
|
|
498
|
+
withEnumKeys,
|
|
499
|
+
withRegexKeys,
|
|
500
|
+
}),
|
|
501
|
+
|
|
502
|
+
array<OUT, Opt>(itemSchema: JsonSchemaAnyBuilder<OUT, Opt>): JsonSchemaArrayBuilder<OUT, Opt> {
|
|
503
|
+
return new JsonSchemaArrayBuilder(itemSchema)
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
tuple<const S extends JsonSchemaAnyBuilder<any, any>[]>(items: S): JsonSchemaTupleBuilder<S> {
|
|
507
|
+
return new JsonSchemaTupleBuilder<S>(items)
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
set<OUT, Opt>(itemSchema: JsonSchemaAnyBuilder<OUT, Opt>): JsonSchemaSet2Builder<OUT, Opt> {
|
|
511
|
+
return new JsonSchemaSet2Builder(itemSchema)
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
buffer(): JsonSchemaBufferBuilder {
|
|
515
|
+
return new JsonSchemaBufferBuilder()
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
enum<const T extends readonly (string | number | boolean | null)[] | StringEnum | NumberEnum>(
|
|
519
|
+
input: T,
|
|
520
|
+
opt?: JsonBuilderRuleOpt,
|
|
521
|
+
): JsonSchemaEnumBuilder<
|
|
522
|
+
T extends readonly (infer U)[]
|
|
523
|
+
? U
|
|
524
|
+
: T extends StringEnum
|
|
525
|
+
? T[keyof T]
|
|
526
|
+
: T extends NumberEnum
|
|
527
|
+
? T[keyof T]
|
|
528
|
+
: never
|
|
529
|
+
> {
|
|
530
|
+
let enumValues: readonly (string | number | boolean | null)[] | undefined
|
|
531
|
+
let baseType: EnumBaseType = 'other'
|
|
532
|
+
|
|
533
|
+
if (Array.isArray(input)) {
|
|
534
|
+
enumValues = input
|
|
535
|
+
if (isEveryItemNumber(input)) {
|
|
536
|
+
baseType = 'number'
|
|
537
|
+
} else if (isEveryItemString(input)) {
|
|
538
|
+
baseType = 'string'
|
|
539
|
+
}
|
|
540
|
+
} else if (typeof input === 'object') {
|
|
541
|
+
const enumType = getEnumType(input)
|
|
542
|
+
if (enumType === 'NumberEnum') {
|
|
543
|
+
enumValues = _numberEnumValues(input as NumberEnum)
|
|
544
|
+
baseType = 'number'
|
|
545
|
+
} else if (enumType === 'StringEnum') {
|
|
546
|
+
enumValues = _stringEnumValues(input as StringEnum)
|
|
547
|
+
baseType = 'string'
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
_assert(enumValues, 'Unsupported enum input')
|
|
552
|
+
return new JsonSchemaEnumBuilder(enumValues as any, baseType, opt)
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Use only with primitive values, otherwise this function will throw to avoid bugs.
|
|
557
|
+
* To validate objects, use `anyOfBy`.
|
|
558
|
+
*
|
|
559
|
+
* Our Ajv is configured to strip unexpected properties from objects,
|
|
560
|
+
* and since Ajv is mutating the input, this means that it cannot
|
|
561
|
+
* properly validate the same data over multiple schemas.
|
|
562
|
+
*
|
|
563
|
+
* Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
|
|
564
|
+
* Use `oneOf` when schemas are mutually exclusive.
|
|
565
|
+
*/
|
|
566
|
+
oneOf<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
|
|
567
|
+
items: [...B],
|
|
568
|
+
): JsonSchemaAnyBuilder<OUT, false> {
|
|
569
|
+
const schemas = items.map(b => b.build())
|
|
570
|
+
_assert(
|
|
571
|
+
schemas.every(hasNoObjectSchemas),
|
|
572
|
+
'Do not use `oneOf` validation with non-primitive types!',
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
return new JsonSchemaAnyBuilder<OUT, false>({
|
|
576
|
+
oneOf: schemas,
|
|
577
|
+
})
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Use only with primitive values, otherwise this function will throw to avoid bugs.
|
|
582
|
+
* To validate objects, use `anyOfBy` or `anyOfThese`.
|
|
583
|
+
*
|
|
584
|
+
* Our Ajv is configured to strip unexpected properties from objects,
|
|
585
|
+
* and since Ajv is mutating the input, this means that it cannot
|
|
586
|
+
* properly validate the same data over multiple schemas.
|
|
587
|
+
*
|
|
588
|
+
* Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
|
|
589
|
+
* Use `oneOf` when schemas are mutually exclusive.
|
|
590
|
+
*/
|
|
591
|
+
anyOf<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
|
|
592
|
+
items: [...B],
|
|
593
|
+
): JsonSchemaAnyBuilder<OUT, false> {
|
|
594
|
+
const schemas = items.map(b => b.build())
|
|
595
|
+
_assert(
|
|
596
|
+
schemas.every(hasNoObjectSchemas),
|
|
597
|
+
'Do not use `anyOf` validation with non-primitive types!',
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
return new JsonSchemaAnyBuilder<OUT, false>({
|
|
601
|
+
anyOf: schemas,
|
|
602
|
+
})
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Pick validation schema for an object based on the value of a specific property.
|
|
607
|
+
*
|
|
608
|
+
* ```
|
|
609
|
+
* const schemaMap = {
|
|
610
|
+
* true: successSchema,
|
|
611
|
+
* false: errorSchema
|
|
612
|
+
* }
|
|
613
|
+
*
|
|
614
|
+
* const schema = j.anyOfBy('success', schemaMap)
|
|
615
|
+
* ```
|
|
616
|
+
*/
|
|
617
|
+
anyOfBy<D extends Record<PropertyKey, JsonSchemaTerminal<any, any>>, OUT = AnyOfByOut<D>>(
|
|
618
|
+
propertyName: string,
|
|
619
|
+
schemaDictionary: D,
|
|
620
|
+
): JsonSchemaAnyOfByBuilder<OUT> {
|
|
621
|
+
return new JsonSchemaAnyOfByBuilder<OUT>(propertyName, schemaDictionary)
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Custom version of `anyOf` which - in contrast to the original function - does not mutate the input.
|
|
626
|
+
* This comes with a performance penalty, so do not use it where performance matters.
|
|
627
|
+
*
|
|
628
|
+
* ```
|
|
629
|
+
* const schema = j.anyOfThese([successSchema, errorSchema])
|
|
630
|
+
* ```
|
|
631
|
+
*/
|
|
632
|
+
anyOfThese<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
|
|
633
|
+
items: [...B],
|
|
634
|
+
): JsonSchemaAnyBuilder<OUT, false> {
|
|
635
|
+
return new JsonSchemaAnyBuilder<OUT, false>({
|
|
636
|
+
anyOfThese: items.map(b => b.build()),
|
|
637
|
+
})
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
and() {
|
|
641
|
+
return {
|
|
642
|
+
silentBob: () => {
|
|
643
|
+
throw new Error('...strike back!')
|
|
644
|
+
},
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
|
|
648
|
+
literal<const V extends string | number | boolean | null>(v: V) {
|
|
649
|
+
let baseType: EnumBaseType = 'other'
|
|
650
|
+
if (typeof v === 'string') baseType = 'string'
|
|
651
|
+
if (typeof v === 'number') baseType = 'number'
|
|
652
|
+
return new JsonSchemaEnumBuilder<V>([v], baseType)
|
|
653
|
+
},
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const TS_2500 = 16725225600 // 2500-01-01
|
|
657
|
+
const TS_2500_MILLIS = TS_2500 * 1000
|
|
658
|
+
const TS_2000 = 946684800 // 2000-01-01
|
|
659
|
+
const TS_2000_MILLIS = TS_2000 * 1000
|
|
660
|
+
|
|
661
|
+
/*
|
|
662
|
+
Notes for future reference
|
|
663
|
+
|
|
664
|
+
Q: Why do we need `Opt` - when `IN` and `OUT` already carries the `| undefined`?
|
|
665
|
+
A: Because of objects. Without `Opt`, an optional field would be inferred as `{ foo: string | undefined }`,
|
|
666
|
+
which means that the `foo` property would be mandatory, it's just that its value can be `undefined` as well.
|
|
667
|
+
With `Opt`, we can infer it as `{ foo?: string | undefined }`.
|
|
668
|
+
*/
|
|
669
|
+
|
|
670
|
+
export class JsonSchemaTerminal<OUT, Opt> {
|
|
671
|
+
protected [HIDDEN_AJV_SCHEMA]: AjvSchema<any> | undefined
|
|
672
|
+
protected schema: JsonSchema
|
|
673
|
+
|
|
674
|
+
constructor(schema: JsonSchema) {
|
|
675
|
+
this.schema = schema
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
get ajvSchema(): AjvSchema<any> {
|
|
679
|
+
if (!this[HIDDEN_AJV_SCHEMA]) {
|
|
680
|
+
this[HIDDEN_AJV_SCHEMA] = AjvSchema.create<OUT>(this)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return this[HIDDEN_AJV_SCHEMA]
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
getSchema(): JsonSchema {
|
|
687
|
+
return this.schema
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Produces a "clean schema object" without methods.
|
|
692
|
+
* Same as if it would be JSON.stringified.
|
|
693
|
+
*/
|
|
694
|
+
build(): JsonSchema<OUT> {
|
|
695
|
+
_assert(
|
|
696
|
+
!(this.schema.optionalField && this.schema.default !== undefined),
|
|
697
|
+
'.optional() and .default() should not be used together - the default value makes .optional() redundant and causes incorrect type inference',
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
const jsonSchema = _sortObject(
|
|
701
|
+
deepCopyPreservingFunctions(this.schema) as AnyObject,
|
|
702
|
+
JSON_SCHEMA_ORDER,
|
|
703
|
+
) as JsonSchema<OUT>
|
|
704
|
+
|
|
705
|
+
delete jsonSchema.optionalField
|
|
706
|
+
|
|
707
|
+
return jsonSchema
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
clone(): this {
|
|
711
|
+
const cloned = Object.create(Object.getPrototypeOf(this))
|
|
712
|
+
cloned.schema = deepCopyPreservingFunctions(this.schema)
|
|
713
|
+
return cloned
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
cloneAndUpdateSchema(schema: Partial<JsonSchema>): this {
|
|
717
|
+
const clone = this.clone()
|
|
718
|
+
_objectAssign(clone.schema, schema)
|
|
719
|
+
return clone
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
validate(input: unknown, opt?: AjvValidationOptions): OUT {
|
|
723
|
+
return this.ajvSchema.validate(input, opt)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
isValid(input: unknown, opt?: AjvValidationOptions): boolean {
|
|
727
|
+
return this.ajvSchema.isValid(input, opt)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
getValidationResult(
|
|
731
|
+
input: unknown,
|
|
732
|
+
opt: AjvValidationOptions = {},
|
|
733
|
+
): ValidationFunctionResult<OUT, AjvValidationError> {
|
|
734
|
+
return this.ajvSchema.getValidationResult(input, opt)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
|
|
738
|
+
return this.ajvSchema.getValidationFunction()
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Specify a function to be called after the normal validation is finished.
|
|
743
|
+
*
|
|
744
|
+
* This function will receive the validated, type-safe data, and you can use it
|
|
745
|
+
* to do further validations, e.g. conditional validations based on certain property values,
|
|
746
|
+
* or to do data modifications either by mutating the input or returning a new value.
|
|
747
|
+
*
|
|
748
|
+
* If you throw an error from this function, it will show up as an error in the validation.
|
|
749
|
+
*/
|
|
750
|
+
postValidation<OUT2 = OUT>(fn: PostValidatonFn<OUT, OUT2>): JsonSchemaTerminal<OUT2, Opt> {
|
|
751
|
+
const clone = this.cloneAndUpdateSchema({
|
|
752
|
+
postValidation: fn,
|
|
753
|
+
})
|
|
754
|
+
return clone as unknown as JsonSchemaTerminal<OUT2, Opt>
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* @experimental
|
|
759
|
+
*/
|
|
760
|
+
out!: OUT
|
|
761
|
+
opt!: Opt
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export class JsonSchemaAnyBuilder<OUT, Opt> extends JsonSchemaTerminal<OUT, Opt> {
|
|
765
|
+
protected setErrorMessage(ruleName: string, errorMessage: string | undefined): void {
|
|
766
|
+
if (_isUndefined(errorMessage)) return
|
|
767
|
+
|
|
768
|
+
this.schema.errorMessages ||= {}
|
|
769
|
+
this.schema.errorMessages[ruleName] = errorMessage
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* A helper function that takes a type parameter and compares it with the type inferred from the schema.
|
|
774
|
+
*
|
|
775
|
+
* When the type inferred from the schema differs from the passed-in type,
|
|
776
|
+
* the schema becomes unusable, by turning its type into `never`.
|
|
777
|
+
*
|
|
778
|
+
* ```ts
|
|
779
|
+
* const schemaGood = j.string().isOfType<string>() // ✅
|
|
780
|
+
*
|
|
781
|
+
* const schemaBad = j.string().isOfType<number>() // ❌
|
|
782
|
+
* schemaBad.build() // TypeError: property "build" does not exist on type "never"
|
|
783
|
+
*
|
|
784
|
+
* const result = ajvValidateRequest.body(req, schemaBad) // result will have `unknown` type
|
|
785
|
+
* ```
|
|
786
|
+
*/
|
|
787
|
+
isOfType<ExpectedType>(): ExactMatch<ExpectedType, OUT> extends true ? this : never {
|
|
788
|
+
return this.cloneAndUpdateSchema({ hasIsOfTypeCheck: true }) as any
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
$schema($schema: string): this {
|
|
792
|
+
return this.cloneAndUpdateSchema({ $schema })
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
$schemaDraft7(): this {
|
|
796
|
+
return this.$schema('http://json-schema.org/draft-07/schema#')
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
$id($id: string): this {
|
|
800
|
+
return this.cloneAndUpdateSchema({ $id })
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
title(title: string): this {
|
|
804
|
+
return this.cloneAndUpdateSchema({ title })
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
description(description: string): this {
|
|
808
|
+
return this.cloneAndUpdateSchema({ description })
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
deprecated(deprecated = true): this {
|
|
812
|
+
return this.cloneAndUpdateSchema({ deprecated })
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
type(type: string): this {
|
|
816
|
+
return this.cloneAndUpdateSchema({ type })
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
default(v: any): this {
|
|
820
|
+
return this.cloneAndUpdateSchema({ default: v })
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
instanceof(of: string): this {
|
|
824
|
+
return this.cloneAndUpdateSchema({ type: 'object', instanceof: of })
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
optional(): JsonSchemaAnyBuilder<OUT | undefined, true> {
|
|
828
|
+
const clone = this.cloneAndUpdateSchema({ optionalField: true })
|
|
829
|
+
return clone as unknown as JsonSchemaAnyBuilder<OUT | undefined, true>
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
nullable(): JsonSchemaAnyBuilder<OUT | null, Opt> {
|
|
833
|
+
return new JsonSchemaAnyBuilder({
|
|
834
|
+
anyOf: [this.build(), { type: 'null' }],
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* @deprecated
|
|
840
|
+
* The usage of this function is discouraged as it defeats the purpose of having type-safe validation.
|
|
841
|
+
*/
|
|
842
|
+
castAs<T>(): JsonSchemaAnyBuilder<T, Opt> {
|
|
843
|
+
return this as unknown as JsonSchemaAnyBuilder<T, Opt>
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Locks the given schema chain and no other modification can be done to it.
|
|
848
|
+
*/
|
|
849
|
+
final(): JsonSchemaTerminal<OUT, Opt> {
|
|
850
|
+
return new JsonSchemaTerminal<OUT, Opt>(this.schema)
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
*
|
|
855
|
+
* @param validator A validator function that returns an error message or undefined.
|
|
856
|
+
*
|
|
857
|
+
* You may add multiple custom validators and they will be executed in the order you added them.
|
|
858
|
+
*/
|
|
859
|
+
custom<OUT2 = OUT>(validator: CustomValidatorFn): JsonSchemaAnyBuilder<OUT2, Opt> {
|
|
860
|
+
const { customValidations = [] } = this.schema
|
|
861
|
+
return this.cloneAndUpdateSchema({
|
|
862
|
+
customValidations: [...customValidations, validator],
|
|
863
|
+
}) as unknown as JsonSchemaAnyBuilder<OUT2, Opt>
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
*
|
|
868
|
+
* @param converter A converter function that returns a new value.
|
|
869
|
+
*
|
|
870
|
+
* You may add multiple converters and they will be executed in the order you added them,
|
|
871
|
+
* each converter receiving the result from the previous one.
|
|
872
|
+
*
|
|
873
|
+
* This feature only works when the current schema is nested in an object or array schema,
|
|
874
|
+
* due to how mutability works in Ajv.
|
|
875
|
+
*/
|
|
876
|
+
convert<OUT2>(converter: CustomConverterFn<OUT2>): JsonSchemaAnyBuilder<OUT2, Opt> {
|
|
877
|
+
const { customConversions = [] } = this.schema
|
|
878
|
+
return this.cloneAndUpdateSchema({
|
|
879
|
+
customConversions: [...customConversions, converter],
|
|
880
|
+
}) as unknown as JsonSchemaAnyBuilder<OUT2, Opt>
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export class JsonSchemaStringBuilder<
|
|
885
|
+
OUT extends string | undefined = string,
|
|
886
|
+
Opt extends boolean = false,
|
|
887
|
+
> extends JsonSchemaAnyBuilder<OUT, Opt> {
|
|
888
|
+
constructor() {
|
|
889
|
+
super({
|
|
890
|
+
type: 'string',
|
|
891
|
+
})
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* @param optionalValues List of values that should be considered/converted as `undefined`.
|
|
896
|
+
*
|
|
897
|
+
* This `optionalValues` feature only works when the current schema is nested in an object or array schema,
|
|
898
|
+
* due to how mutability works in Ajv.
|
|
899
|
+
*
|
|
900
|
+
* Make sure this `optional()` call is at the end of your call chain.
|
|
901
|
+
*
|
|
902
|
+
* When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
|
|
903
|
+
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
|
|
904
|
+
*/
|
|
905
|
+
override optional<T extends readonly (string | null)[] | undefined = undefined>(
|
|
906
|
+
optionalValues?: T,
|
|
907
|
+
): T extends readonly (infer U)[]
|
|
908
|
+
? null extends U
|
|
909
|
+
? JsonSchemaTerminal<OUT | undefined, true>
|
|
910
|
+
: JsonSchemaStringBuilder<OUT | undefined, true>
|
|
911
|
+
: JsonSchemaStringBuilder<OUT | undefined, true> {
|
|
912
|
+
if (!optionalValues) {
|
|
913
|
+
return super.optional() as any
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
_typeCast<(string | null)[]>(optionalValues)
|
|
917
|
+
|
|
918
|
+
let newBuilder: JsonSchemaTerminal<OUT | undefined, true> = new JsonSchemaStringBuilder<
|
|
919
|
+
OUT,
|
|
920
|
+
Opt
|
|
921
|
+
>().optional()
|
|
922
|
+
const alternativesSchema = j.enum(optionalValues)
|
|
923
|
+
Object.assign(newBuilder.getSchema(), {
|
|
924
|
+
anyOf: [this.build(), alternativesSchema.build()],
|
|
925
|
+
optionalValues,
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
|
|
929
|
+
// so we must allow `null` values to be parsed by Ajv,
|
|
930
|
+
// but the typing should not reflect that.
|
|
931
|
+
// We also cannot accept more rules attached, since we're not building a StringSchema anymore.
|
|
932
|
+
if (optionalValues.includes(null)) {
|
|
933
|
+
newBuilder = new JsonSchemaTerminal({
|
|
934
|
+
anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
|
|
935
|
+
optionalField: true,
|
|
936
|
+
})
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return newBuilder as any
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this {
|
|
943
|
+
_assert(
|
|
944
|
+
!pattern.flags,
|
|
945
|
+
`Regex flags are not supported by JSON Schema. Received: /${pattern.source}/${pattern.flags}`,
|
|
946
|
+
)
|
|
947
|
+
return this.pattern(pattern.source, opt)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
pattern(pattern: string, opt?: JsonBuilderRuleOpt): this {
|
|
951
|
+
const clone = this.cloneAndUpdateSchema({ pattern })
|
|
952
|
+
if (opt?.name) clone.setErrorMessage('pattern', `is not a valid ${opt.name}`)
|
|
953
|
+
if (opt?.msg) clone.setErrorMessage('pattern', opt.msg)
|
|
954
|
+
return clone
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
minLength(minLength: number): this {
|
|
958
|
+
return this.cloneAndUpdateSchema({ minLength })
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
maxLength(maxLength: number): this {
|
|
962
|
+
return this.cloneAndUpdateSchema({ maxLength })
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
length(exactLength: number): this
|
|
966
|
+
length(minLength: number, maxLength: number): this
|
|
967
|
+
length(minLengthOrExactLength: number, maxLength?: number): this {
|
|
968
|
+
const maxLengthActual = maxLength ?? minLengthOrExactLength
|
|
969
|
+
return this.minLength(minLengthOrExactLength).maxLength(maxLengthActual)
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
email(opt?: Partial<JsonSchemaStringEmailOptions>): this {
|
|
973
|
+
const defaultOptions: JsonSchemaStringEmailOptions = { checkTLD: true }
|
|
974
|
+
return this.cloneAndUpdateSchema({ email: { ...defaultOptions, ...opt } })
|
|
975
|
+
.trim()
|
|
976
|
+
.toLowerCase()
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
trim(): this {
|
|
980
|
+
return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, trim: true } })
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
toLowerCase(): this {
|
|
984
|
+
return this.cloneAndUpdateSchema({
|
|
985
|
+
transform: { ...this.schema.transform, toLowerCase: true },
|
|
986
|
+
})
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
toUpperCase(): this {
|
|
990
|
+
return this.cloneAndUpdateSchema({
|
|
991
|
+
transform: { ...this.schema.transform, toUpperCase: true },
|
|
992
|
+
})
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
truncate(toLength: number): this {
|
|
996
|
+
return this.cloneAndUpdateSchema({
|
|
997
|
+
transform: { ...this.schema.transform, truncate: toLength },
|
|
998
|
+
})
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
branded<B extends string>(): JsonSchemaStringBuilder<B, Opt> {
|
|
1002
|
+
return this as unknown as JsonSchemaStringBuilder<B, Opt>
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Validates that the input is a fully-specified YYYY-MM-DD formatted valid IsoDate value.
|
|
1007
|
+
*
|
|
1008
|
+
* All previous expectations in the schema chain are dropped - including `.optional()` -
|
|
1009
|
+
* because this call effectively starts a new schema chain.
|
|
1010
|
+
*/
|
|
1011
|
+
isoDate(): JsonSchemaIsoDateBuilder {
|
|
1012
|
+
return new JsonSchemaIsoDateBuilder()
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
isoDateTime(): JsonSchemaStringBuilder<IsoDateTime, Opt> {
|
|
1016
|
+
return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded<IsoDateTime>()
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
isoMonth(): JsonSchemaIsoMonthBuilder {
|
|
1020
|
+
return new JsonSchemaIsoMonthBuilder()
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Validates the string format to be JWT.
|
|
1025
|
+
* Expects the JWT to be signed!
|
|
1026
|
+
*/
|
|
1027
|
+
jwt(): this {
|
|
1028
|
+
return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' })
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
url(): this {
|
|
1032
|
+
return this.regex(URL_REGEX, { msg: 'is not a valid URL format' })
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
ipv4(): this {
|
|
1036
|
+
return this.regex(IPV4_REGEX, { msg: 'is not a valid IPv4 format' })
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
ipv6(): this {
|
|
1040
|
+
return this.regex(IPV6_REGEX, { msg: 'is not a valid IPv6 format' })
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
slug(): this {
|
|
1044
|
+
return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' })
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
semVer(): this {
|
|
1048
|
+
return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' })
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
languageTag(): this {
|
|
1052
|
+
return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' })
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
countryCode(): this {
|
|
1056
|
+
return this.regex(COUNTRY_CODE_REGEX, { msg: 'is not a valid country code format' })
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
currency(): this {
|
|
1060
|
+
return this.regex(CURRENCY_REGEX, { msg: 'is not a valid currency format' })
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Validates that the input is a valid IANATimzone value.
|
|
1065
|
+
*
|
|
1066
|
+
* All previous expectations in the schema chain are dropped - including `.optional()` -
|
|
1067
|
+
* because this call effectively starts a new schema chain as an `enum` validation.
|
|
1068
|
+
*/
|
|
1069
|
+
ianaTimezone(): JsonSchemaEnumBuilder<IANATimezone, false> {
|
|
1070
|
+
// UTC is added to assist unit-testing, which uses UTC by default (not technically a valid Iana timezone identifier)
|
|
1071
|
+
return j.enum(TIMEZONES, { msg: 'is an invalid IANA timezone' }).branded<IANATimezone>()
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
base64Url(): this {
|
|
1075
|
+
return this.regex(BASE64URL_REGEX, {
|
|
1076
|
+
msg: 'contains characters not allowed in Base64 URL characterset',
|
|
1077
|
+
})
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
uuid(): this {
|
|
1081
|
+
return this.regex(UUID_REGEX, { msg: 'is an invalid UUID' })
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
export interface JsonSchemaStringEmailOptions {
|
|
1086
|
+
checkTLD: boolean
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
export class JsonSchemaIsoDateBuilder<Opt extends boolean = false> extends JsonSchemaAnyBuilder<
|
|
1090
|
+
IsoDate,
|
|
1091
|
+
Opt
|
|
1092
|
+
> {
|
|
1093
|
+
constructor() {
|
|
1094
|
+
super({
|
|
1095
|
+
type: 'string',
|
|
1096
|
+
IsoDate: {},
|
|
1097
|
+
})
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
|
|
1102
|
+
*
|
|
1103
|
+
* This `null` feature only works when the current schema is nested in an object or array schema,
|
|
1104
|
+
* due to how mutability works in Ajv.
|
|
1105
|
+
*
|
|
1106
|
+
* When `null` is passed, the return type becomes `JsonSchemaTerminal`
|
|
1107
|
+
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
|
|
1108
|
+
*/
|
|
1109
|
+
override optional<N extends null | undefined = undefined>(
|
|
1110
|
+
nullValue?: N,
|
|
1111
|
+
): N extends null
|
|
1112
|
+
? JsonSchemaTerminal<IsoDate | undefined, true>
|
|
1113
|
+
: JsonSchemaAnyBuilder<IsoDate | undefined, true> {
|
|
1114
|
+
if (nullValue === undefined) {
|
|
1115
|
+
return super.optional() as any
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
|
|
1119
|
+
// so we must allow `null` values to be parsed by Ajv,
|
|
1120
|
+
// but the typing should not reflect that.
|
|
1121
|
+
// We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
|
|
1122
|
+
return new JsonSchemaTerminal({
|
|
1123
|
+
anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
|
|
1124
|
+
optionalField: true,
|
|
1125
|
+
}) as any
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
before(date: string): this {
|
|
1129
|
+
return this.cloneAndUpdateSchema({ IsoDate: { before: date } })
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
sameOrBefore(date: string): this {
|
|
1133
|
+
return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } })
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
after(date: string): this {
|
|
1137
|
+
return this.cloneAndUpdateSchema({ IsoDate: { after: date } })
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
sameOrAfter(date: string): this {
|
|
1141
|
+
return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } })
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
between(fromDate: string, toDate: string, incl: Inclusiveness): this {
|
|
1145
|
+
let schemaPatch: Partial<JsonSchema> = {}
|
|
1146
|
+
|
|
1147
|
+
if (incl === '[)') {
|
|
1148
|
+
schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } }
|
|
1149
|
+
} else if (incl === '[]') {
|
|
1150
|
+
schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } }
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
return this.cloneAndUpdateSchema(schemaPatch)
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
export interface JsonSchemaIsoDateOptions {
|
|
1158
|
+
before?: string
|
|
1159
|
+
sameOrBefore?: string
|
|
1160
|
+
after?: string
|
|
1161
|
+
sameOrAfter?: string
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
export class JsonSchemaIsoMonthBuilder<Opt extends boolean = false> extends JsonSchemaAnyBuilder<
|
|
1165
|
+
IsoMonth,
|
|
1166
|
+
Opt
|
|
1167
|
+
> {
|
|
1168
|
+
constructor() {
|
|
1169
|
+
super({
|
|
1170
|
+
type: 'string',
|
|
1171
|
+
IsoMonth: {},
|
|
1172
|
+
})
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
export interface JsonSchemaIsoMonthOptions {}
|
|
1177
|
+
|
|
1178
|
+
export class JsonSchemaNumberBuilder<
|
|
1179
|
+
OUT extends number | undefined = number,
|
|
1180
|
+
Opt extends boolean = false,
|
|
1181
|
+
> extends JsonSchemaAnyBuilder<OUT, Opt> {
|
|
1182
|
+
constructor() {
|
|
1183
|
+
super({
|
|
1184
|
+
type: 'number',
|
|
1185
|
+
})
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* @param optionalValues List of values that should be considered/converted as `undefined`.
|
|
1190
|
+
*
|
|
1191
|
+
* This `optionalValues` feature only works when the current schema is nested in an object or array schema,
|
|
1192
|
+
* due to how mutability works in Ajv.
|
|
1193
|
+
*
|
|
1194
|
+
* Make sure this `optional()` call is at the end of your call chain.
|
|
1195
|
+
*
|
|
1196
|
+
* When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
|
|
1197
|
+
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
|
|
1198
|
+
*/
|
|
1199
|
+
override optional<T extends readonly (number | null)[] | undefined = undefined>(
|
|
1200
|
+
optionalValues?: T,
|
|
1201
|
+
): T extends readonly (infer U)[]
|
|
1202
|
+
? null extends U
|
|
1203
|
+
? JsonSchemaTerminal<OUT | undefined, true>
|
|
1204
|
+
: JsonSchemaNumberBuilder<OUT | undefined, true>
|
|
1205
|
+
: JsonSchemaNumberBuilder<OUT | undefined, true> {
|
|
1206
|
+
if (!optionalValues) {
|
|
1207
|
+
return super.optional() as any
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
_typeCast<(number | null)[]>(optionalValues)
|
|
1211
|
+
|
|
1212
|
+
let newBuilder: JsonSchemaTerminal<OUT | undefined, true> = new JsonSchemaNumberBuilder<
|
|
1213
|
+
OUT,
|
|
1214
|
+
Opt
|
|
1215
|
+
>().optional()
|
|
1216
|
+
const alternativesSchema = j.enum(optionalValues)
|
|
1217
|
+
Object.assign(newBuilder.getSchema(), {
|
|
1218
|
+
anyOf: [this.build(), alternativesSchema.build()],
|
|
1219
|
+
optionalValues,
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
|
|
1223
|
+
// so we must allow `null` values to be parsed by Ajv,
|
|
1224
|
+
// but the typing should not reflect that.
|
|
1225
|
+
// We also cannot accept more rules attached, since we're not building a NumberSchema anymore.
|
|
1226
|
+
if (optionalValues.includes(null)) {
|
|
1227
|
+
newBuilder = new JsonSchemaTerminal({
|
|
1228
|
+
anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
|
|
1229
|
+
optionalField: true,
|
|
1230
|
+
})
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
return newBuilder as any
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
integer(): this {
|
|
1237
|
+
return this.cloneAndUpdateSchema({ type: 'integer' })
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
branded<B extends number>(): JsonSchemaNumberBuilder<B, Opt> {
|
|
1241
|
+
return this as unknown as JsonSchemaNumberBuilder<B, Opt>
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
multipleOf(multipleOf: number): this {
|
|
1245
|
+
return this.cloneAndUpdateSchema({ multipleOf })
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
min(minimum: number): this {
|
|
1249
|
+
return this.cloneAndUpdateSchema({ minimum })
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
exclusiveMin(exclusiveMinimum: number): this {
|
|
1253
|
+
return this.cloneAndUpdateSchema({ exclusiveMinimum })
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
max(maximum: number): this {
|
|
1257
|
+
return this.cloneAndUpdateSchema({ maximum })
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
exclusiveMax(exclusiveMaximum: number): this {
|
|
1261
|
+
return this.cloneAndUpdateSchema({ exclusiveMaximum })
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
lessThan(value: number): this {
|
|
1265
|
+
return this.exclusiveMax(value)
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
lessThanOrEqual(value: number): this {
|
|
1269
|
+
return this.max(value)
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
moreThan(value: number): this {
|
|
1273
|
+
return this.exclusiveMin(value)
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
moreThanOrEqual(value: number): this {
|
|
1277
|
+
return this.min(value)
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
equal(value: number): this {
|
|
1281
|
+
return this.min(value).max(value)
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
range(minimum: number, maximum: number, incl: Inclusiveness): this {
|
|
1285
|
+
if (incl === '[)') {
|
|
1286
|
+
return this.moreThanOrEqual(minimum).lessThan(maximum)
|
|
1287
|
+
}
|
|
1288
|
+
return this.moreThanOrEqual(minimum).lessThanOrEqual(maximum)
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
int32(): this {
|
|
1292
|
+
const MIN_INT32 = -(2 ** 31)
|
|
1293
|
+
const MAX_INT32 = 2 ** 31 - 1
|
|
1294
|
+
const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
|
|
1295
|
+
const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
|
|
1296
|
+
const newMin = Math.max(MIN_INT32, currentMin)
|
|
1297
|
+
const newMax = Math.min(MAX_INT32, currentMax)
|
|
1298
|
+
return this.integer().min(newMin).max(newMax)
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
int64(): this {
|
|
1302
|
+
const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
|
|
1303
|
+
const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
|
|
1304
|
+
const newMin = Math.max(Number.MIN_SAFE_INTEGER, currentMin)
|
|
1305
|
+
const newMax = Math.min(Number.MAX_SAFE_INTEGER, currentMax)
|
|
1306
|
+
return this.integer().min(newMin).max(newMax)
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
float(): this {
|
|
1310
|
+
return this
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
double(): this {
|
|
1314
|
+
return this
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
unixTimestamp(): JsonSchemaNumberBuilder<UnixTimestamp, Opt> {
|
|
1318
|
+
return this.integer().min(0).max(TS_2500).branded<UnixTimestamp>()
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
unixTimestamp2000(): JsonSchemaNumberBuilder<UnixTimestamp, Opt> {
|
|
1322
|
+
return this.integer().min(TS_2000).max(TS_2500).branded<UnixTimestamp>()
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
unixTimestampMillis(): JsonSchemaNumberBuilder<UnixTimestampMillis, Opt> {
|
|
1326
|
+
return this.integer().min(0).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
unixTimestamp2000Millis(): JsonSchemaNumberBuilder<UnixTimestampMillis, Opt> {
|
|
1330
|
+
return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
utcOffset(): this {
|
|
1334
|
+
return this.integer()
|
|
1335
|
+
.multipleOf(15)
|
|
1336
|
+
.min(-12 * 60)
|
|
1337
|
+
.max(14 * 60)
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
utcOffsetHour(): this {
|
|
1341
|
+
return this.integer().min(-12).max(14)
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Specify the precision of the floating point numbers by the number of digits after the ".".
|
|
1346
|
+
* Excess digits will be cut-off when the current schema is nested in an object or array schema,
|
|
1347
|
+
* due to how mutability works in Ajv.
|
|
1348
|
+
*/
|
|
1349
|
+
precision(numberOfDigits: number): this {
|
|
1350
|
+
return this.cloneAndUpdateSchema({ precision: numberOfDigits })
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
export class JsonSchemaBooleanBuilder<
|
|
1355
|
+
OUT extends boolean | undefined = boolean,
|
|
1356
|
+
Opt extends boolean = false,
|
|
1357
|
+
> extends JsonSchemaAnyBuilder<OUT, Opt> {
|
|
1358
|
+
constructor() {
|
|
1359
|
+
super({
|
|
1360
|
+
type: 'boolean',
|
|
1361
|
+
})
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* @param optionalValue One of the two possible boolean values that should be considered/converted as `undefined`.
|
|
1366
|
+
*
|
|
1367
|
+
* This `optionalValue` feature only works when the current schema is nested in an object or array schema,
|
|
1368
|
+
* due to how mutability works in Ajv.
|
|
1369
|
+
*/
|
|
1370
|
+
override optional(optionalValue?: boolean): JsonSchemaBooleanBuilder<OUT | undefined, true> {
|
|
1371
|
+
if (typeof optionalValue === 'undefined') {
|
|
1372
|
+
return super.optional() as unknown as JsonSchemaBooleanBuilder<OUT | undefined, true>
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const newBuilder = new JsonSchemaBooleanBuilder<OUT, Opt>().optional()
|
|
1376
|
+
const alternativesSchema = j.enum([optionalValue])
|
|
1377
|
+
Object.assign(newBuilder.getSchema(), {
|
|
1378
|
+
anyOf: [this.build(), alternativesSchema.build()],
|
|
1379
|
+
optionalValues: [optionalValue],
|
|
1380
|
+
})
|
|
1381
|
+
|
|
1382
|
+
return newBuilder
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
export class JsonSchemaObjectBuilder<
|
|
1387
|
+
OUT extends AnyObject,
|
|
1388
|
+
Opt extends boolean = false,
|
|
1389
|
+
> extends JsonSchemaAnyBuilder<OUT, Opt> {
|
|
1390
|
+
constructor(props?: AnyObject, opt?: JsonSchemaObjectBuilderOpts) {
|
|
1391
|
+
super({
|
|
1392
|
+
type: 'object',
|
|
1393
|
+
properties: {},
|
|
1394
|
+
required: [],
|
|
1395
|
+
additionalProperties: false,
|
|
1396
|
+
hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true,
|
|
1397
|
+
patternProperties: opt?.patternProperties ?? undefined,
|
|
1398
|
+
keySchema: opt?.keySchema ?? undefined,
|
|
1399
|
+
})
|
|
1400
|
+
|
|
1401
|
+
if (props) this.addProperties(props)
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
addProperties(props: AnyObject): this {
|
|
1405
|
+
const properties: Record<string, JsonSchema> = {}
|
|
1406
|
+
const required: string[] = []
|
|
1407
|
+
|
|
1408
|
+
for (const [key, builder] of Object.entries(props)) {
|
|
1409
|
+
const isOptional = (builder as JsonSchemaTerminal<any, any>).getSchema().optionalField
|
|
1410
|
+
if (!isOptional) {
|
|
1411
|
+
required.push(key)
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const schema = builder.build()
|
|
1415
|
+
properties[key] = schema
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
this.schema.properties = properties
|
|
1419
|
+
this.schema.required = _uniq(required).sort()
|
|
1420
|
+
|
|
1421
|
+
return this
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
|
|
1426
|
+
*
|
|
1427
|
+
* This `null` feature only works when the current schema is nested in an object or array schema,
|
|
1428
|
+
* due to how mutability works in Ajv.
|
|
1429
|
+
*
|
|
1430
|
+
* When `null` is passed, the return type becomes `JsonSchemaTerminal`
|
|
1431
|
+
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
|
|
1432
|
+
*/
|
|
1433
|
+
override optional<N extends null | undefined = undefined>(
|
|
1434
|
+
nullValue?: N,
|
|
1435
|
+
): N extends null
|
|
1436
|
+
? JsonSchemaTerminal<OUT | undefined, true>
|
|
1437
|
+
: JsonSchemaAnyBuilder<OUT | undefined, true> {
|
|
1438
|
+
if (nullValue === undefined) {
|
|
1439
|
+
return super.optional() as any
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
|
|
1443
|
+
// so we must allow `null` values to be parsed by Ajv,
|
|
1444
|
+
// but the typing should not reflect that.
|
|
1445
|
+
// We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
|
|
1446
|
+
return new JsonSchemaTerminal({
|
|
1447
|
+
anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
|
|
1448
|
+
optionalField: true,
|
|
1449
|
+
}) as any
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* When set, the validation will not strip away properties that are not specified explicitly in the schema.
|
|
1454
|
+
*/
|
|
1455
|
+
|
|
1456
|
+
allowAdditionalProperties(): this {
|
|
1457
|
+
return this.cloneAndUpdateSchema({ additionalProperties: true })
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
extend<P extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
|
|
1461
|
+
props: P,
|
|
1462
|
+
): JsonSchemaObjectBuilder<
|
|
1463
|
+
Override<
|
|
1464
|
+
OUT,
|
|
1465
|
+
{
|
|
1466
|
+
// required keys
|
|
1467
|
+
[K in keyof P as P[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
|
|
1468
|
+
? IsOpt extends true
|
|
1469
|
+
? never
|
|
1470
|
+
: K
|
|
1471
|
+
: never]: P[K] extends JsonSchemaAnyBuilder<infer OUT2, any> ? OUT2 : never
|
|
1472
|
+
} & {
|
|
1473
|
+
// optional keys
|
|
1474
|
+
[K in keyof P as P[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
|
|
1475
|
+
? IsOpt extends true
|
|
1476
|
+
? K
|
|
1477
|
+
: never
|
|
1478
|
+
: never]?: P[K] extends JsonSchemaAnyBuilder<infer OUT2, any> ? OUT2 : never
|
|
1479
|
+
}
|
|
1480
|
+
>,
|
|
1481
|
+
false
|
|
1482
|
+
> {
|
|
1483
|
+
const newBuilder = new JsonSchemaObjectBuilder()
|
|
1484
|
+
_objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
|
|
1485
|
+
|
|
1486
|
+
const incomingSchemaBuilder = new JsonSchemaObjectBuilder(props)
|
|
1487
|
+
mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
|
|
1488
|
+
|
|
1489
|
+
_objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
|
|
1490
|
+
|
|
1491
|
+
return newBuilder as any
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Concatenates another schema to the current schema.
|
|
1496
|
+
*
|
|
1497
|
+
* It expects you to use `isOfType<T>()` in the chain,
|
|
1498
|
+
* otherwise the validation will throw. This is to ensure
|
|
1499
|
+
* that the schemas you concatenated match the intended final type.
|
|
1500
|
+
*
|
|
1501
|
+
* ```ts
|
|
1502
|
+
* interface Foo { foo: string }
|
|
1503
|
+
* const fooSchema = j.object<Foo>({ foo: j.string() })
|
|
1504
|
+
*
|
|
1505
|
+
* interface Bar { bar: number }
|
|
1506
|
+
* const barSchema = j.object<Bar>({ bar: j.number() })
|
|
1507
|
+
*
|
|
1508
|
+
* interface Shu { foo: string, bar: number }
|
|
1509
|
+
* const shuSchema = fooSchema.concat(barSchema).isOfType<Shu>() // important
|
|
1510
|
+
* ```
|
|
1511
|
+
*/
|
|
1512
|
+
concat<OUT2 extends AnyObject>(
|
|
1513
|
+
other: JsonSchemaObjectBuilder<OUT2, any>,
|
|
1514
|
+
): JsonSchemaObjectBuilder<OUT & OUT2, false> {
|
|
1515
|
+
const clone = this.clone()
|
|
1516
|
+
mergeJsonSchemaObjects(clone.schema as any, other.schema as any)
|
|
1517
|
+
_objectAssign(clone.schema, { hasIsOfTypeCheck: false })
|
|
1518
|
+
return clone as unknown as JsonSchemaObjectBuilder<OUT & OUT2, false>
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
|
|
1523
|
+
*/
|
|
1524
|
+
// oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
|
|
1525
|
+
dbEntity() {
|
|
1526
|
+
return this.extend({
|
|
1527
|
+
id: j.string(),
|
|
1528
|
+
created: j.number().unixTimestamp2000(),
|
|
1529
|
+
updated: j.number().unixTimestamp2000(),
|
|
1530
|
+
})
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
minProperties(minProperties: number): this {
|
|
1534
|
+
return this.cloneAndUpdateSchema({ minProperties, minProperties2: minProperties })
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
maxProperties(maxProperties: number): this {
|
|
1538
|
+
return this.cloneAndUpdateSchema({ maxProperties })
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
exclusiveProperties(propNames: readonly (keyof OUT & string)[]): this {
|
|
1542
|
+
const exclusiveProperties = this.schema.exclusiveProperties ?? []
|
|
1543
|
+
return this.cloneAndUpdateSchema({ exclusiveProperties: [...exclusiveProperties, propNames] })
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
interface JsonSchemaObjectBuilderOpts {
|
|
1548
|
+
hasIsOfTypeCheck?: false
|
|
1549
|
+
patternProperties?: StringMap<JsonSchema<any>>
|
|
1550
|
+
keySchema?: JsonSchema
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
export class JsonSchemaObjectInferringBuilder<
|
|
1554
|
+
PROPS extends Record<string, JsonSchemaAnyBuilder<any, any>>,
|
|
1555
|
+
Opt extends boolean = false,
|
|
1556
|
+
> extends JsonSchemaAnyBuilder<
|
|
1557
|
+
Expand<
|
|
1558
|
+
{
|
|
1559
|
+
[K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
|
|
1560
|
+
? IsOpt extends true
|
|
1561
|
+
? never
|
|
1562
|
+
: K
|
|
1563
|
+
: never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
|
|
1564
|
+
} & {
|
|
1565
|
+
[K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
|
|
1566
|
+
? IsOpt extends true
|
|
1567
|
+
? K
|
|
1568
|
+
: never
|
|
1569
|
+
: never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
|
|
1570
|
+
}
|
|
1571
|
+
>,
|
|
1572
|
+
Opt
|
|
1573
|
+
> {
|
|
1574
|
+
constructor(props?: PROPS) {
|
|
1575
|
+
super({
|
|
1576
|
+
type: 'object',
|
|
1577
|
+
properties: {},
|
|
1578
|
+
required: [],
|
|
1579
|
+
additionalProperties: false,
|
|
1580
|
+
})
|
|
1581
|
+
|
|
1582
|
+
if (props) this.addProperties(props)
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
addProperties(props: PROPS): this {
|
|
1586
|
+
const properties: Record<string, JsonSchema> = {}
|
|
1587
|
+
const required: string[] = []
|
|
1588
|
+
|
|
1589
|
+
for (const [key, builder] of Object.entries(props)) {
|
|
1590
|
+
const isOptional = (builder as JsonSchemaTerminal<any, any>).getSchema().optionalField
|
|
1591
|
+
if (!isOptional) {
|
|
1592
|
+
required.push(key)
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const schema = builder.build()
|
|
1596
|
+
properties[key] = schema
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
this.schema.properties = properties
|
|
1600
|
+
this.schema.required = _uniq(required).sort()
|
|
1601
|
+
|
|
1602
|
+
return this
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
|
|
1607
|
+
*
|
|
1608
|
+
* This `null` feature only works when the current schema is nested in an object or array schema,
|
|
1609
|
+
* due to how mutability works in Ajv.
|
|
1610
|
+
*
|
|
1611
|
+
* When `null` is passed, the return type becomes `JsonSchemaTerminal`
|
|
1612
|
+
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
|
|
1613
|
+
*/
|
|
1614
|
+
// @ts-expect-error override adds optional parameter which is compatible but TS can't verify complex mapped types
|
|
1615
|
+
override optional<N extends null | undefined = undefined>(
|
|
1616
|
+
nullValue?: N,
|
|
1617
|
+
): N extends null
|
|
1618
|
+
? JsonSchemaTerminal<
|
|
1619
|
+
| Expand<
|
|
1620
|
+
{
|
|
1621
|
+
[K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
|
|
1622
|
+
? IsOpt extends true
|
|
1623
|
+
? never
|
|
1624
|
+
: K
|
|
1625
|
+
: never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
|
|
1626
|
+
} & {
|
|
1627
|
+
[K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
|
|
1628
|
+
? IsOpt extends true
|
|
1629
|
+
? K
|
|
1630
|
+
: never
|
|
1631
|
+
: never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
|
|
1632
|
+
}
|
|
1633
|
+
>
|
|
1634
|
+
| undefined,
|
|
1635
|
+
true
|
|
1636
|
+
>
|
|
1637
|
+
: JsonSchemaAnyBuilder<
|
|
1638
|
+
| Expand<
|
|
1639
|
+
{
|
|
1640
|
+
[K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
|
|
1641
|
+
? IsOpt extends true
|
|
1642
|
+
? never
|
|
1643
|
+
: K
|
|
1644
|
+
: never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
|
|
1645
|
+
} & {
|
|
1646
|
+
[K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
|
|
1647
|
+
? IsOpt extends true
|
|
1648
|
+
? K
|
|
1649
|
+
: never
|
|
1650
|
+
: never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
|
|
1651
|
+
}
|
|
1652
|
+
>
|
|
1653
|
+
| undefined,
|
|
1654
|
+
true
|
|
1655
|
+
> {
|
|
1656
|
+
if (nullValue === undefined) {
|
|
1657
|
+
return super.optional() as any
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
|
|
1661
|
+
// so we must allow `null` values to be parsed by Ajv,
|
|
1662
|
+
// but the typing should not reflect that.
|
|
1663
|
+
// We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
|
|
1664
|
+
return new JsonSchemaTerminal({
|
|
1665
|
+
anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
|
|
1666
|
+
optionalField: true,
|
|
1667
|
+
}) as any
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/**
|
|
1671
|
+
* When set, the validation will not strip away properties that are not specified explicitly in the schema.
|
|
1672
|
+
*/
|
|
1673
|
+
|
|
1674
|
+
allowAdditionalProperties(): this {
|
|
1675
|
+
return this.cloneAndUpdateSchema({ additionalProperties: true })
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
extend<NEW_PROPS extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
|
|
1679
|
+
props: NEW_PROPS,
|
|
1680
|
+
): JsonSchemaObjectInferringBuilder<
|
|
1681
|
+
{
|
|
1682
|
+
[K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
|
|
1683
|
+
? NEW_PROPS[K]
|
|
1684
|
+
: K extends keyof PROPS
|
|
1685
|
+
? PROPS[K]
|
|
1686
|
+
: never
|
|
1687
|
+
},
|
|
1688
|
+
Opt
|
|
1689
|
+
> {
|
|
1690
|
+
const newBuilder = new JsonSchemaObjectInferringBuilder<PROPS, Opt>()
|
|
1691
|
+
_objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
|
|
1692
|
+
|
|
1693
|
+
const incomingSchemaBuilder = new JsonSchemaObjectInferringBuilder<NEW_PROPS, false>(props)
|
|
1694
|
+
mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
|
|
1695
|
+
|
|
1696
|
+
// This extend function is not type-safe as it is inferring,
|
|
1697
|
+
// so even if the base schema was already type-checked,
|
|
1698
|
+
// the new schema loses that quality.
|
|
1699
|
+
_objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
|
|
1700
|
+
|
|
1701
|
+
return newBuilder as unknown as JsonSchemaObjectInferringBuilder<
|
|
1702
|
+
{
|
|
1703
|
+
[K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
|
|
1704
|
+
? NEW_PROPS[K]
|
|
1705
|
+
: K extends keyof PROPS
|
|
1706
|
+
? PROPS[K]
|
|
1707
|
+
: never
|
|
1708
|
+
},
|
|
1709
|
+
Opt
|
|
1710
|
+
>
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
|
|
1715
|
+
*/
|
|
1716
|
+
// oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
|
|
1717
|
+
dbEntity() {
|
|
1718
|
+
return this.extend({
|
|
1719
|
+
id: j.string(),
|
|
1720
|
+
created: j.number().unixTimestamp2000(),
|
|
1721
|
+
updated: j.number().unixTimestamp2000(),
|
|
1722
|
+
})
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
export class JsonSchemaArrayBuilder<OUT, Opt> extends JsonSchemaAnyBuilder<OUT[], Opt> {
|
|
1727
|
+
constructor(itemsSchema: JsonSchemaAnyBuilder<OUT, Opt>) {
|
|
1728
|
+
super({
|
|
1729
|
+
type: 'array',
|
|
1730
|
+
items: itemsSchema.build(),
|
|
1731
|
+
})
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
minLength(minItems: number): this {
|
|
1735
|
+
return this.cloneAndUpdateSchema({ minItems })
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
maxLength(maxItems: number): this {
|
|
1739
|
+
return this.cloneAndUpdateSchema({ maxItems })
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
length(exactLength: number): this
|
|
1743
|
+
length(minItems: number, maxItems: number): this
|
|
1744
|
+
length(minItemsOrExact: number, maxItems?: number): this {
|
|
1745
|
+
const maxItemsActual = maxItems ?? minItemsOrExact
|
|
1746
|
+
return this.minLength(minItemsOrExact).maxLength(maxItemsActual)
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
exactLength(length: number): this {
|
|
1750
|
+
return this.minLength(length).maxLength(length)
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
unique(): this {
|
|
1754
|
+
return this.cloneAndUpdateSchema({ uniqueItems: true })
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
export class JsonSchemaSet2Builder<OUT, Opt> extends JsonSchemaAnyBuilder<Set2<OUT>, Opt> {
|
|
1759
|
+
constructor(itemsSchema: JsonSchemaAnyBuilder<OUT, Opt>) {
|
|
1760
|
+
super({
|
|
1761
|
+
type: ['array', 'object'],
|
|
1762
|
+
Set2: itemsSchema.build(),
|
|
1763
|
+
})
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
min(minItems: number): this {
|
|
1767
|
+
return this.cloneAndUpdateSchema({ minItems })
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
max(maxItems: number): this {
|
|
1771
|
+
return this.cloneAndUpdateSchema({ maxItems })
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
export class JsonSchemaBufferBuilder extends JsonSchemaAnyBuilder<Buffer, false> {
|
|
1776
|
+
constructor() {
|
|
1777
|
+
super({
|
|
1778
|
+
Buffer: true,
|
|
1779
|
+
})
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
export class JsonSchemaEnumBuilder<
|
|
1784
|
+
OUT extends string | number | boolean | null,
|
|
1785
|
+
Opt extends boolean = false,
|
|
1786
|
+
> extends JsonSchemaAnyBuilder<OUT, Opt> {
|
|
1787
|
+
constructor(enumValues: readonly OUT[], baseType: EnumBaseType, opt?: JsonBuilderRuleOpt) {
|
|
1788
|
+
const jsonSchema: JsonSchema = { enum: enumValues }
|
|
1789
|
+
// Specifying the base type helps in cases when we ask Ajv to coerce the types.
|
|
1790
|
+
// Having only the `enum` in the schema does not trigger a coercion in Ajv.
|
|
1791
|
+
if (baseType === 'string') jsonSchema.type = 'string'
|
|
1792
|
+
if (baseType === 'number') jsonSchema.type = 'number'
|
|
1793
|
+
|
|
1794
|
+
super(jsonSchema)
|
|
1795
|
+
|
|
1796
|
+
if (opt?.name) this.setErrorMessage('pattern', `is not a valid ${opt.name}`)
|
|
1797
|
+
if (opt?.msg) this.setErrorMessage('enum', opt.msg)
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
branded<B extends OUT>(): JsonSchemaEnumBuilder<B, Opt> {
|
|
1801
|
+
return this as unknown as JsonSchemaEnumBuilder<B, Opt>
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
export class JsonSchemaTupleBuilder<
|
|
1806
|
+
ITEMS extends JsonSchemaAnyBuilder<any, any>[],
|
|
1807
|
+
> extends JsonSchemaAnyBuilder<TupleOut<ITEMS>, false> {
|
|
1808
|
+
constructor(items: ITEMS) {
|
|
1809
|
+
super({
|
|
1810
|
+
type: 'array',
|
|
1811
|
+
prefixItems: items.map(i => i.build()),
|
|
1812
|
+
minItems: items.length,
|
|
1813
|
+
maxItems: items.length,
|
|
1814
|
+
})
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
export class JsonSchemaAnyOfByBuilder<OUT> extends JsonSchemaAnyBuilder<OUT, false> {
|
|
1819
|
+
constructor(
|
|
1820
|
+
propertyName: string,
|
|
1821
|
+
schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any>>,
|
|
1822
|
+
) {
|
|
1823
|
+
const builtSchemaDictionary: Record<string, JsonSchema> = {}
|
|
1824
|
+
for (const [key, schema] of Object.entries(schemaDictionary)) {
|
|
1825
|
+
builtSchemaDictionary[key] = schema.build()
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
super({
|
|
1829
|
+
type: 'object',
|
|
1830
|
+
hasIsOfTypeCheck: true,
|
|
1831
|
+
anyOfBy: {
|
|
1832
|
+
propertyName,
|
|
1833
|
+
schemaDictionary: builtSchemaDictionary,
|
|
1834
|
+
},
|
|
1835
|
+
})
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
export class JsonSchemaAnyOfTheseBuilder<OUT> extends JsonSchemaAnyBuilder<OUT, false> {
|
|
1840
|
+
constructor(
|
|
1841
|
+
propertyName: string,
|
|
1842
|
+
schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any>>,
|
|
1843
|
+
) {
|
|
1844
|
+
const builtSchemaDictionary: Record<string, JsonSchema> = {}
|
|
1845
|
+
for (const [key, schema] of Object.entries(schemaDictionary)) {
|
|
1846
|
+
builtSchemaDictionary[key] = schema.build()
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
super({
|
|
1850
|
+
type: 'object',
|
|
1851
|
+
hasIsOfTypeCheck: true,
|
|
1852
|
+
anyOfBy: {
|
|
1853
|
+
propertyName,
|
|
1854
|
+
schemaDictionary: builtSchemaDictionary,
|
|
1855
|
+
},
|
|
1856
|
+
})
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
type EnumBaseType = 'string' | 'number' | 'other'
|
|
1861
|
+
|
|
1862
|
+
export interface JsonSchema<OUT = unknown> {
|
|
1863
|
+
readonly out?: OUT
|
|
1864
|
+
|
|
1865
|
+
$schema?: string
|
|
1866
|
+
$id?: string
|
|
1867
|
+
title?: string
|
|
1868
|
+
description?: string
|
|
1869
|
+
// $comment?: string
|
|
1870
|
+
deprecated?: boolean
|
|
1871
|
+
readOnly?: boolean
|
|
1872
|
+
writeOnly?: boolean
|
|
1873
|
+
|
|
1874
|
+
type?: string | string[]
|
|
1875
|
+
items?: JsonSchema
|
|
1876
|
+
prefixItems?: JsonSchema[]
|
|
1877
|
+
properties?: {
|
|
1878
|
+
[K in keyof OUT]: JsonSchema<OUT[K]>
|
|
1879
|
+
}
|
|
1880
|
+
patternProperties?: StringMap<JsonSchema<any>>
|
|
1881
|
+
required?: string[]
|
|
1882
|
+
additionalProperties?: boolean
|
|
1883
|
+
minProperties?: number
|
|
1884
|
+
maxProperties?: number
|
|
1885
|
+
|
|
1886
|
+
default?: OUT
|
|
1887
|
+
|
|
1888
|
+
// https://json-schema.org/understanding-json-schema/reference/conditionals.html#id6
|
|
1889
|
+
if?: JsonSchema
|
|
1890
|
+
then?: JsonSchema
|
|
1891
|
+
else?: JsonSchema
|
|
1892
|
+
|
|
1893
|
+
anyOf?: JsonSchema[]
|
|
1894
|
+
oneOf?: JsonSchema[]
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* This is a temporary "intermediate AST" field that is used inside the parser.
|
|
1898
|
+
* In the final schema this field will NOT be present.
|
|
1899
|
+
*/
|
|
1900
|
+
optionalField?: true
|
|
1901
|
+
|
|
1902
|
+
pattern?: string
|
|
1903
|
+
minLength?: number
|
|
1904
|
+
maxLength?: number
|
|
1905
|
+
format?: string
|
|
1906
|
+
|
|
1907
|
+
contentMediaType?: string
|
|
1908
|
+
contentEncoding?: string // e.g 'base64'
|
|
1909
|
+
|
|
1910
|
+
multipleOf?: number
|
|
1911
|
+
minimum?: number
|
|
1912
|
+
exclusiveMinimum?: number
|
|
1913
|
+
maximum?: number
|
|
1914
|
+
exclusiveMaximum?: number
|
|
1915
|
+
minItems?: number
|
|
1916
|
+
maxItems?: number
|
|
1917
|
+
uniqueItems?: boolean
|
|
1918
|
+
|
|
1919
|
+
enum?: any
|
|
1920
|
+
|
|
1921
|
+
hasIsOfTypeCheck?: boolean
|
|
1922
|
+
|
|
1923
|
+
// Below we add custom Ajv keywords
|
|
1924
|
+
|
|
1925
|
+
email?: JsonSchemaStringEmailOptions
|
|
1926
|
+
Set2?: JsonSchema
|
|
1927
|
+
Buffer?: true
|
|
1928
|
+
IsoDate?: JsonSchemaIsoDateOptions
|
|
1929
|
+
IsoDateTime?: true
|
|
1930
|
+
IsoMonth?: JsonSchemaIsoMonthOptions
|
|
1931
|
+
instanceof?: string | string[]
|
|
1932
|
+
transform?: { trim?: true; toLowerCase?: true; toUpperCase?: true; truncate?: number }
|
|
1933
|
+
errorMessages?: StringMap<string>
|
|
1934
|
+
optionalValues?: (string | number | boolean | null)[]
|
|
1935
|
+
keySchema?: JsonSchema
|
|
1936
|
+
isUndefined?: true
|
|
1937
|
+
minProperties2?: number
|
|
1938
|
+
exclusiveProperties?: (readonly string[])[]
|
|
1939
|
+
anyOfBy?: {
|
|
1940
|
+
propertyName: string
|
|
1941
|
+
schemaDictionary: Record<string, JsonSchema>
|
|
1942
|
+
}
|
|
1943
|
+
anyOfThese?: JsonSchema[]
|
|
1944
|
+
precision?: number
|
|
1945
|
+
customValidations?: CustomValidatorFn[]
|
|
1946
|
+
customConversions?: CustomConverterFn<any>[]
|
|
1947
|
+
postValidation?: PostValidatonFn<any, OUT>
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function object(props: AnyObject): never
|
|
1951
|
+
function object<OUT extends AnyObject>(props: {
|
|
1952
|
+
[K in keyof Required<OUT>]-?: JsonSchemaTerminal<OUT[K], any>
|
|
1953
|
+
}): JsonSchemaObjectBuilder<OUT, false>
|
|
1954
|
+
|
|
1955
|
+
function object<OUT extends AnyObject>(props: {
|
|
1956
|
+
[key in keyof OUT]: JsonSchemaTerminal<OUT[key], any>
|
|
1957
|
+
}): JsonSchemaObjectBuilder<OUT, false> {
|
|
1958
|
+
return new JsonSchemaObjectBuilder<OUT, false>(props)
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function objectInfer<P extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
|
|
1962
|
+
props: P,
|
|
1963
|
+
): JsonSchemaObjectInferringBuilder<P, false> {
|
|
1964
|
+
return new JsonSchemaObjectInferringBuilder<P, false>(props)
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function objectDbEntity(props: AnyObject): never
|
|
1968
|
+
function objectDbEntity<
|
|
1969
|
+
OUT extends BaseDBEntity,
|
|
1970
|
+
EXTRA_KEYS extends Exclude<keyof OUT, keyof BaseDBEntity> = Exclude<
|
|
1971
|
+
keyof OUT,
|
|
1972
|
+
keyof BaseDBEntity
|
|
1973
|
+
>,
|
|
1974
|
+
>(
|
|
1975
|
+
props: {
|
|
1976
|
+
// ✅ all non-system fields must be explicitly provided
|
|
1977
|
+
[K in EXTRA_KEYS]-?: BuilderFor<OUT[K]>
|
|
1978
|
+
} &
|
|
1979
|
+
// ✅ if `id` differs, it's required
|
|
1980
|
+
(ExactMatch<OUT['id'], BaseDBEntity['id']> extends true
|
|
1981
|
+
? { id?: BuilderFor<BaseDBEntity['id']> }
|
|
1982
|
+
: { id: BuilderFor<OUT['id']> }) &
|
|
1983
|
+
(ExactMatch<OUT['created'], BaseDBEntity['created']> extends true
|
|
1984
|
+
? { created?: BuilderFor<BaseDBEntity['created']> }
|
|
1985
|
+
: { created: BuilderFor<OUT['created']> }) &
|
|
1986
|
+
(ExactMatch<OUT['updated'], BaseDBEntity['updated']> extends true
|
|
1987
|
+
? { updated?: BuilderFor<BaseDBEntity['updated']> }
|
|
1988
|
+
: { updated: BuilderFor<OUT['updated']> }),
|
|
1989
|
+
): JsonSchemaObjectBuilder<OUT, false>
|
|
1990
|
+
|
|
1991
|
+
function objectDbEntity(props: AnyObject): any {
|
|
1992
|
+
return j.object({
|
|
1993
|
+
id: j.string(),
|
|
1994
|
+
created: j.number().unixTimestamp2000(),
|
|
1995
|
+
updated: j.number().unixTimestamp2000(),
|
|
1996
|
+
...props,
|
|
1997
|
+
})
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function record<
|
|
2001
|
+
KS extends JsonSchemaAnyBuilder<any, any>,
|
|
2002
|
+
VS extends JsonSchemaAnyBuilder<any, any>,
|
|
2003
|
+
Opt extends boolean = SchemaOpt<VS>,
|
|
2004
|
+
>(
|
|
2005
|
+
keySchema: KS,
|
|
2006
|
+
valueSchema: VS,
|
|
2007
|
+
): JsonSchemaObjectBuilder<
|
|
2008
|
+
Opt extends true
|
|
2009
|
+
? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
|
|
2010
|
+
: Record<SchemaOut<KS>, SchemaOut<VS>>,
|
|
2011
|
+
false
|
|
2012
|
+
> {
|
|
2013
|
+
const keyJsonSchema = keySchema.build()
|
|
2014
|
+
// Check if value schema is optional before build() strips the optionalField flag
|
|
2015
|
+
const isValueOptional = (valueSchema as JsonSchemaTerminal<any, any>).getSchema().optionalField
|
|
2016
|
+
const valueJsonSchema = valueSchema.build()
|
|
2017
|
+
|
|
2018
|
+
// When value schema is optional, wrap in anyOf to allow undefined values
|
|
2019
|
+
const finalValueSchema: JsonSchema = isValueOptional
|
|
2020
|
+
? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
|
|
2021
|
+
: valueJsonSchema
|
|
2022
|
+
|
|
2023
|
+
return new JsonSchemaObjectBuilder<
|
|
2024
|
+
Opt extends true
|
|
2025
|
+
? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
|
|
2026
|
+
: Record<SchemaOut<KS>, SchemaOut<VS>>,
|
|
2027
|
+
false
|
|
2028
|
+
>([], {
|
|
2029
|
+
hasIsOfTypeCheck: false,
|
|
2030
|
+
keySchema: keyJsonSchema,
|
|
2031
|
+
patternProperties: {
|
|
2032
|
+
['^.*$']: finalValueSchema,
|
|
2033
|
+
},
|
|
2034
|
+
})
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
function withRegexKeys<S extends JsonSchemaAnyBuilder<any, any>>(
|
|
2038
|
+
keyRegex: RegExp | string,
|
|
2039
|
+
schema: S,
|
|
2040
|
+
): JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>, false> {
|
|
2041
|
+
if (keyRegex instanceof RegExp) {
|
|
2042
|
+
_assert(
|
|
2043
|
+
!keyRegex.flags,
|
|
2044
|
+
`Regex flags are not supported by JSON Schema. Received: /${keyRegex.source}/${keyRegex.flags}`,
|
|
2045
|
+
)
|
|
2046
|
+
}
|
|
2047
|
+
const pattern = keyRegex instanceof RegExp ? keyRegex.source : keyRegex
|
|
2048
|
+
const jsonSchema = schema.build()
|
|
2049
|
+
|
|
2050
|
+
return new JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>, false>([], {
|
|
2051
|
+
hasIsOfTypeCheck: false,
|
|
2052
|
+
patternProperties: {
|
|
2053
|
+
[pattern]: jsonSchema,
|
|
2054
|
+
},
|
|
2055
|
+
})
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
/**
|
|
2059
|
+
* Builds the object schema with the indicated `keys` and uses the `schema` for their validation.
|
|
2060
|
+
*/
|
|
2061
|
+
function withEnumKeys<
|
|
2062
|
+
const T extends readonly (string | number)[] | StringEnum | NumberEnum,
|
|
2063
|
+
S extends JsonSchemaAnyBuilder<any, any>,
|
|
2064
|
+
K extends string | number = EnumKeyUnion<T>,
|
|
2065
|
+
Opt extends boolean = SchemaOpt<S>,
|
|
2066
|
+
>(
|
|
2067
|
+
keys: T,
|
|
2068
|
+
schema: S,
|
|
2069
|
+
): JsonSchemaObjectBuilder<
|
|
2070
|
+
Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
|
|
2071
|
+
false
|
|
2072
|
+
> {
|
|
2073
|
+
let enumValues: readonly (string | number)[] | undefined
|
|
2074
|
+
if (Array.isArray(keys)) {
|
|
2075
|
+
_assert(
|
|
2076
|
+
isEveryItemPrimitive(keys),
|
|
2077
|
+
'Every item in the key list should be string, number or symbol',
|
|
2078
|
+
)
|
|
2079
|
+
enumValues = keys
|
|
2080
|
+
} else if (typeof keys === 'object') {
|
|
2081
|
+
const enumType = getEnumType(keys)
|
|
2082
|
+
_assert(
|
|
2083
|
+
enumType === 'NumberEnum' || enumType === 'StringEnum',
|
|
2084
|
+
'The key list should be StringEnum or NumberEnum',
|
|
2085
|
+
)
|
|
2086
|
+
if (enumType === 'NumberEnum') {
|
|
2087
|
+
enumValues = _numberEnumValues(keys as NumberEnum)
|
|
2088
|
+
} else if (enumType === 'StringEnum') {
|
|
2089
|
+
enumValues = _stringEnumValues(keys as StringEnum)
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
_assert(enumValues, 'The key list should be an array of values, NumberEnum or a StringEnum')
|
|
2094
|
+
|
|
2095
|
+
const typedValues = enumValues as readonly K[]
|
|
2096
|
+
const props = Object.fromEntries(typedValues.map(key => [key, schema])) as any
|
|
2097
|
+
|
|
2098
|
+
return new JsonSchemaObjectBuilder<
|
|
2099
|
+
Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
|
|
2100
|
+
false
|
|
2101
|
+
>(props, { hasIsOfTypeCheck: false })
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function hasNoObjectSchemas(schema: JsonSchema): boolean {
|
|
2105
|
+
if (Array.isArray(schema.type)) {
|
|
2106
|
+
schema.type.every(type => ['string', 'number', 'integer', 'boolean', 'null'].includes(type))
|
|
2107
|
+
} else if (schema.anyOf) {
|
|
2108
|
+
return schema.anyOf.every(hasNoObjectSchemas)
|
|
2109
|
+
} else if (schema.oneOf) {
|
|
2110
|
+
return schema.oneOf.every(hasNoObjectSchemas)
|
|
2111
|
+
} else if (schema.enum) {
|
|
2112
|
+
return true
|
|
2113
|
+
} else if (schema.type === 'array') {
|
|
2114
|
+
return !schema.items || hasNoObjectSchemas(schema.items)
|
|
2115
|
+
} else {
|
|
2116
|
+
return !!schema.type && ['string', 'number', 'integer', 'boolean', 'null'].includes(schema.type)
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
return false
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
type Expand<T> = { [K in keyof T]: T[K] }
|
|
2123
|
+
|
|
2124
|
+
type StripIndexSignatureDeep<T> = T extends readonly unknown[]
|
|
2125
|
+
? T
|
|
2126
|
+
: T extends Record<string, any>
|
|
2127
|
+
? {
|
|
2128
|
+
[K in keyof T as string extends K
|
|
2129
|
+
? never
|
|
2130
|
+
: number extends K
|
|
2131
|
+
? never
|
|
2132
|
+
: symbol extends K
|
|
2133
|
+
? never
|
|
2134
|
+
: K]: StripIndexSignatureDeep<T[K]>
|
|
2135
|
+
}
|
|
2136
|
+
: T
|
|
2137
|
+
|
|
2138
|
+
type RelaxIndexSignature<T> = T extends readonly unknown[]
|
|
2139
|
+
? T
|
|
2140
|
+
: T extends AnyObject
|
|
2141
|
+
? { [K in keyof T]: RelaxIndexSignature<T[K]> }
|
|
2142
|
+
: T
|
|
2143
|
+
|
|
2144
|
+
type Override<T, U> = Omit<T, keyof U> & U
|
|
2145
|
+
|
|
2146
|
+
declare const allowExtraKeysSymbol: unique symbol
|
|
2147
|
+
|
|
2148
|
+
type HasAllowExtraKeys<T> = T extends { readonly [allowExtraKeysSymbol]?: true } ? true : false
|
|
2149
|
+
|
|
2150
|
+
type IsAny<T> = 0 extends 1 & T ? true : false
|
|
2151
|
+
|
|
2152
|
+
type IsAssignableRelaxed<A, B> =
|
|
2153
|
+
IsAny<RelaxIndexSignature<A>> extends true
|
|
2154
|
+
? true
|
|
2155
|
+
: [RelaxIndexSignature<A>] extends [B]
|
|
2156
|
+
? true
|
|
2157
|
+
: false
|
|
2158
|
+
|
|
2159
|
+
type ExactMatchBase<A, B> =
|
|
2160
|
+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
2161
|
+
? (<T>() => T extends B ? 1 : 2) extends <T>() => T extends A ? 1 : 2
|
|
2162
|
+
? true
|
|
2163
|
+
: false
|
|
2164
|
+
: false
|
|
2165
|
+
|
|
2166
|
+
type ExactMatch<A, B> =
|
|
2167
|
+
HasAllowExtraKeys<B> extends true
|
|
2168
|
+
? IsAssignableRelaxed<B, A>
|
|
2169
|
+
: ExactMatchBase<Expand<A>, Expand<B>> extends true
|
|
2170
|
+
? true
|
|
2171
|
+
: ExactMatchBase<Expand<StripIndexSignatureDeep<A>>, Expand<StripIndexSignatureDeep<B>>>
|
|
2172
|
+
|
|
2173
|
+
type BuilderOutUnion<B extends readonly JsonSchemaAnyBuilder<any, any>[]> = {
|
|
2174
|
+
[K in keyof B]: B[K] extends JsonSchemaAnyBuilder<infer O, any> ? O : never
|
|
2175
|
+
}[number]
|
|
2176
|
+
|
|
2177
|
+
type AnyOfByOut<D extends Record<PropertyKey, JsonSchemaTerminal<any, any>>> = {
|
|
2178
|
+
[K in keyof D]: D[K] extends JsonSchemaTerminal<infer O, any> ? O : never
|
|
2179
|
+
}[keyof D]
|
|
2180
|
+
|
|
2181
|
+
type BuilderFor<T> = JsonSchemaAnyBuilder<T, any>
|
|
2182
|
+
|
|
2183
|
+
interface JsonBuilderRuleOpt {
|
|
2184
|
+
/**
|
|
2185
|
+
* Text of error message to return when the validation fails for the given rule:
|
|
2186
|
+
*
|
|
2187
|
+
* `{ msg: "is not a valid Oompa-loompa" } => "Object.property is not a valid Oompa-loompa"`
|
|
2188
|
+
*/
|
|
2189
|
+
msg?: string
|
|
2190
|
+
/**
|
|
2191
|
+
* A friendly name for what we are validating, that will be used in error messages:
|
|
2192
|
+
*
|
|
2193
|
+
* `{ name: "Oompa-loompa" } => "Object.property is not a valid Oompa-loompa"`
|
|
2194
|
+
*/
|
|
2195
|
+
name?: string
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
type EnumKeyUnion<T> =
|
|
2199
|
+
// array of literals -> union of its elements
|
|
2200
|
+
T extends readonly (infer U)[]
|
|
2201
|
+
? U
|
|
2202
|
+
: // enum object -> union of its values
|
|
2203
|
+
T extends StringEnum | NumberEnum
|
|
2204
|
+
? T[keyof T]
|
|
2205
|
+
: never
|
|
2206
|
+
|
|
2207
|
+
type SchemaOut<S> = S extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
|
|
2208
|
+
type SchemaOpt<S> =
|
|
2209
|
+
S extends JsonSchemaAnyBuilder<any, infer Opt> ? (Opt extends true ? true : false) : false
|
|
2210
|
+
|
|
2211
|
+
type TupleOut<T extends readonly JsonSchemaAnyBuilder<any, any>[]> = {
|
|
2212
|
+
[K in keyof T]: T[K] extends JsonSchemaAnyBuilder<infer O, any> ? O : never
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
export type PostValidatonFn<OUT, OUT2> = (v: OUT) => OUT2
|
|
2216
|
+
export type CustomValidatorFn = (v: any) => string | undefined
|
|
2217
|
+
export type CustomConverterFn<OUT> = (v: any) => OUT
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* Deep copy that preserves functions in customValidations/customConversions.
|
|
2221
|
+
* Unlike structuredClone, this handles function references (which only exist in those two properties).
|
|
2222
|
+
*/
|
|
2223
|
+
function deepCopyPreservingFunctions<T>(obj: T): T {
|
|
2224
|
+
if (obj === null || typeof obj !== 'object') return obj
|
|
2225
|
+
if (Array.isArray(obj)) return obj.map(deepCopyPreservingFunctions) as T
|
|
2226
|
+
const copy = {} as T
|
|
2227
|
+
for (const key of Object.keys(obj)) {
|
|
2228
|
+
const value = (obj as any)[key]
|
|
2229
|
+
// customValidations/customConversions are arrays of functions - shallow copy the array
|
|
2230
|
+
;(copy as any)[key] =
|
|
2231
|
+
(key === 'customValidations' || key === 'customConversions') && Array.isArray(value)
|
|
2232
|
+
? [...value]
|
|
2233
|
+
: deepCopyPreservingFunctions(value)
|
|
2234
|
+
}
|
|
2235
|
+
return copy
|
|
2236
|
+
}
|