@naturalcycles/nodejs-lib 15.90.2 → 15.91.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 +659 -3
- package/dist/validation/ajv/ajvSchema.js +1148 -4
- package/dist/validation/ajv/from-data/generateJsonSchemaFromData.d.ts +1 -1
- 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 +2008 -6
- package/src/validation/ajv/from-data/generateJsonSchemaFromData.ts +1 -1
- package/src/validation/ajv/getAjv.ts +1 -1
- 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,12 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint-disable id-denylist */
|
|
2
|
+
// oxlint-disable max-lines
|
|
3
|
+
import { _isObject, _isUndefined, _lazyValue, _numberEnumValues, _stringEnumValues, getEnumType, } from '@naturalcycles/js-lib';
|
|
4
|
+
import { _uniq } from '@naturalcycles/js-lib/array';
|
|
2
5
|
import { _assert } from '@naturalcycles/js-lib/error';
|
|
3
|
-
import { _deepCopy, _filterNullishValues } from '@naturalcycles/js-lib/object';
|
|
6
|
+
import { _deepCopy, _filterNullishValues, _sortObject } from '@naturalcycles/js-lib/object';
|
|
4
7
|
import { _substringBefore } from '@naturalcycles/js-lib/string';
|
|
5
|
-
import { _typeCast } from '@naturalcycles/js-lib/types';
|
|
8
|
+
import { _objectAssign, _typeCast, JWT_REGEX } from '@naturalcycles/js-lib/types';
|
|
6
9
|
import { _inspect } from '../../string/inspect.js';
|
|
10
|
+
import { BASE64URL_REGEX, COUNTRY_CODE_REGEX, CURRENCY_REGEX, IPV4_REGEX, IPV6_REGEX, LANGUAGE_TAG_REGEX, SEMVER_REGEX, SLUG_REGEX, URL_REGEX, UUID_REGEX, } from '../regexes.js';
|
|
11
|
+
import { TIMEZONES } from '../timezones.js';
|
|
7
12
|
import { AjvValidationError } from './ajvValidationError.js';
|
|
8
13
|
import { getAjv } from './getAjv.js';
|
|
9
|
-
import {
|
|
14
|
+
import { isEveryItemNumber, isEveryItemPrimitive, isEveryItemString, JSON_SCHEMA_ORDER, mergeJsonSchemaObjects, } from './jsonSchemaBuilder.util.js';
|
|
15
|
+
// ==== AJV =====
|
|
10
16
|
/**
|
|
11
17
|
* On creation - compiles ajv validation function.
|
|
12
18
|
* Provides convenient methods, error reporting, etc.
|
|
@@ -205,3 +211,1141 @@ export class AjvSchema {
|
|
|
205
211
|
}
|
|
206
212
|
const separator = '\n';
|
|
207
213
|
export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA');
|
|
214
|
+
// ===== JsonSchemaBuilders ===== //
|
|
215
|
+
export const j = {
|
|
216
|
+
/**
|
|
217
|
+
* Matches literally any value - equivalent to TypeScript's `any` type.
|
|
218
|
+
* Use sparingly, as it bypasses type validation entirely.
|
|
219
|
+
*/
|
|
220
|
+
any() {
|
|
221
|
+
return new JsonSchemaAnyBuilder({});
|
|
222
|
+
},
|
|
223
|
+
string() {
|
|
224
|
+
return new JsonSchemaStringBuilder();
|
|
225
|
+
},
|
|
226
|
+
number() {
|
|
227
|
+
return new JsonSchemaNumberBuilder();
|
|
228
|
+
},
|
|
229
|
+
boolean() {
|
|
230
|
+
return new JsonSchemaBooleanBuilder();
|
|
231
|
+
},
|
|
232
|
+
object: Object.assign(object, {
|
|
233
|
+
dbEntity: objectDbEntity,
|
|
234
|
+
infer: objectInfer,
|
|
235
|
+
any() {
|
|
236
|
+
return j.object({}).allowAdditionalProperties();
|
|
237
|
+
},
|
|
238
|
+
stringMap(schema) {
|
|
239
|
+
const isValueOptional = schema.getSchema().optionalField;
|
|
240
|
+
const builtSchema = schema.build();
|
|
241
|
+
const finalValueSchema = isValueOptional
|
|
242
|
+
? { anyOf: [{ isUndefined: true }, builtSchema] }
|
|
243
|
+
: builtSchema;
|
|
244
|
+
return new JsonSchemaObjectBuilder({}, {
|
|
245
|
+
hasIsOfTypeCheck: false,
|
|
246
|
+
patternProperties: {
|
|
247
|
+
'^.+$': finalValueSchema,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
/**
|
|
252
|
+
* @experimental Look around, maybe you find a rule that is better for your use-case.
|
|
253
|
+
*
|
|
254
|
+
* For Record<K, V> type of validations.
|
|
255
|
+
* ```ts
|
|
256
|
+
* const schema = j.object
|
|
257
|
+
* .record(
|
|
258
|
+
* j
|
|
259
|
+
* .string()
|
|
260
|
+
* .regex(/^\d{3,4}$/)
|
|
261
|
+
* .branded<B>(),
|
|
262
|
+
* j.number().nullable(),
|
|
263
|
+
* )
|
|
264
|
+
* .isOfType<Record<B, number | null>>()
|
|
265
|
+
* ```
|
|
266
|
+
*
|
|
267
|
+
* When the keys of the Record are values from an Enum, prefer `j.object.withEnumKeys`!
|
|
268
|
+
*
|
|
269
|
+
* Non-matching keys will be stripped from the object, i.e. they will not cause an error.
|
|
270
|
+
*
|
|
271
|
+
* Caveat: This rule first validates values of every properties of the object, and only then validates the keys.
|
|
272
|
+
* A consequence of that is that the validation will throw when there is an unexpected property with a value not matching the value schema.
|
|
273
|
+
*/
|
|
274
|
+
record,
|
|
275
|
+
/**
|
|
276
|
+
* For Record<ENUM, V> type of validations.
|
|
277
|
+
*
|
|
278
|
+
* When the keys of the Record are values from an Enum,
|
|
279
|
+
* this helper is more performant and behaves in a more conventional manner than `j.object.record` would.
|
|
280
|
+
*
|
|
281
|
+
*
|
|
282
|
+
*/
|
|
283
|
+
withEnumKeys,
|
|
284
|
+
withRegexKeys,
|
|
285
|
+
}),
|
|
286
|
+
array(itemSchema) {
|
|
287
|
+
return new JsonSchemaArrayBuilder(itemSchema);
|
|
288
|
+
},
|
|
289
|
+
tuple(items) {
|
|
290
|
+
return new JsonSchemaTupleBuilder(items);
|
|
291
|
+
},
|
|
292
|
+
set(itemSchema) {
|
|
293
|
+
return new JsonSchemaSet2Builder(itemSchema);
|
|
294
|
+
},
|
|
295
|
+
buffer() {
|
|
296
|
+
return new JsonSchemaBufferBuilder();
|
|
297
|
+
},
|
|
298
|
+
enum(input, opt) {
|
|
299
|
+
let enumValues;
|
|
300
|
+
let baseType = 'other';
|
|
301
|
+
if (Array.isArray(input)) {
|
|
302
|
+
enumValues = input;
|
|
303
|
+
if (isEveryItemNumber(input)) {
|
|
304
|
+
baseType = 'number';
|
|
305
|
+
}
|
|
306
|
+
else if (isEveryItemString(input)) {
|
|
307
|
+
baseType = 'string';
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else if (typeof input === 'object') {
|
|
311
|
+
const enumType = getEnumType(input);
|
|
312
|
+
if (enumType === 'NumberEnum') {
|
|
313
|
+
enumValues = _numberEnumValues(input);
|
|
314
|
+
baseType = 'number';
|
|
315
|
+
}
|
|
316
|
+
else if (enumType === 'StringEnum') {
|
|
317
|
+
enumValues = _stringEnumValues(input);
|
|
318
|
+
baseType = 'string';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
_assert(enumValues, 'Unsupported enum input');
|
|
322
|
+
return new JsonSchemaEnumBuilder(enumValues, baseType, opt);
|
|
323
|
+
},
|
|
324
|
+
/**
|
|
325
|
+
* Use only with primitive values, otherwise this function will throw to avoid bugs.
|
|
326
|
+
* To validate objects, use `anyOfBy`.
|
|
327
|
+
*
|
|
328
|
+
* Our Ajv is configured to strip unexpected properties from objects,
|
|
329
|
+
* and since Ajv is mutating the input, this means that it cannot
|
|
330
|
+
* properly validate the same data over multiple schemas.
|
|
331
|
+
*
|
|
332
|
+
* Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
|
|
333
|
+
* Use `oneOf` when schemas are mutually exclusive.
|
|
334
|
+
*/
|
|
335
|
+
oneOf(items) {
|
|
336
|
+
const schemas = items.map(b => b.build());
|
|
337
|
+
_assert(schemas.every(hasNoObjectSchemas), 'Do not use `oneOf` validation with non-primitive types!');
|
|
338
|
+
return new JsonSchemaAnyBuilder({
|
|
339
|
+
oneOf: schemas,
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
/**
|
|
343
|
+
* Use only with primitive values, otherwise this function will throw to avoid bugs.
|
|
344
|
+
* To validate objects, use `anyOfBy` or `anyOfThese`.
|
|
345
|
+
*
|
|
346
|
+
* Our Ajv is configured to strip unexpected properties from objects,
|
|
347
|
+
* and since Ajv is mutating the input, this means that it cannot
|
|
348
|
+
* properly validate the same data over multiple schemas.
|
|
349
|
+
*
|
|
350
|
+
* Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
|
|
351
|
+
* Use `oneOf` when schemas are mutually exclusive.
|
|
352
|
+
*/
|
|
353
|
+
anyOf(items) {
|
|
354
|
+
const schemas = items.map(b => b.build());
|
|
355
|
+
_assert(schemas.every(hasNoObjectSchemas), 'Do not use `anyOf` validation with non-primitive types!');
|
|
356
|
+
return new JsonSchemaAnyBuilder({
|
|
357
|
+
anyOf: schemas,
|
|
358
|
+
});
|
|
359
|
+
},
|
|
360
|
+
/**
|
|
361
|
+
* Pick validation schema for an object based on the value of a specific property.
|
|
362
|
+
*
|
|
363
|
+
* ```
|
|
364
|
+
* const schemaMap = {
|
|
365
|
+
* true: successSchema,
|
|
366
|
+
* false: errorSchema
|
|
367
|
+
* }
|
|
368
|
+
*
|
|
369
|
+
* const schema = j.anyOfBy('success', schemaMap)
|
|
370
|
+
* ```
|
|
371
|
+
*/
|
|
372
|
+
anyOfBy(propertyName, schemaDictionary) {
|
|
373
|
+
return new JsonSchemaAnyOfByBuilder(propertyName, schemaDictionary);
|
|
374
|
+
},
|
|
375
|
+
/**
|
|
376
|
+
* Custom version of `anyOf` which - in contrast to the original function - does not mutate the input.
|
|
377
|
+
* This comes with a performance penalty, so do not use it where performance matters.
|
|
378
|
+
*
|
|
379
|
+
* ```
|
|
380
|
+
* const schema = j.anyOfThese([successSchema, errorSchema])
|
|
381
|
+
* ```
|
|
382
|
+
*/
|
|
383
|
+
anyOfThese(items) {
|
|
384
|
+
return new JsonSchemaAnyBuilder({
|
|
385
|
+
anyOfThese: items.map(b => b.build()),
|
|
386
|
+
});
|
|
387
|
+
},
|
|
388
|
+
and() {
|
|
389
|
+
return {
|
|
390
|
+
silentBob: () => {
|
|
391
|
+
throw new Error('...strike back!');
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
literal(v) {
|
|
396
|
+
let baseType = 'other';
|
|
397
|
+
if (typeof v === 'string')
|
|
398
|
+
baseType = 'string';
|
|
399
|
+
if (typeof v === 'number')
|
|
400
|
+
baseType = 'number';
|
|
401
|
+
return new JsonSchemaEnumBuilder([v], baseType);
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
const TS_2500 = 16725225600; // 2500-01-01
|
|
405
|
+
const TS_2500_MILLIS = TS_2500 * 1000;
|
|
406
|
+
const TS_2000 = 946684800; // 2000-01-01
|
|
407
|
+
const TS_2000_MILLIS = TS_2000 * 1000;
|
|
408
|
+
/*
|
|
409
|
+
Notes for future reference
|
|
410
|
+
|
|
411
|
+
Q: Why do we need `Opt` - when `IN` and `OUT` already carries the `| undefined`?
|
|
412
|
+
A: Because of objects. Without `Opt`, an optional field would be inferred as `{ foo: string | undefined }`,
|
|
413
|
+
which means that the `foo` property would be mandatory, it's just that its value can be `undefined` as well.
|
|
414
|
+
With `Opt`, we can infer it as `{ foo?: string | undefined }`.
|
|
415
|
+
*/
|
|
416
|
+
export class JsonSchemaTerminal {
|
|
417
|
+
[HIDDEN_AJV_SCHEMA];
|
|
418
|
+
schema;
|
|
419
|
+
constructor(schema) {
|
|
420
|
+
this.schema = schema;
|
|
421
|
+
}
|
|
422
|
+
get ajvSchema() {
|
|
423
|
+
if (!this[HIDDEN_AJV_SCHEMA]) {
|
|
424
|
+
this[HIDDEN_AJV_SCHEMA] = AjvSchema.create(this);
|
|
425
|
+
}
|
|
426
|
+
return this[HIDDEN_AJV_SCHEMA];
|
|
427
|
+
}
|
|
428
|
+
getSchema() {
|
|
429
|
+
return this.schema;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Produces a "clean schema object" without methods.
|
|
433
|
+
* Same as if it would be JSON.stringified.
|
|
434
|
+
*/
|
|
435
|
+
build() {
|
|
436
|
+
_assert(!(this.schema.optionalField && this.schema.default !== undefined), '.optional() and .default() should not be used together - the default value makes .optional() redundant and causes incorrect type inference');
|
|
437
|
+
const jsonSchema = _sortObject(deepCopyPreservingFunctions(this.schema), JSON_SCHEMA_ORDER);
|
|
438
|
+
delete jsonSchema.optionalField;
|
|
439
|
+
return jsonSchema;
|
|
440
|
+
}
|
|
441
|
+
clone() {
|
|
442
|
+
const cloned = Object.create(Object.getPrototypeOf(this));
|
|
443
|
+
cloned.schema = deepCopyPreservingFunctions(this.schema);
|
|
444
|
+
return cloned;
|
|
445
|
+
}
|
|
446
|
+
cloneAndUpdateSchema(schema) {
|
|
447
|
+
const clone = this.clone();
|
|
448
|
+
_objectAssign(clone.schema, schema);
|
|
449
|
+
return clone;
|
|
450
|
+
}
|
|
451
|
+
validate(input, opt) {
|
|
452
|
+
return this.ajvSchema.validate(input, opt);
|
|
453
|
+
}
|
|
454
|
+
isValid(input, opt) {
|
|
455
|
+
return this.ajvSchema.isValid(input, opt);
|
|
456
|
+
}
|
|
457
|
+
getValidationResult(input, opt = {}) {
|
|
458
|
+
return this.ajvSchema.getValidationResult(input, opt);
|
|
459
|
+
}
|
|
460
|
+
getValidationFunction() {
|
|
461
|
+
return this.ajvSchema.getValidationFunction();
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* @experimental
|
|
465
|
+
*/
|
|
466
|
+
in;
|
|
467
|
+
out;
|
|
468
|
+
opt;
|
|
469
|
+
}
|
|
470
|
+
export class JsonSchemaAnyBuilder extends JsonSchemaTerminal {
|
|
471
|
+
setErrorMessage(ruleName, errorMessage) {
|
|
472
|
+
if (_isUndefined(errorMessage))
|
|
473
|
+
return;
|
|
474
|
+
this.schema.errorMessages ||= {};
|
|
475
|
+
this.schema.errorMessages[ruleName] = errorMessage;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* A helper function that takes a type parameter and compares it with the type inferred from the schema.
|
|
479
|
+
*
|
|
480
|
+
* When the type inferred from the schema differs from the passed-in type,
|
|
481
|
+
* the schema becomes unusable, by turning its type into `never`.
|
|
482
|
+
*
|
|
483
|
+
* ```ts
|
|
484
|
+
* const schemaGood = j.string().isOfType<string>() // ✅
|
|
485
|
+
*
|
|
486
|
+
* const schemaBad = j.string().isOfType<number>() // ❌
|
|
487
|
+
* schemaBad.build() // TypeError: property "build" does not exist on type "never"
|
|
488
|
+
*
|
|
489
|
+
* const result = ajvValidateRequest.body(req, schemaBad) // result will have `unknown` type
|
|
490
|
+
* ```
|
|
491
|
+
*/
|
|
492
|
+
isOfType() {
|
|
493
|
+
return this.cloneAndUpdateSchema({ hasIsOfTypeCheck: true });
|
|
494
|
+
}
|
|
495
|
+
$schema($schema) {
|
|
496
|
+
return this.cloneAndUpdateSchema({ $schema });
|
|
497
|
+
}
|
|
498
|
+
$schemaDraft7() {
|
|
499
|
+
return this.$schema('http://json-schema.org/draft-07/schema#');
|
|
500
|
+
}
|
|
501
|
+
$id($id) {
|
|
502
|
+
return this.cloneAndUpdateSchema({ $id });
|
|
503
|
+
}
|
|
504
|
+
title(title) {
|
|
505
|
+
return this.cloneAndUpdateSchema({ title });
|
|
506
|
+
}
|
|
507
|
+
description(description) {
|
|
508
|
+
return this.cloneAndUpdateSchema({ description });
|
|
509
|
+
}
|
|
510
|
+
deprecated(deprecated = true) {
|
|
511
|
+
return this.cloneAndUpdateSchema({ deprecated });
|
|
512
|
+
}
|
|
513
|
+
type(type) {
|
|
514
|
+
return this.cloneAndUpdateSchema({ type });
|
|
515
|
+
}
|
|
516
|
+
default(v) {
|
|
517
|
+
return this.cloneAndUpdateSchema({ default: v });
|
|
518
|
+
}
|
|
519
|
+
instanceof(of) {
|
|
520
|
+
return this.cloneAndUpdateSchema({ type: 'object', instanceof: of });
|
|
521
|
+
}
|
|
522
|
+
optional() {
|
|
523
|
+
const clone = this.cloneAndUpdateSchema({ optionalField: true });
|
|
524
|
+
return clone;
|
|
525
|
+
}
|
|
526
|
+
nullable() {
|
|
527
|
+
return new JsonSchemaAnyBuilder({
|
|
528
|
+
anyOf: [this.build(), { type: 'null' }],
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* @deprecated
|
|
533
|
+
* The usage of this function is discouraged as it defeats the purpose of having type-safe validation.
|
|
534
|
+
*/
|
|
535
|
+
castAs() {
|
|
536
|
+
return this;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Locks the given schema chain and no other modification can be done to it.
|
|
540
|
+
*/
|
|
541
|
+
final() {
|
|
542
|
+
return new JsonSchemaTerminal(this.schema);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
*
|
|
546
|
+
* @param validator A validator function that returns an error message or undefined.
|
|
547
|
+
*
|
|
548
|
+
* You may add multiple custom validators and they will be executed in the order you added them.
|
|
549
|
+
*/
|
|
550
|
+
custom(validator) {
|
|
551
|
+
const { customValidations = [] } = this.schema;
|
|
552
|
+
return this.cloneAndUpdateSchema({
|
|
553
|
+
customValidations: [...customValidations, validator],
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
*
|
|
558
|
+
* @param converter A converter function that returns a new value.
|
|
559
|
+
*
|
|
560
|
+
* You may add multiple converters and they will be executed in the order you added them,
|
|
561
|
+
* each converter receiving the result from the previous one.
|
|
562
|
+
*
|
|
563
|
+
* This feature only works when the current schema is nested in an object or array schema,
|
|
564
|
+
* due to how mutability works in Ajv.
|
|
565
|
+
*/
|
|
566
|
+
convert(converter) {
|
|
567
|
+
const { customConversions = [] } = this.schema;
|
|
568
|
+
return this.cloneAndUpdateSchema({
|
|
569
|
+
customConversions: [...customConversions, converter],
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
export class JsonSchemaStringBuilder extends JsonSchemaAnyBuilder {
|
|
574
|
+
constructor() {
|
|
575
|
+
super({
|
|
576
|
+
type: 'string',
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* @param optionalValues List of values that should be considered/converted as `undefined`.
|
|
581
|
+
*
|
|
582
|
+
* This `optionalValues` feature only works when the current schema is nested in an object or array schema,
|
|
583
|
+
* due to how mutability works in Ajv.
|
|
584
|
+
*
|
|
585
|
+
* Make sure this `optional()` call is at the end of your call chain.
|
|
586
|
+
*
|
|
587
|
+
* When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
|
|
588
|
+
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
|
|
589
|
+
*/
|
|
590
|
+
optional(optionalValues) {
|
|
591
|
+
if (!optionalValues) {
|
|
592
|
+
return super.optional();
|
|
593
|
+
}
|
|
594
|
+
_typeCast(optionalValues);
|
|
595
|
+
let newBuilder = new JsonSchemaStringBuilder().optional();
|
|
596
|
+
const alternativesSchema = j.enum(optionalValues);
|
|
597
|
+
Object.assign(newBuilder.getSchema(), {
|
|
598
|
+
anyOf: [this.build(), alternativesSchema.build()],
|
|
599
|
+
optionalValues,
|
|
600
|
+
});
|
|
601
|
+
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
|
|
602
|
+
// so we must allow `null` values to be parsed by Ajv,
|
|
603
|
+
// but the typing should not reflect that.
|
|
604
|
+
// We also cannot accept more rules attached, since we're not building a StringSchema anymore.
|
|
605
|
+
if (optionalValues.includes(null)) {
|
|
606
|
+
newBuilder = new JsonSchemaTerminal({
|
|
607
|
+
anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
|
|
608
|
+
optionalField: true,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
return newBuilder;
|
|
612
|
+
}
|
|
613
|
+
regex(pattern, opt) {
|
|
614
|
+
_assert(!pattern.flags, `Regex flags are not supported by JSON Schema. Received: /${pattern.source}/${pattern.flags}`);
|
|
615
|
+
return this.pattern(pattern.source, opt);
|
|
616
|
+
}
|
|
617
|
+
pattern(pattern, opt) {
|
|
618
|
+
const clone = this.cloneAndUpdateSchema({ pattern });
|
|
619
|
+
if (opt?.name)
|
|
620
|
+
clone.setErrorMessage('pattern', `is not a valid ${opt.name}`);
|
|
621
|
+
if (opt?.msg)
|
|
622
|
+
clone.setErrorMessage('pattern', opt.msg);
|
|
623
|
+
return clone;
|
|
624
|
+
}
|
|
625
|
+
minLength(minLength) {
|
|
626
|
+
return this.cloneAndUpdateSchema({ minLength });
|
|
627
|
+
}
|
|
628
|
+
maxLength(maxLength) {
|
|
629
|
+
return this.cloneAndUpdateSchema({ maxLength });
|
|
630
|
+
}
|
|
631
|
+
length(minLengthOrExactLength, maxLength) {
|
|
632
|
+
const maxLengthActual = maxLength ?? minLengthOrExactLength;
|
|
633
|
+
return this.minLength(minLengthOrExactLength).maxLength(maxLengthActual);
|
|
634
|
+
}
|
|
635
|
+
email(opt) {
|
|
636
|
+
const defaultOptions = { checkTLD: true };
|
|
637
|
+
return this.cloneAndUpdateSchema({ email: { ...defaultOptions, ...opt } })
|
|
638
|
+
.trim()
|
|
639
|
+
.toLowerCase();
|
|
640
|
+
}
|
|
641
|
+
trim() {
|
|
642
|
+
return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, trim: true } });
|
|
643
|
+
}
|
|
644
|
+
toLowerCase() {
|
|
645
|
+
return this.cloneAndUpdateSchema({
|
|
646
|
+
transform: { ...this.schema.transform, toLowerCase: true },
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
toUpperCase() {
|
|
650
|
+
return this.cloneAndUpdateSchema({
|
|
651
|
+
transform: { ...this.schema.transform, toUpperCase: true },
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
truncate(toLength) {
|
|
655
|
+
return this.cloneAndUpdateSchema({
|
|
656
|
+
transform: { ...this.schema.transform, truncate: toLength },
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
branded() {
|
|
660
|
+
return this;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Validates that the input is a fully-specified YYYY-MM-DD formatted valid IsoDate value.
|
|
664
|
+
*
|
|
665
|
+
* All previous expectations in the schema chain are dropped - including `.optional()` -
|
|
666
|
+
* because this call effectively starts a new schema chain.
|
|
667
|
+
*/
|
|
668
|
+
isoDate() {
|
|
669
|
+
return new JsonSchemaIsoDateBuilder();
|
|
670
|
+
}
|
|
671
|
+
isoDateTime() {
|
|
672
|
+
return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded();
|
|
673
|
+
}
|
|
674
|
+
isoMonth() {
|
|
675
|
+
return new JsonSchemaIsoMonthBuilder();
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Validates the string format to be JWT.
|
|
679
|
+
* Expects the JWT to be signed!
|
|
680
|
+
*/
|
|
681
|
+
jwt() {
|
|
682
|
+
return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' });
|
|
683
|
+
}
|
|
684
|
+
url() {
|
|
685
|
+
return this.regex(URL_REGEX, { msg: 'is not a valid URL format' });
|
|
686
|
+
}
|
|
687
|
+
ipv4() {
|
|
688
|
+
return this.regex(IPV4_REGEX, { msg: 'is not a valid IPv4 format' });
|
|
689
|
+
}
|
|
690
|
+
ipv6() {
|
|
691
|
+
return this.regex(IPV6_REGEX, { msg: 'is not a valid IPv6 format' });
|
|
692
|
+
}
|
|
693
|
+
slug() {
|
|
694
|
+
return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' });
|
|
695
|
+
}
|
|
696
|
+
semVer() {
|
|
697
|
+
return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' });
|
|
698
|
+
}
|
|
699
|
+
languageTag() {
|
|
700
|
+
return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' });
|
|
701
|
+
}
|
|
702
|
+
countryCode() {
|
|
703
|
+
return this.regex(COUNTRY_CODE_REGEX, { msg: 'is not a valid country code format' });
|
|
704
|
+
}
|
|
705
|
+
currency() {
|
|
706
|
+
return this.regex(CURRENCY_REGEX, { msg: 'is not a valid currency format' });
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Validates that the input is a valid IANATimzone value.
|
|
710
|
+
*
|
|
711
|
+
* All previous expectations in the schema chain are dropped - including `.optional()` -
|
|
712
|
+
* because this call effectively starts a new schema chain as an `enum` validation.
|
|
713
|
+
*/
|
|
714
|
+
ianaTimezone() {
|
|
715
|
+
// UTC is added to assist unit-testing, which uses UTC by default (not technically a valid Iana timezone identifier)
|
|
716
|
+
return j.enum(TIMEZONES, { msg: 'is an invalid IANA timezone' }).branded();
|
|
717
|
+
}
|
|
718
|
+
base64Url() {
|
|
719
|
+
return this.regex(BASE64URL_REGEX, {
|
|
720
|
+
msg: 'contains characters not allowed in Base64 URL characterset',
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
uuid() {
|
|
724
|
+
return this.regex(UUID_REGEX, { msg: 'is an invalid UUID' });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
export class JsonSchemaIsoDateBuilder extends JsonSchemaAnyBuilder {
|
|
728
|
+
constructor() {
|
|
729
|
+
super({
|
|
730
|
+
type: 'string',
|
|
731
|
+
IsoDate: {},
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
|
|
736
|
+
*
|
|
737
|
+
* This `null` feature only works when the current schema is nested in an object or array schema,
|
|
738
|
+
* due to how mutability works in Ajv.
|
|
739
|
+
*
|
|
740
|
+
* When `null` is passed, the return type becomes `JsonSchemaTerminal`
|
|
741
|
+
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
|
|
742
|
+
*/
|
|
743
|
+
optional(nullValue) {
|
|
744
|
+
if (nullValue === undefined) {
|
|
745
|
+
return super.optional();
|
|
746
|
+
}
|
|
747
|
+
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
|
|
748
|
+
// so we must allow `null` values to be parsed by Ajv,
|
|
749
|
+
// but the typing should not reflect that.
|
|
750
|
+
// We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
|
|
751
|
+
return new JsonSchemaTerminal({
|
|
752
|
+
anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
|
|
753
|
+
optionalField: true,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
before(date) {
|
|
757
|
+
return this.cloneAndUpdateSchema({ IsoDate: { before: date } });
|
|
758
|
+
}
|
|
759
|
+
sameOrBefore(date) {
|
|
760
|
+
return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } });
|
|
761
|
+
}
|
|
762
|
+
after(date) {
|
|
763
|
+
return this.cloneAndUpdateSchema({ IsoDate: { after: date } });
|
|
764
|
+
}
|
|
765
|
+
sameOrAfter(date) {
|
|
766
|
+
return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } });
|
|
767
|
+
}
|
|
768
|
+
between(fromDate, toDate, incl) {
|
|
769
|
+
let schemaPatch = {};
|
|
770
|
+
if (incl === '[)') {
|
|
771
|
+
schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } };
|
|
772
|
+
}
|
|
773
|
+
else if (incl === '[]') {
|
|
774
|
+
schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } };
|
|
775
|
+
}
|
|
776
|
+
return this.cloneAndUpdateSchema(schemaPatch);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
export class JsonSchemaIsoMonthBuilder extends JsonSchemaAnyBuilder {
|
|
780
|
+
constructor() {
|
|
781
|
+
super({
|
|
782
|
+
type: 'string',
|
|
783
|
+
IsoMonth: {},
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
export class JsonSchemaNumberBuilder extends JsonSchemaAnyBuilder {
|
|
788
|
+
constructor() {
|
|
789
|
+
super({
|
|
790
|
+
type: 'number',
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* @param optionalValues List of values that should be considered/converted as `undefined`.
|
|
795
|
+
*
|
|
796
|
+
* This `optionalValues` feature only works when the current schema is nested in an object or array schema,
|
|
797
|
+
* due to how mutability works in Ajv.
|
|
798
|
+
*
|
|
799
|
+
* Make sure this `optional()` call is at the end of your call chain.
|
|
800
|
+
*
|
|
801
|
+
* When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
|
|
802
|
+
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
|
|
803
|
+
*/
|
|
804
|
+
optional(optionalValues) {
|
|
805
|
+
if (!optionalValues) {
|
|
806
|
+
return super.optional();
|
|
807
|
+
}
|
|
808
|
+
_typeCast(optionalValues);
|
|
809
|
+
let newBuilder = new JsonSchemaNumberBuilder().optional();
|
|
810
|
+
const alternativesSchema = j.enum(optionalValues);
|
|
811
|
+
Object.assign(newBuilder.getSchema(), {
|
|
812
|
+
anyOf: [this.build(), alternativesSchema.build()],
|
|
813
|
+
optionalValues,
|
|
814
|
+
});
|
|
815
|
+
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
|
|
816
|
+
// so we must allow `null` values to be parsed by Ajv,
|
|
817
|
+
// but the typing should not reflect that.
|
|
818
|
+
// We also cannot accept more rules attached, since we're not building a NumberSchema anymore.
|
|
819
|
+
if (optionalValues.includes(null)) {
|
|
820
|
+
newBuilder = new JsonSchemaTerminal({
|
|
821
|
+
anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
|
|
822
|
+
optionalField: true,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
return newBuilder;
|
|
826
|
+
}
|
|
827
|
+
integer() {
|
|
828
|
+
return this.cloneAndUpdateSchema({ type: 'integer' });
|
|
829
|
+
}
|
|
830
|
+
branded() {
|
|
831
|
+
return this;
|
|
832
|
+
}
|
|
833
|
+
multipleOf(multipleOf) {
|
|
834
|
+
return this.cloneAndUpdateSchema({ multipleOf });
|
|
835
|
+
}
|
|
836
|
+
min(minimum) {
|
|
837
|
+
return this.cloneAndUpdateSchema({ minimum });
|
|
838
|
+
}
|
|
839
|
+
exclusiveMin(exclusiveMinimum) {
|
|
840
|
+
return this.cloneAndUpdateSchema({ exclusiveMinimum });
|
|
841
|
+
}
|
|
842
|
+
max(maximum) {
|
|
843
|
+
return this.cloneAndUpdateSchema({ maximum });
|
|
844
|
+
}
|
|
845
|
+
exclusiveMax(exclusiveMaximum) {
|
|
846
|
+
return this.cloneAndUpdateSchema({ exclusiveMaximum });
|
|
847
|
+
}
|
|
848
|
+
lessThan(value) {
|
|
849
|
+
return this.exclusiveMax(value);
|
|
850
|
+
}
|
|
851
|
+
lessThanOrEqual(value) {
|
|
852
|
+
return this.max(value);
|
|
853
|
+
}
|
|
854
|
+
moreThan(value) {
|
|
855
|
+
return this.exclusiveMin(value);
|
|
856
|
+
}
|
|
857
|
+
moreThanOrEqual(value) {
|
|
858
|
+
return this.min(value);
|
|
859
|
+
}
|
|
860
|
+
equal(value) {
|
|
861
|
+
return this.min(value).max(value);
|
|
862
|
+
}
|
|
863
|
+
range(minimum, maximum, incl) {
|
|
864
|
+
if (incl === '[)') {
|
|
865
|
+
return this.moreThanOrEqual(minimum).lessThan(maximum);
|
|
866
|
+
}
|
|
867
|
+
return this.moreThanOrEqual(minimum).lessThanOrEqual(maximum);
|
|
868
|
+
}
|
|
869
|
+
int32() {
|
|
870
|
+
const MIN_INT32 = -(2 ** 31);
|
|
871
|
+
const MAX_INT32 = 2 ** 31 - 1;
|
|
872
|
+
const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER;
|
|
873
|
+
const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER;
|
|
874
|
+
const newMin = Math.max(MIN_INT32, currentMin);
|
|
875
|
+
const newMax = Math.min(MAX_INT32, currentMax);
|
|
876
|
+
return this.integer().min(newMin).max(newMax);
|
|
877
|
+
}
|
|
878
|
+
int64() {
|
|
879
|
+
const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER;
|
|
880
|
+
const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER;
|
|
881
|
+
const newMin = Math.max(Number.MIN_SAFE_INTEGER, currentMin);
|
|
882
|
+
const newMax = Math.min(Number.MAX_SAFE_INTEGER, currentMax);
|
|
883
|
+
return this.integer().min(newMin).max(newMax);
|
|
884
|
+
}
|
|
885
|
+
float() {
|
|
886
|
+
return this;
|
|
887
|
+
}
|
|
888
|
+
double() {
|
|
889
|
+
return this;
|
|
890
|
+
}
|
|
891
|
+
unixTimestamp() {
|
|
892
|
+
return this.integer().min(0).max(TS_2500).branded();
|
|
893
|
+
}
|
|
894
|
+
unixTimestamp2000() {
|
|
895
|
+
return this.integer().min(TS_2000).max(TS_2500).branded();
|
|
896
|
+
}
|
|
897
|
+
unixTimestampMillis() {
|
|
898
|
+
return this.integer().min(0).max(TS_2500_MILLIS).branded();
|
|
899
|
+
}
|
|
900
|
+
unixTimestamp2000Millis() {
|
|
901
|
+
return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded();
|
|
902
|
+
}
|
|
903
|
+
utcOffset() {
|
|
904
|
+
return this.integer()
|
|
905
|
+
.multipleOf(15)
|
|
906
|
+
.min(-12 * 60)
|
|
907
|
+
.max(14 * 60);
|
|
908
|
+
}
|
|
909
|
+
utcOffsetHour() {
|
|
910
|
+
return this.integer().min(-12).max(14);
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Specify the precision of the floating point numbers by the number of digits after the ".".
|
|
914
|
+
* Excess digits will be cut-off when the current schema is nested in an object or array schema,
|
|
915
|
+
* due to how mutability works in Ajv.
|
|
916
|
+
*/
|
|
917
|
+
precision(numberOfDigits) {
|
|
918
|
+
return this.cloneAndUpdateSchema({ precision: numberOfDigits });
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
export class JsonSchemaBooleanBuilder extends JsonSchemaAnyBuilder {
|
|
922
|
+
constructor() {
|
|
923
|
+
super({
|
|
924
|
+
type: 'boolean',
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* @param optionalValue One of the two possible boolean values that should be considered/converted as `undefined`.
|
|
929
|
+
*
|
|
930
|
+
* This `optionalValue` feature only works when the current schema is nested in an object or array schema,
|
|
931
|
+
* due to how mutability works in Ajv.
|
|
932
|
+
*/
|
|
933
|
+
optional(optionalValue) {
|
|
934
|
+
if (typeof optionalValue === 'undefined') {
|
|
935
|
+
return super.optional();
|
|
936
|
+
}
|
|
937
|
+
const newBuilder = new JsonSchemaBooleanBuilder().optional();
|
|
938
|
+
const alternativesSchema = j.enum([optionalValue]);
|
|
939
|
+
Object.assign(newBuilder.getSchema(), {
|
|
940
|
+
anyOf: [this.build(), alternativesSchema.build()],
|
|
941
|
+
optionalValues: [optionalValue],
|
|
942
|
+
});
|
|
943
|
+
return newBuilder;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
export class JsonSchemaObjectBuilder extends JsonSchemaAnyBuilder {
|
|
947
|
+
constructor(props, opt) {
|
|
948
|
+
super({
|
|
949
|
+
type: 'object',
|
|
950
|
+
properties: {},
|
|
951
|
+
required: [],
|
|
952
|
+
additionalProperties: false,
|
|
953
|
+
hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true,
|
|
954
|
+
patternProperties: opt?.patternProperties ?? undefined,
|
|
955
|
+
keySchema: opt?.keySchema ?? undefined,
|
|
956
|
+
});
|
|
957
|
+
if (props)
|
|
958
|
+
this.addProperties(props);
|
|
959
|
+
}
|
|
960
|
+
addProperties(props) {
|
|
961
|
+
const properties = {};
|
|
962
|
+
const required = [];
|
|
963
|
+
for (const [key, builder] of Object.entries(props)) {
|
|
964
|
+
const isOptional = builder.getSchema().optionalField;
|
|
965
|
+
if (!isOptional) {
|
|
966
|
+
required.push(key);
|
|
967
|
+
}
|
|
968
|
+
const schema = builder.build();
|
|
969
|
+
properties[key] = schema;
|
|
970
|
+
}
|
|
971
|
+
this.schema.properties = properties;
|
|
972
|
+
this.schema.required = _uniq(required).sort();
|
|
973
|
+
return this;
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
|
|
977
|
+
*
|
|
978
|
+
* This `null` feature only works when the current schema is nested in an object or array schema,
|
|
979
|
+
* due to how mutability works in Ajv.
|
|
980
|
+
*
|
|
981
|
+
* When `null` is passed, the return type becomes `JsonSchemaTerminal`
|
|
982
|
+
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
|
|
983
|
+
*/
|
|
984
|
+
optional(nullValue) {
|
|
985
|
+
if (nullValue === undefined) {
|
|
986
|
+
return super.optional();
|
|
987
|
+
}
|
|
988
|
+
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
|
|
989
|
+
// so we must allow `null` values to be parsed by Ajv,
|
|
990
|
+
// but the typing should not reflect that.
|
|
991
|
+
// We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
|
|
992
|
+
return new JsonSchemaTerminal({
|
|
993
|
+
anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
|
|
994
|
+
optionalField: true,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* When set, the validation will not strip away properties that are not specified explicitly in the schema.
|
|
999
|
+
*/
|
|
1000
|
+
allowAdditionalProperties() {
|
|
1001
|
+
return this.cloneAndUpdateSchema({ additionalProperties: true });
|
|
1002
|
+
}
|
|
1003
|
+
extend(props) {
|
|
1004
|
+
const newBuilder = new JsonSchemaObjectBuilder();
|
|
1005
|
+
_objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema));
|
|
1006
|
+
const incomingSchemaBuilder = new JsonSchemaObjectBuilder(props);
|
|
1007
|
+
mergeJsonSchemaObjects(newBuilder.schema, incomingSchemaBuilder.schema);
|
|
1008
|
+
_objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false });
|
|
1009
|
+
return newBuilder;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Concatenates another schema to the current schema.
|
|
1013
|
+
*
|
|
1014
|
+
* It expects you to use `isOfType<T>()` in the chain,
|
|
1015
|
+
* otherwise the validation will throw. This is to ensure
|
|
1016
|
+
* that the schemas you concatenated match the intended final type.
|
|
1017
|
+
*
|
|
1018
|
+
* ```ts
|
|
1019
|
+
* interface Foo { foo: string }
|
|
1020
|
+
* const fooSchema = j.object<Foo>({ foo: j.string() })
|
|
1021
|
+
*
|
|
1022
|
+
* interface Bar { bar: number }
|
|
1023
|
+
* const barSchema = j.object<Bar>({ bar: j.number() })
|
|
1024
|
+
*
|
|
1025
|
+
* interface Shu { foo: string, bar: number }
|
|
1026
|
+
* const shuSchema = fooSchema.concat(barSchema).isOfType<Shu>() // important
|
|
1027
|
+
* ```
|
|
1028
|
+
*/
|
|
1029
|
+
concat(other) {
|
|
1030
|
+
const clone = this.clone();
|
|
1031
|
+
mergeJsonSchemaObjects(clone.schema, other.schema);
|
|
1032
|
+
_objectAssign(clone.schema, { hasIsOfTypeCheck: false });
|
|
1033
|
+
return clone;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
|
|
1037
|
+
*/
|
|
1038
|
+
// oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
|
|
1039
|
+
dbEntity() {
|
|
1040
|
+
return this.extend({
|
|
1041
|
+
id: j.string(),
|
|
1042
|
+
created: j.number().unixTimestamp2000(),
|
|
1043
|
+
updated: j.number().unixTimestamp2000(),
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
minProperties(minProperties) {
|
|
1047
|
+
return this.cloneAndUpdateSchema({ minProperties, minProperties2: minProperties });
|
|
1048
|
+
}
|
|
1049
|
+
maxProperties(maxProperties) {
|
|
1050
|
+
return this.cloneAndUpdateSchema({ maxProperties });
|
|
1051
|
+
}
|
|
1052
|
+
exclusiveProperties(propNames) {
|
|
1053
|
+
const exclusiveProperties = this.schema.exclusiveProperties ?? [];
|
|
1054
|
+
return this.cloneAndUpdateSchema({ exclusiveProperties: [...exclusiveProperties, propNames] });
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
export class JsonSchemaObjectInferringBuilder extends JsonSchemaAnyBuilder {
|
|
1058
|
+
constructor(props) {
|
|
1059
|
+
super({
|
|
1060
|
+
type: 'object',
|
|
1061
|
+
properties: {},
|
|
1062
|
+
required: [],
|
|
1063
|
+
additionalProperties: false,
|
|
1064
|
+
});
|
|
1065
|
+
if (props)
|
|
1066
|
+
this.addProperties(props);
|
|
1067
|
+
}
|
|
1068
|
+
addProperties(props) {
|
|
1069
|
+
const properties = {};
|
|
1070
|
+
const required = [];
|
|
1071
|
+
for (const [key, builder] of Object.entries(props)) {
|
|
1072
|
+
const isOptional = builder.getSchema().optionalField;
|
|
1073
|
+
if (!isOptional) {
|
|
1074
|
+
required.push(key);
|
|
1075
|
+
}
|
|
1076
|
+
const schema = builder.build();
|
|
1077
|
+
properties[key] = schema;
|
|
1078
|
+
}
|
|
1079
|
+
this.schema.properties = properties;
|
|
1080
|
+
this.schema.required = _uniq(required).sort();
|
|
1081
|
+
return this;
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
|
|
1085
|
+
*
|
|
1086
|
+
* This `null` feature only works when the current schema is nested in an object or array schema,
|
|
1087
|
+
* due to how mutability works in Ajv.
|
|
1088
|
+
*
|
|
1089
|
+
* When `null` is passed, the return type becomes `JsonSchemaTerminal`
|
|
1090
|
+
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
|
|
1091
|
+
*/
|
|
1092
|
+
// @ts-expect-error override adds optional parameter which is compatible but TS can't verify complex mapped types
|
|
1093
|
+
optional(nullValue) {
|
|
1094
|
+
if (nullValue === undefined) {
|
|
1095
|
+
return super.optional();
|
|
1096
|
+
}
|
|
1097
|
+
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
|
|
1098
|
+
// so we must allow `null` values to be parsed by Ajv,
|
|
1099
|
+
// but the typing should not reflect that.
|
|
1100
|
+
// We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
|
|
1101
|
+
return new JsonSchemaTerminal({
|
|
1102
|
+
anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
|
|
1103
|
+
optionalField: true,
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* When set, the validation will not strip away properties that are not specified explicitly in the schema.
|
|
1108
|
+
*/
|
|
1109
|
+
allowAdditionalProperties() {
|
|
1110
|
+
return this.cloneAndUpdateSchema({ additionalProperties: true });
|
|
1111
|
+
}
|
|
1112
|
+
extend(props) {
|
|
1113
|
+
const newBuilder = new JsonSchemaObjectInferringBuilder();
|
|
1114
|
+
_objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema));
|
|
1115
|
+
const incomingSchemaBuilder = new JsonSchemaObjectInferringBuilder(props);
|
|
1116
|
+
mergeJsonSchemaObjects(newBuilder.schema, incomingSchemaBuilder.schema);
|
|
1117
|
+
// This extend function is not type-safe as it is inferring,
|
|
1118
|
+
// so even if the base schema was already type-checked,
|
|
1119
|
+
// the new schema loses that quality.
|
|
1120
|
+
_objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false });
|
|
1121
|
+
return newBuilder;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
|
|
1125
|
+
*/
|
|
1126
|
+
// oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
|
|
1127
|
+
dbEntity() {
|
|
1128
|
+
return this.extend({
|
|
1129
|
+
id: j.string(),
|
|
1130
|
+
created: j.number().unixTimestamp2000(),
|
|
1131
|
+
updated: j.number().unixTimestamp2000(),
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
export class JsonSchemaArrayBuilder extends JsonSchemaAnyBuilder {
|
|
1136
|
+
constructor(itemsSchema) {
|
|
1137
|
+
super({
|
|
1138
|
+
type: 'array',
|
|
1139
|
+
items: itemsSchema.build(),
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
minLength(minItems) {
|
|
1143
|
+
return this.cloneAndUpdateSchema({ minItems });
|
|
1144
|
+
}
|
|
1145
|
+
maxLength(maxItems) {
|
|
1146
|
+
return this.cloneAndUpdateSchema({ maxItems });
|
|
1147
|
+
}
|
|
1148
|
+
length(minItemsOrExact, maxItems) {
|
|
1149
|
+
const maxItemsActual = maxItems ?? minItemsOrExact;
|
|
1150
|
+
return this.minLength(minItemsOrExact).maxLength(maxItemsActual);
|
|
1151
|
+
}
|
|
1152
|
+
exactLength(length) {
|
|
1153
|
+
return this.minLength(length).maxLength(length);
|
|
1154
|
+
}
|
|
1155
|
+
unique() {
|
|
1156
|
+
return this.cloneAndUpdateSchema({ uniqueItems: true });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
export class JsonSchemaSet2Builder extends JsonSchemaAnyBuilder {
|
|
1160
|
+
constructor(itemsSchema) {
|
|
1161
|
+
super({
|
|
1162
|
+
type: ['array', 'object'],
|
|
1163
|
+
Set2: itemsSchema.build(),
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
min(minItems) {
|
|
1167
|
+
return this.cloneAndUpdateSchema({ minItems });
|
|
1168
|
+
}
|
|
1169
|
+
max(maxItems) {
|
|
1170
|
+
return this.cloneAndUpdateSchema({ maxItems });
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
export class JsonSchemaBufferBuilder extends JsonSchemaAnyBuilder {
|
|
1174
|
+
constructor() {
|
|
1175
|
+
super({
|
|
1176
|
+
Buffer: true,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
export class JsonSchemaEnumBuilder extends JsonSchemaAnyBuilder {
|
|
1181
|
+
constructor(enumValues, baseType, opt) {
|
|
1182
|
+
const jsonSchema = { enum: enumValues };
|
|
1183
|
+
// Specifying the base type helps in cases when we ask Ajv to coerce the types.
|
|
1184
|
+
// Having only the `enum` in the schema does not trigger a coercion in Ajv.
|
|
1185
|
+
if (baseType === 'string')
|
|
1186
|
+
jsonSchema.type = 'string';
|
|
1187
|
+
if (baseType === 'number')
|
|
1188
|
+
jsonSchema.type = 'number';
|
|
1189
|
+
super(jsonSchema);
|
|
1190
|
+
if (opt?.name)
|
|
1191
|
+
this.setErrorMessage('pattern', `is not a valid ${opt.name}`);
|
|
1192
|
+
if (opt?.msg)
|
|
1193
|
+
this.setErrorMessage('enum', opt.msg);
|
|
1194
|
+
}
|
|
1195
|
+
branded() {
|
|
1196
|
+
return this;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
export class JsonSchemaTupleBuilder extends JsonSchemaAnyBuilder {
|
|
1200
|
+
_items;
|
|
1201
|
+
constructor(items) {
|
|
1202
|
+
super({
|
|
1203
|
+
type: 'array',
|
|
1204
|
+
prefixItems: items.map(i => i.build()),
|
|
1205
|
+
minItems: items.length,
|
|
1206
|
+
maxItems: items.length,
|
|
1207
|
+
});
|
|
1208
|
+
this._items = items;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
export class JsonSchemaAnyOfByBuilder extends JsonSchemaAnyBuilder {
|
|
1212
|
+
constructor(propertyName, schemaDictionary) {
|
|
1213
|
+
const builtSchemaDictionary = {};
|
|
1214
|
+
for (const [key, schema] of Object.entries(schemaDictionary)) {
|
|
1215
|
+
builtSchemaDictionary[key] = schema.build();
|
|
1216
|
+
}
|
|
1217
|
+
super({
|
|
1218
|
+
type: 'object',
|
|
1219
|
+
hasIsOfTypeCheck: true,
|
|
1220
|
+
anyOfBy: {
|
|
1221
|
+
propertyName,
|
|
1222
|
+
schemaDictionary: builtSchemaDictionary,
|
|
1223
|
+
},
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
export class JsonSchemaAnyOfTheseBuilder extends JsonSchemaAnyBuilder {
|
|
1228
|
+
constructor(propertyName, schemaDictionary) {
|
|
1229
|
+
const builtSchemaDictionary = {};
|
|
1230
|
+
for (const [key, schema] of Object.entries(schemaDictionary)) {
|
|
1231
|
+
builtSchemaDictionary[key] = schema.build();
|
|
1232
|
+
}
|
|
1233
|
+
super({
|
|
1234
|
+
type: 'object',
|
|
1235
|
+
hasIsOfTypeCheck: true,
|
|
1236
|
+
anyOfBy: {
|
|
1237
|
+
propertyName,
|
|
1238
|
+
schemaDictionary: builtSchemaDictionary,
|
|
1239
|
+
},
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
function object(props) {
|
|
1244
|
+
return new JsonSchemaObjectBuilder(props);
|
|
1245
|
+
}
|
|
1246
|
+
function objectInfer(props) {
|
|
1247
|
+
return new JsonSchemaObjectInferringBuilder(props);
|
|
1248
|
+
}
|
|
1249
|
+
function objectDbEntity(props) {
|
|
1250
|
+
return j.object({
|
|
1251
|
+
id: j.string(),
|
|
1252
|
+
created: j.number().unixTimestamp2000(),
|
|
1253
|
+
updated: j.number().unixTimestamp2000(),
|
|
1254
|
+
...props,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
function record(keySchema, valueSchema) {
|
|
1258
|
+
const keyJsonSchema = keySchema.build();
|
|
1259
|
+
// Check if value schema is optional before build() strips the optionalField flag
|
|
1260
|
+
const isValueOptional = valueSchema.getSchema()
|
|
1261
|
+
.optionalField;
|
|
1262
|
+
const valueJsonSchema = valueSchema.build();
|
|
1263
|
+
// When value schema is optional, wrap in anyOf to allow undefined values
|
|
1264
|
+
const finalValueSchema = isValueOptional
|
|
1265
|
+
? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
|
|
1266
|
+
: valueJsonSchema;
|
|
1267
|
+
return new JsonSchemaObjectBuilder([], {
|
|
1268
|
+
hasIsOfTypeCheck: false,
|
|
1269
|
+
keySchema: keyJsonSchema,
|
|
1270
|
+
patternProperties: {
|
|
1271
|
+
['^.*$']: finalValueSchema,
|
|
1272
|
+
},
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
function withRegexKeys(keyRegex, schema) {
|
|
1276
|
+
if (keyRegex instanceof RegExp) {
|
|
1277
|
+
_assert(!keyRegex.flags, `Regex flags are not supported by JSON Schema. Received: /${keyRegex.source}/${keyRegex.flags}`);
|
|
1278
|
+
}
|
|
1279
|
+
const pattern = keyRegex instanceof RegExp ? keyRegex.source : keyRegex;
|
|
1280
|
+
const jsonSchema = schema.build();
|
|
1281
|
+
return new JsonSchemaObjectBuilder([], {
|
|
1282
|
+
hasIsOfTypeCheck: false,
|
|
1283
|
+
patternProperties: {
|
|
1284
|
+
[pattern]: jsonSchema,
|
|
1285
|
+
},
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Builds the object schema with the indicated `keys` and uses the `schema` for their validation.
|
|
1290
|
+
*/
|
|
1291
|
+
function withEnumKeys(keys, schema) {
|
|
1292
|
+
let enumValues;
|
|
1293
|
+
if (Array.isArray(keys)) {
|
|
1294
|
+
_assert(isEveryItemPrimitive(keys), 'Every item in the key list should be string, number or symbol');
|
|
1295
|
+
enumValues = keys;
|
|
1296
|
+
}
|
|
1297
|
+
else if (typeof keys === 'object') {
|
|
1298
|
+
const enumType = getEnumType(keys);
|
|
1299
|
+
_assert(enumType === 'NumberEnum' || enumType === 'StringEnum', 'The key list should be StringEnum or NumberEnum');
|
|
1300
|
+
if (enumType === 'NumberEnum') {
|
|
1301
|
+
enumValues = _numberEnumValues(keys);
|
|
1302
|
+
}
|
|
1303
|
+
else if (enumType === 'StringEnum') {
|
|
1304
|
+
enumValues = _stringEnumValues(keys);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
_assert(enumValues, 'The key list should be an array of values, NumberEnum or a StringEnum');
|
|
1308
|
+
const typedValues = enumValues;
|
|
1309
|
+
const props = Object.fromEntries(typedValues.map(key => [key, schema]));
|
|
1310
|
+
return new JsonSchemaObjectBuilder(props, { hasIsOfTypeCheck: false });
|
|
1311
|
+
}
|
|
1312
|
+
function hasNoObjectSchemas(schema) {
|
|
1313
|
+
if (Array.isArray(schema.type)) {
|
|
1314
|
+
schema.type.every(type => ['string', 'number', 'integer', 'boolean', 'null'].includes(type));
|
|
1315
|
+
}
|
|
1316
|
+
else if (schema.anyOf) {
|
|
1317
|
+
return schema.anyOf.every(hasNoObjectSchemas);
|
|
1318
|
+
}
|
|
1319
|
+
else if (schema.oneOf) {
|
|
1320
|
+
return schema.oneOf.every(hasNoObjectSchemas);
|
|
1321
|
+
}
|
|
1322
|
+
else if (schema.enum) {
|
|
1323
|
+
return true;
|
|
1324
|
+
}
|
|
1325
|
+
else if (schema.type === 'array') {
|
|
1326
|
+
return !schema.items || hasNoObjectSchemas(schema.items);
|
|
1327
|
+
}
|
|
1328
|
+
else {
|
|
1329
|
+
return !!schema.type && ['string', 'number', 'integer', 'boolean', 'null'].includes(schema.type);
|
|
1330
|
+
}
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Deep copy that preserves functions in customValidations/customConversions.
|
|
1335
|
+
* Unlike structuredClone, this handles function references (which only exist in those two properties).
|
|
1336
|
+
*/
|
|
1337
|
+
function deepCopyPreservingFunctions(obj) {
|
|
1338
|
+
if (obj === null || typeof obj !== 'object')
|
|
1339
|
+
return obj;
|
|
1340
|
+
if (Array.isArray(obj))
|
|
1341
|
+
return obj.map(deepCopyPreservingFunctions);
|
|
1342
|
+
const copy = {};
|
|
1343
|
+
for (const key of Object.keys(obj)) {
|
|
1344
|
+
const value = obj[key];
|
|
1345
|
+
copy[key] =
|
|
1346
|
+
(key === 'customValidations' || key === 'customConversions') && Array.isArray(value)
|
|
1347
|
+
? [...value]
|
|
1348
|
+
: deepCopyPreservingFunctions(value);
|
|
1349
|
+
}
|
|
1350
|
+
return copy;
|
|
1351
|
+
}
|