@naturalcycles/nodejs-lib 15.38.0 → 15.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,768 @@
1
+ /* eslint-disable id-denylist */
2
+ // oxlint-disable max-lines
3
+
4
+ import {
5
+ _isUndefined,
6
+ _numberEnumValues,
7
+ _stringEnumValues,
8
+ getEnumType,
9
+ } from '@naturalcycles/js-lib'
10
+ import { _uniq } from '@naturalcycles/js-lib/array'
11
+ import { _assert } from '@naturalcycles/js-lib/error'
12
+ import { JSON_SCHEMA_ORDER, mergeJsonSchemaObjects } from '@naturalcycles/js-lib/json-schema'
13
+ import type { Set2 } from '@naturalcycles/js-lib/object'
14
+ import { _deepCopy, _sortObject } from '@naturalcycles/js-lib/object'
15
+ import {
16
+ type AnyObject,
17
+ type IsoDate,
18
+ type IsoDateTime,
19
+ JWT_REGEX,
20
+ type NumberEnum,
21
+ type StringEnum,
22
+ type StringMap,
23
+ type UnixTimestamp,
24
+ type UnixTimestampMillis,
25
+ } from '@naturalcycles/js-lib/types'
26
+
27
+ export const j = {
28
+ string(): JsonSchemaStringBuilder<string, string, false> {
29
+ return new JsonSchemaStringBuilder()
30
+ },
31
+
32
+ number(): JsonSchemaNumberBuilder<number, number, false> {
33
+ return new JsonSchemaNumberBuilder()
34
+ },
35
+
36
+ boolean(): JsonSchemaBooleanBuilder<boolean, boolean, false> {
37
+ return new JsonSchemaBooleanBuilder()
38
+ },
39
+
40
+ object<P extends Record<string, JsonSchemaAnyBuilder<any, any, any>>>(
41
+ props: P,
42
+ ): JsonSchemaObjectBuilder<P, false> {
43
+ return new JsonSchemaObjectBuilder<P, false>(props)
44
+ },
45
+
46
+ array<IN, OUT, Opt>(
47
+ itemSchema: JsonSchemaAnyBuilder<IN, OUT, Opt>,
48
+ ): JsonSchemaArrayBuilder<IN, OUT, Opt> {
49
+ return new JsonSchemaArrayBuilder(itemSchema)
50
+ },
51
+
52
+ set<IN, OUT, Opt>(
53
+ itemSchema: JsonSchemaAnyBuilder<IN, OUT, Opt>,
54
+ ): JsonSchemaSet2Builder<IN, OUT, Opt> {
55
+ return new JsonSchemaSet2Builder(itemSchema)
56
+ },
57
+
58
+ buffer(): JsonSchemaBufferBuilder {
59
+ return new JsonSchemaBufferBuilder()
60
+ },
61
+
62
+ enum<const T extends readonly (string | number | boolean | null)[] | StringEnum | NumberEnum>(
63
+ input: T,
64
+ ): JsonSchemaEnumBuilder<
65
+ T extends readonly (infer U)[]
66
+ ? U
67
+ : T extends StringEnum
68
+ ? T[keyof T]
69
+ : T extends NumberEnum
70
+ ? T[keyof T]
71
+ : never
72
+ > {
73
+ let enumValues: readonly (string | number | boolean | null)[] | undefined
74
+
75
+ if (Array.isArray(input)) {
76
+ enumValues = input
77
+ } else if (typeof input === 'object') {
78
+ const enumType = getEnumType(input)
79
+ if (enumType === 'NumberEnum') {
80
+ enumValues = _numberEnumValues(input as NumberEnum)
81
+ } else if (enumType === 'StringEnum') {
82
+ enumValues = _stringEnumValues(input as StringEnum)
83
+ }
84
+ }
85
+
86
+ _assert(enumValues, 'Unsupported enum input')
87
+ return new JsonSchemaEnumBuilder(enumValues as any)
88
+ },
89
+
90
+ oneOf<
91
+ B extends readonly JsonSchemaAnyBuilder<any, any, boolean>[],
92
+ IN = BuilderInUnion<B>,
93
+ OUT = BuilderOutUnion<B>,
94
+ >(items: [...B]): JsonSchemaAnyBuilder<IN, OUT, false> {
95
+ const schemas = items.map(b => b.build())
96
+ return new JsonSchemaAnyBuilder<IN, OUT, false>({
97
+ oneOf: schemas,
98
+ })
99
+ },
100
+ }
101
+
102
+ const TS_2500 = 16725225600 // 2500-01-01
103
+ const TS_2500_MILLIS = TS_2500 * 1000
104
+ const TS_2000 = 946684800 // 2000-01-01
105
+ const TS_2000_MILLIS = TS_2000 * 1000
106
+
107
+ /*
108
+ Notes for future reference
109
+
110
+ Q: Why do we need `Opt` - when `IN` and `OUT` already carries the `| undefined`?
111
+ A: Because of objects. Without `Opt`, an optional field would be inferred as `{ foo: string | undefined }`,
112
+ which means that the `foo` property would be mandatory, it's just that its value can be `undefined` as well.
113
+ With `Opt`, we can infer it as `{ foo?: string | undefined }`.
114
+ */
115
+
116
+ export class JsonSchemaAnyBuilder<IN, OUT, Opt> {
117
+ constructor(protected schema: JsonSchema) {}
118
+
119
+ protected setErrorMessage(ruleName: string, errorMessage: string | undefined): void {
120
+ if (_isUndefined(errorMessage)) return
121
+
122
+ this.schema.errorMessages ||= {}
123
+ this.schema.errorMessages[ruleName] = errorMessage
124
+ }
125
+
126
+ /**
127
+ * A helper function that takes a type parameter and compares it with the type inferred from the schema.
128
+ *
129
+ * When the type inferred from the schema differs from the passed-in type,
130
+ * the schema becomes unusable, by turning its type into `never`.
131
+ *
132
+ * ```ts
133
+ * const schemaGood = j.string().isOfType<string>() // ✅
134
+ *
135
+ * const schemaBad = j.string().isOfType<number>() // ❌
136
+ * schemaBad.build() // TypeError: property "build" does not exist on type "never"
137
+ * ```
138
+ */
139
+ isOfType<ExpectedType>(): ExactMatch<ExpectedType, OUT> extends true ? this : never {
140
+ Object.assign(this.schema, { hasIsOfTypeCheck: true })
141
+ return this as any
142
+ }
143
+
144
+ getSchema(): JsonSchema {
145
+ return this.schema
146
+ }
147
+
148
+ $schema($schema: string): this {
149
+ Object.assign(this.schema, { $schema })
150
+ return this
151
+ }
152
+
153
+ $schemaDraft7(): this {
154
+ this.$schema('http://json-schema.org/draft-07/schema#')
155
+ return this
156
+ }
157
+
158
+ $id($id: string): this {
159
+ Object.assign(this.schema, { $id })
160
+ return this
161
+ }
162
+
163
+ title(title: string): this {
164
+ Object.assign(this.schema, { title })
165
+ return this
166
+ }
167
+
168
+ description(description: string): this {
169
+ Object.assign(this.schema, { description })
170
+ return this
171
+ }
172
+
173
+ deprecated(deprecated = true): this {
174
+ Object.assign(this.schema, { deprecated })
175
+ return this
176
+ }
177
+
178
+ type(type: string): this {
179
+ Object.assign(this.schema, { type })
180
+ return this
181
+ }
182
+
183
+ default(v: any): this {
184
+ Object.assign(this.schema, { default: v })
185
+ return this
186
+ }
187
+
188
+ instanceof(of: string): this {
189
+ Object.assign(this.schema, { type: 'object', instanceof: of })
190
+ return this
191
+ }
192
+
193
+ optional(): JsonSchemaAnyBuilder<IN | undefined, OUT | undefined, true> {
194
+ this.schema.optionalField = true
195
+ return this as unknown as JsonSchemaAnyBuilder<IN | undefined, OUT | undefined, true>
196
+ }
197
+
198
+ nullable(): JsonSchemaAnyBuilder<IN | null, OUT | null, Opt> {
199
+ return new JsonSchemaAnyBuilder({
200
+ anyOf: [this.build(), { type: 'null' }],
201
+ })
202
+ }
203
+
204
+ /**
205
+ * Produces a "clean schema object" without methods.
206
+ * Same as if it would be JSON.stringified.
207
+ */
208
+ build(): JsonSchema<IN, OUT> {
209
+ return _sortObject(JSON.parse(JSON.stringify(this.schema)), JSON_SCHEMA_ORDER)
210
+ }
211
+
212
+ clone(): JsonSchemaAnyBuilder<IN, OUT, Opt> {
213
+ return new JsonSchemaAnyBuilder<IN, OUT, Opt>(_deepCopy(this.schema))
214
+ }
215
+
216
+ /**
217
+ * @deprecated
218
+ * The usage of this function is discouraged as it defeats the purpose of having type-safe validation.
219
+ */
220
+ castAs<T>(): JsonSchemaAnyBuilder<T, T, Opt> {
221
+ return this as unknown as JsonSchemaAnyBuilder<T, T, Opt>
222
+ }
223
+
224
+ /**
225
+ * @experimental
226
+ */
227
+ in!: IN
228
+ out!: OUT
229
+ }
230
+
231
+ export class JsonSchemaStringBuilder<
232
+ IN extends string = string,
233
+ OUT = IN,
234
+ Opt extends boolean = false,
235
+ > extends JsonSchemaAnyBuilder<IN, OUT, Opt> {
236
+ constructor() {
237
+ super({
238
+ type: 'string',
239
+ })
240
+ }
241
+
242
+ regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this {
243
+ return this.pattern(pattern.source, opt)
244
+ }
245
+
246
+ pattern(pattern: string, opt?: JsonBuilderRuleOpt): this {
247
+ if (opt?.msg) this.setErrorMessage('pattern', opt.msg)
248
+ Object.assign(this.schema, { pattern })
249
+ return this
250
+ }
251
+
252
+ min(minLength: number): this {
253
+ Object.assign(this.schema, { minLength })
254
+ return this
255
+ }
256
+
257
+ max(maxLength: number): this {
258
+ Object.assign(this.schema, { maxLength })
259
+ return this
260
+ }
261
+
262
+ length(minLength: number, maxLength: number): this {
263
+ Object.assign(this.schema, { minLength, maxLength })
264
+ return this
265
+ }
266
+
267
+ email(opt?: Partial<JsonSchemaStringEmailOptions>): this {
268
+ const defaultOptions: JsonSchemaStringEmailOptions = { checkTLD: true }
269
+ Object.assign(this.schema, { email: { ...defaultOptions, ...opt } })
270
+
271
+ // from `ajv-formats`
272
+ const regex =
273
+ /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i
274
+ return this.regex(regex, { msg: 'is not a valid email address' }).trim().toLowerCase()
275
+ }
276
+
277
+ trim(): this {
278
+ Object.assign(this.schema, { transform: { ...this.schema.transform, trim: true } })
279
+ return this
280
+ }
281
+
282
+ toLowerCase(): this {
283
+ Object.assign(this.schema, { transform: { ...this.schema.transform, toLowerCase: true } })
284
+ return this
285
+ }
286
+
287
+ toUpperCase(): this {
288
+ Object.assign(this.schema, { transform: { ...this.schema.transform, toUpperCase: true } })
289
+ return this
290
+ }
291
+
292
+ truncate(toLength: number): this {
293
+ Object.assign(this.schema, { transform: { ...this.schema.transform, truncate: toLength } })
294
+ return this
295
+ }
296
+
297
+ branded<B extends string>(): JsonSchemaStringBuilder<B, B, Opt> {
298
+ return this as unknown as JsonSchemaStringBuilder<B, B, Opt>
299
+ }
300
+
301
+ isoDate(): JsonSchemaStringBuilder<IsoDate | IN, IsoDate, Opt> {
302
+ Object.assign(this.schema, { IsoDate: true })
303
+ return this.branded<IsoDate>()
304
+ }
305
+
306
+ isoDateTime(): JsonSchemaStringBuilder<IsoDateTime | IN, IsoDateTime, Opt> {
307
+ Object.assign(this.schema, { IsoDateTime: true })
308
+ return this.branded<IsoDateTime>()
309
+ }
310
+
311
+ /**
312
+ * Validates the string format to be JWT.
313
+ * Expects the JWT to be signed!
314
+ */
315
+ jwt(): this {
316
+ return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' })
317
+ }
318
+
319
+ url(): this {
320
+ // from `ajv-formats`
321
+ const regex =
322
+ /^(?:https?|ftp):\/\/(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u{00A1}-\u{FFFF}]+-)*[a-z0-9\u{00A1}-\u{FFFF}]+)(?:\.(?:[a-z0-9\u{00A1}-\u{FFFF}]+-)*[a-z0-9\u{00A1}-\u{FFFF}]+)*(?:\.(?:[a-z\u{00A1}-\u{FFFF}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/iu
323
+ return this.regex(regex, { msg: 'is not a valid URL format' })
324
+ }
325
+
326
+ ipv4(): this {
327
+ // from `ajv-formats`
328
+ const regex =
329
+ /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/
330
+ return this.regex(regex, { msg: 'is not a valid IPv4 format' })
331
+ }
332
+
333
+ ipv6(): this {
334
+ // from `ajv-formats`
335
+ const regex =
336
+ /^((([0-9a-f]{1,4}:){7}([0-9a-f]{1,4}|:))|(([0-9a-f]{1,4}:){6}(:[0-9a-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){5}(((:[0-9a-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){4}(((:[0-9a-f]{1,4}){1,3})|((:[0-9a-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){3}(((:[0-9a-f]{1,4}){1,4})|((:[0-9a-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){2}(((:[0-9a-f]{1,4}){1,5})|((:[0-9a-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){1}(((:[0-9a-f]{1,4}){1,6})|((:[0-9a-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-f]{1,4}){1,7})|((:[0-9a-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/i
337
+ return this.regex(regex, { msg: 'is not a valid IPv6 format' })
338
+ }
339
+
340
+ id(): this {
341
+ const regex = /^[a-z0-9_]{6,64}$/
342
+ return this.regex(regex, { msg: 'is not a valid ID format' })
343
+ }
344
+
345
+ slug(): this {
346
+ const regex = /^[a-z0-9-]+$/
347
+ return this.regex(regex, { msg: 'is not a valid slug format' })
348
+ }
349
+
350
+ semVer(): this {
351
+ const regex = /^[0-9]+\.[0-9]+\.[0-9]+$/
352
+ return this.regex(regex, { msg: 'is not a valid semver format' })
353
+ }
354
+
355
+ languageTag(): this {
356
+ // IETF language tag (https://en.wikipedia.org/wiki/IETF_language_tag)
357
+ const regex = /^[a-z]{2}(-[A-Z]{2})?$/
358
+ return this.regex(regex, { msg: 'is not a valid language format' })
359
+ }
360
+
361
+ countryCode(): this {
362
+ const regex = /^[A-Z]{2}$/
363
+ return this.regex(regex, { msg: 'is not a valid country code format' })
364
+ }
365
+
366
+ currency(): this {
367
+ const regex = /^[A-Z]{3}$/
368
+ return this.regex(regex, { msg: 'is not a valid currency format' })
369
+ }
370
+ }
371
+
372
+ export interface JsonSchemaStringEmailOptions {
373
+ checkTLD: boolean
374
+ }
375
+
376
+ export class JsonSchemaNumberBuilder<
377
+ IN extends number = number,
378
+ OUT = IN,
379
+ Opt extends boolean = false,
380
+ > extends JsonSchemaAnyBuilder<IN, OUT, Opt> {
381
+ constructor() {
382
+ super({
383
+ type: 'number',
384
+ })
385
+ }
386
+
387
+ integer(): this {
388
+ Object.assign(this.schema, { type: 'integer' })
389
+ return this
390
+ }
391
+
392
+ branded<B extends number>(): JsonSchemaNumberBuilder<B, B, Opt> {
393
+ return this as unknown as JsonSchemaNumberBuilder<B, B, Opt>
394
+ }
395
+
396
+ multipleOf(multipleOf: number): this {
397
+ Object.assign(this.schema, { multipleOf })
398
+ return this
399
+ }
400
+
401
+ min(minimum: number): this {
402
+ Object.assign(this.schema, { minimum })
403
+ return this
404
+ }
405
+
406
+ exclusiveMin(exclusiveMinimum: number): this {
407
+ Object.assign(this.schema, { exclusiveMinimum })
408
+ return this
409
+ }
410
+
411
+ max(maximum: number): this {
412
+ Object.assign(this.schema, { maximum })
413
+ return this
414
+ }
415
+
416
+ exclusiveMax(exclusiveMaximum: number): this {
417
+ Object.assign(this.schema, { exclusiveMaximum })
418
+ return this
419
+ }
420
+
421
+ /**
422
+ * Both ranges are inclusive.
423
+ */
424
+ range(minimum: number, maximum: number): this {
425
+ Object.assign(this.schema, { minimum, maximum })
426
+ return this
427
+ }
428
+
429
+ int32(): this {
430
+ const MIN_INT32 = -(2 ** 31)
431
+ const MAX_INT32 = 2 ** 31 - 1
432
+ const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
433
+ const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
434
+ const newMin = Math.max(MIN_INT32, currentMin)
435
+ const newMax = Math.min(MAX_INT32, currentMax)
436
+ return this.integer().min(newMin).max(newMax)
437
+ }
438
+
439
+ int64(): this {
440
+ const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
441
+ const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
442
+ const newMin = Math.max(Number.MIN_SAFE_INTEGER, currentMin)
443
+ const newMax = Math.min(Number.MAX_SAFE_INTEGER, currentMax)
444
+ return this.integer().min(newMin).max(newMax)
445
+ }
446
+
447
+ float(): this {
448
+ return this
449
+ }
450
+
451
+ double(): this {
452
+ return this
453
+ }
454
+
455
+ unixTimestamp(): JsonSchemaNumberBuilder<UnixTimestamp, UnixTimestamp, Opt> {
456
+ return this.integer().min(0).max(TS_2500).branded<UnixTimestamp>()
457
+ }
458
+
459
+ unixTimestamp2000(): JsonSchemaNumberBuilder<UnixTimestamp, UnixTimestamp, Opt> {
460
+ return this.integer().min(TS_2000).max(TS_2500).branded<UnixTimestamp>()
461
+ }
462
+
463
+ unixTimestampMillis(): JsonSchemaNumberBuilder<UnixTimestampMillis, UnixTimestampMillis, Opt> {
464
+ return this.integer().min(0).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
465
+ }
466
+
467
+ unixTimestamp2000Millis(): JsonSchemaNumberBuilder<
468
+ UnixTimestampMillis,
469
+ UnixTimestampMillis,
470
+ Opt
471
+ > {
472
+ return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
473
+ }
474
+
475
+ utcOffset(): this {
476
+ return this.integer()
477
+ .multipleOf(15)
478
+ .min(-12 * 60)
479
+ .max(14 * 60)
480
+ }
481
+
482
+ utcOffsetHour(): this {
483
+ return this.integer().min(-12).max(14)
484
+ }
485
+ }
486
+
487
+ export class JsonSchemaBooleanBuilder<
488
+ IN extends boolean = boolean,
489
+ OUT = IN,
490
+ Opt extends boolean = false,
491
+ > extends JsonSchemaAnyBuilder<IN, OUT, Opt> {
492
+ constructor() {
493
+ super({
494
+ type: 'boolean',
495
+ })
496
+ }
497
+ }
498
+
499
+ export class JsonSchemaObjectBuilder<
500
+ PROPS extends Record<string, JsonSchemaAnyBuilder<any, any, any>>,
501
+ Opt extends boolean = false,
502
+ > extends JsonSchemaAnyBuilder<
503
+ Expand<
504
+ {
505
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
506
+ ? IsOpt extends true
507
+ ? never
508
+ : K
509
+ : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer IN, any, any> ? IN : never
510
+ } & {
511
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
512
+ ? IsOpt extends true
513
+ ? K
514
+ : never
515
+ : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer IN, any, any> ? IN : never
516
+ }
517
+ >,
518
+ Expand<
519
+ {
520
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
521
+ ? IsOpt extends true
522
+ ? never
523
+ : K
524
+ : never]: PROPS[K] extends JsonSchemaAnyBuilder<any, infer OUT, any> ? OUT : never
525
+ } & {
526
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
527
+ ? IsOpt extends true
528
+ ? K
529
+ : never
530
+ : never]?: PROPS[K] extends JsonSchemaAnyBuilder<any, infer OUT, any> ? OUT : never
531
+ }
532
+ >,
533
+ Opt
534
+ > {
535
+ constructor(props?: PROPS) {
536
+ super({
537
+ type: 'object',
538
+ properties: {},
539
+ required: [],
540
+ additionalProperties: false,
541
+ })
542
+
543
+ if (props) this.addProperties(props)
544
+ }
545
+
546
+ addProperties(props: PROPS): this {
547
+ const properties: Record<string, JsonSchema> = {}
548
+ const required: string[] = []
549
+
550
+ for (const [key, builder] of Object.entries(props)) {
551
+ const schema = builder.build()
552
+ if (!schema.optionalField) {
553
+ required.push(key)
554
+ } else {
555
+ schema.optionalField = undefined
556
+ }
557
+ properties[key] = schema
558
+ }
559
+
560
+ this.schema.properties = properties
561
+ this.schema.required = _uniq(required).sort()
562
+
563
+ return this
564
+ }
565
+
566
+ /**
567
+ * When set, the validation will not strip away properties that are not specified explicitly in the schema.
568
+ */
569
+ allowAdditionalProperties(): this {
570
+ Object.assign(this.schema, { additionalProperties: true })
571
+ return this
572
+ }
573
+
574
+ extend<NEW_PROPS extends Record<string, JsonSchemaAnyBuilder<any, any, any>>>(
575
+ props: NEW_PROPS,
576
+ ): JsonSchemaObjectBuilder<
577
+ {
578
+ [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
579
+ ? NEW_PROPS[K]
580
+ : K extends keyof PROPS
581
+ ? PROPS[K]
582
+ : never
583
+ },
584
+ Opt
585
+ > {
586
+ const newBuilder = new JsonSchemaObjectBuilder<PROPS, Opt>()
587
+ Object.assign(newBuilder.schema, _deepCopy(this.schema))
588
+
589
+ const incomingSchemaBuilder = new JsonSchemaObjectBuilder<NEW_PROPS, false>(props)
590
+ mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
591
+
592
+ return newBuilder as JsonSchemaObjectBuilder<
593
+ {
594
+ [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
595
+ ? NEW_PROPS[K]
596
+ : K extends keyof PROPS
597
+ ? PROPS[K]
598
+ : never
599
+ },
600
+ Opt
601
+ >
602
+ }
603
+
604
+ /**
605
+ * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
606
+ */
607
+ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
608
+ dbEntity() {
609
+ return this.extend({
610
+ id: j.string(),
611
+ created: j.number().unixTimestamp2000(),
612
+ updated: j.number().unixTimestamp2000(),
613
+ })
614
+ }
615
+ }
616
+
617
+ export class JsonSchemaArrayBuilder<IN, OUT, Opt> extends JsonSchemaAnyBuilder<IN[], OUT[], Opt> {
618
+ constructor(itemsSchema: JsonSchemaAnyBuilder<IN, OUT, Opt>) {
619
+ super({
620
+ type: 'array',
621
+ items: itemsSchema.build(),
622
+ })
623
+ }
624
+
625
+ min(minItems: number): this {
626
+ Object.assign(this.schema, { minItems })
627
+ return this
628
+ }
629
+
630
+ max(maxItems: number): this {
631
+ Object.assign(this.schema, { maxItems })
632
+ return this
633
+ }
634
+
635
+ unique(uniqueItems: number): this {
636
+ Object.assign(this.schema, { uniqueItems })
637
+ return this
638
+ }
639
+ }
640
+
641
+ export class JsonSchemaSet2Builder<IN, OUT, Opt> extends JsonSchemaAnyBuilder<
642
+ Iterable<IN>,
643
+ Set2<OUT>,
644
+ Opt
645
+ > {
646
+ constructor(itemsSchema: JsonSchemaAnyBuilder<IN, OUT, Opt>) {
647
+ super({
648
+ type: ['array', 'object'],
649
+ Set2: itemsSchema.build(),
650
+ })
651
+ }
652
+
653
+ min(minItems: number): this {
654
+ Object.assign(this.schema, { minItems })
655
+ return this
656
+ }
657
+
658
+ max(maxItems: number): this {
659
+ Object.assign(this.schema, { maxItems })
660
+ return this
661
+ }
662
+ }
663
+
664
+ export class JsonSchemaBufferBuilder extends JsonSchemaAnyBuilder<
665
+ string | any[] | ArrayBuffer | Buffer,
666
+ Buffer,
667
+ false
668
+ > {
669
+ constructor() {
670
+ super({
671
+ Buffer: true,
672
+ })
673
+ }
674
+ }
675
+
676
+ export class JsonSchemaEnumBuilder<
677
+ IN extends string | number | boolean | null,
678
+ OUT extends IN = IN,
679
+ Opt extends boolean = false,
680
+ > extends JsonSchemaAnyBuilder<IN, OUT, Opt> {
681
+ constructor(enumValues: readonly IN[]) {
682
+ super({ enum: enumValues })
683
+ }
684
+ }
685
+
686
+ export interface JsonSchema<IN = unknown, OUT = IN> {
687
+ readonly in?: IN
688
+ readonly out?: OUT
689
+
690
+ $schema?: AnyObject
691
+ $id?: string
692
+ title?: string
693
+ description?: string
694
+ // $comment?: string
695
+ deprecated?: boolean
696
+ readOnly?: boolean
697
+ writeOnly?: boolean
698
+
699
+ type?: string | string[]
700
+ items?: JsonSchema
701
+ properties?: {
702
+ [K in keyof IN & keyof OUT]: JsonSchema<IN[K], OUT[K]>
703
+ }
704
+ required?: string[]
705
+ additionalProperties?: boolean
706
+ minProperties?: number
707
+ maxProperties?: number
708
+
709
+ default?: IN
710
+
711
+ // https://json-schema.org/understanding-json-schema/reference/conditionals.html#id6
712
+ if?: JsonSchema
713
+ then?: JsonSchema
714
+ else?: JsonSchema
715
+
716
+ anyOf?: JsonSchema[]
717
+ oneOf?: JsonSchema[]
718
+
719
+ /**
720
+ * This is a temporary "intermediate AST" field that is used inside the parser.
721
+ * In the final schema this field will NOT be present.
722
+ */
723
+ optionalField?: true
724
+
725
+ pattern?: string
726
+ minLength?: number
727
+ maxLength?: number
728
+ format?: string
729
+
730
+ contentMediaType?: string
731
+ contentEncoding?: string // e.g 'base64'
732
+
733
+ multipleOf?: number
734
+ minimum?: number
735
+ exclusiveMinimum?: number
736
+ maximum?: number
737
+ exclusiveMaximum?: number
738
+
739
+ enum?: any
740
+
741
+ // Below we add custom Ajv keywords
742
+
743
+ Set2?: JsonSchema
744
+ Buffer?: true
745
+ IsoDate?: true
746
+ IsoDateTime?: true
747
+ instanceof?: string | string[]
748
+ transform?: { trim?: true; toLowerCase?: true; toUpperCase?: true; truncate?: number }
749
+ errorMessages?: StringMap<string>
750
+ hasIsOfTypeCheck?: boolean
751
+ }
752
+
753
+ type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never
754
+
755
+ type ExactMatch<A, B> =
756
+ (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false
757
+
758
+ type BuilderOutUnion<B extends readonly JsonSchemaAnyBuilder<any, any, any>[]> = {
759
+ [K in keyof B]: B[K] extends JsonSchemaAnyBuilder<any, infer O, any> ? O : never
760
+ }[number]
761
+
762
+ type BuilderInUnion<B extends readonly JsonSchemaAnyBuilder<any, any, any>[]> = {
763
+ [K in keyof B]: B[K] extends JsonSchemaAnyBuilder<infer I, any, any> ? I : never
764
+ }[number]
765
+
766
+ interface JsonBuilderRuleOpt {
767
+ msg?: string
768
+ }