@naturalcycles/js-lib
Version:
Standard library for universal (browser + Node.js) javascript
460 lines (399 loc) • 12 kB
text/typescript
import { _uniq } from '../array/array.util.js'
import { _deepCopy } from '../object/object.util.js'
import { _sortObject } from '../object/sortObject.js'
import type { AnyObject, BaseDBEntity, IsoDate, UnixTimestamp } from '../types.js'
import { JSON_SCHEMA_ORDER } from './jsonSchema.cnst.js'
import type {
JsonSchema,
JsonSchemaAllOf,
JsonSchemaAny,
JsonSchemaArray,
JsonSchemaBoolean,
JsonSchemaConst,
JsonSchemaEnum,
JsonSchemaNull,
JsonSchemaNumber,
JsonSchemaObject,
JsonSchemaOneOf,
JsonSchemaRef,
JsonSchemaString,
JsonSchemaTuple,
} from './jsonSchema.model.js'
import { mergeJsonSchemaObjects } from './jsonSchema.util.js'
/* eslint-disable id-blacklist, @typescript-eslint/explicit-module-boundary-types */
export interface JsonSchemaBuilder<T = unknown> {
build: () => JsonSchema<T>
}
/**
* Fluent (chainable) API to manually create Json Schemas.
* Inspired by Joi and Zod.
*/
export const j = {
any<T = unknown>() {
return new JsonSchemaAnyBuilder<T, JsonSchemaAny<T>>({})
},
const<T = unknown>(value: T) {
return new JsonSchemaAnyBuilder<T, JsonSchemaConst<T>>({
const: value,
})
},
null() {
return new JsonSchemaAnyBuilder<null, JsonSchemaNull>({
type: 'null',
})
},
ref<T = unknown>($ref: string) {
return new JsonSchemaAnyBuilder<T, JsonSchemaRef<T>>({
$ref,
})
},
enum<T = unknown>(enumValues: T[]) {
return new JsonSchemaAnyBuilder<T, JsonSchemaEnum<T>>({ enum: enumValues })
},
boolean() {
return new JsonSchemaAnyBuilder<boolean, JsonSchemaBoolean>({
type: 'boolean',
})
},
buffer() {
return new JsonSchemaAnyBuilder<Buffer, JsonSchemaAny<Buffer>>({
instanceof: 'Buffer',
})
},
// number types
number<T extends number = number>() {
return new JsonSchemaNumberBuilder<T>()
},
integer<T extends number = number>() {
return new JsonSchemaNumberBuilder<T>().integer()
},
unixTimestamp() {
return new JsonSchemaNumberBuilder<UnixTimestamp>().unixTimestamp()
},
unixTimestamp2000() {
return new JsonSchemaNumberBuilder<UnixTimestamp>().unixTimestamp2000()
},
// string types
string<T extends string = string>() {
return new JsonSchemaStringBuilder<T>()
},
isoDate() {
return new JsonSchemaStringBuilder<IsoDate>().isoDate()
},
// email: () => new JsonSchemaStringBuilder().email(),
// complex types
object<T extends AnyObject>(props: {
[K in keyof T]: JsonSchemaAnyBuilder<T[K]>
}) {
return new JsonSchemaObjectBuilder<T>().addProperties(props)
},
rootObject<T extends AnyObject>(props: {
[K in keyof T]: JsonSchemaAnyBuilder<T[K]>
}) {
return new JsonSchemaObjectBuilder<T>().addProperties(props).$schemaDraft7()
},
array<ITEM = unknown>(itemSchema: JsonSchemaAnyBuilder<ITEM>) {
return new JsonSchemaArrayBuilder<ITEM>(itemSchema)
},
tuple<T extends any[] = unknown[]>(items: JsonSchemaAnyBuilder[]) {
return new JsonSchemaTupleBuilder<T>(items)
},
oneOf<T = unknown>(items: JsonSchemaAnyBuilder[]) {
return new JsonSchemaAnyBuilder<T, JsonSchemaOneOf<T>>({
oneOf: items.map(b => b.build()),
})
},
allOf<T = unknown>(items: JsonSchemaAnyBuilder[]) {
return new JsonSchemaAnyBuilder<T, JsonSchemaAllOf<T>>({
allOf: items.map(b => b.build()),
})
},
}
export class JsonSchemaAnyBuilder<T = unknown, SCHEMA_TYPE extends JsonSchema<T> = JsonSchema<T>>
implements JsonSchemaBuilder<T>
{
constructor(protected schema: SCHEMA_TYPE) {}
/**
* Used in ObjectBuilder to access schema.optionalProperty
*/
getSchema(): SCHEMA_TYPE {
return this.schema
}
$schema($schema: string): this {
Object.assign(this.schema, { $schema })
return this
}
$schemaDraft7(): this {
this.$schema('http://json-schema.org/draft-07/schema#')
return this
}
$id($id: string): this {
Object.assign(this.schema, { $id })
return this
}
title(title: string): this {
Object.assign(this.schema, { title })
return this
}
description(description: string): this {
Object.assign(this.schema, { description })
return this
}
deprecated(deprecated = true): this {
Object.assign(this.schema, { deprecated })
return this
}
type(type: string): this {
Object.assign(this.schema, { type })
return this
}
default(v: any): this {
Object.assign(this.schema, { default: v })
return this
}
oneOf(schemas: JsonSchema[]): this {
Object.assign(this.schema, { oneOf: schemas })
return this
}
allOf(schemas: JsonSchema[]): this {
Object.assign(this.schema, { allOf: schemas })
return this
}
instanceof(of: string): this {
this.schema.instanceof = of
return this
}
optional(optional = true): this {
if (optional) {
this.schema.optionalField = true
} else {
this.schema.optionalField = undefined
}
return this
}
/**
* Produces a "clean schema object" without methods.
* Same as if it would be JSON.stringified.
*/
build(): SCHEMA_TYPE {
return _sortObject(JSON.parse(JSON.stringify(this.schema)), JSON_SCHEMA_ORDER)
}
clone(): JsonSchemaAnyBuilder<T, SCHEMA_TYPE> {
return new JsonSchemaAnyBuilder<T, SCHEMA_TYPE>(_deepCopy(this.schema))
}
/**
* @experimental
*/
infer!: T
}
export class JsonSchemaNumberBuilder<T extends number = number> extends JsonSchemaAnyBuilder<
T,
JsonSchemaNumber<T>
> {
constructor() {
super({
type: 'number',
})
}
integer(): this {
Object.assign(this.schema, { type: 'integer' })
return this
}
multipleOf(multipleOf: number): this {
Object.assign(this.schema, { multipleOf })
return this
}
min(minimum: number): this {
Object.assign(this.schema, { minimum })
return this
}
exclusiveMin(exclusiveMinimum: number): this {
Object.assign(this.schema, { exclusiveMinimum })
return this
}
max(maximum: number): this {
Object.assign(this.schema, { maximum })
return this
}
exclusiveMax(exclusiveMaximum: number): this {
Object.assign(this.schema, { exclusiveMaximum })
return this
}
/**
* Both ranges are inclusive.
*/
range(minimum: number, maximum: number): this {
Object.assign(this.schema, { minimum, maximum })
return this
}
format(format: string): this {
Object.assign(this.schema, { format })
return this
}
int32 = (): this => this.format('int32')
int64 = (): this => this.format('int64')
float = (): this => this.format('float')
double = (): this => this.format('double')
unixTimestamp = (): this => this.format('unixTimestamp').description('UnixTimestamp')
unixTimestamp2000 = (): this => this.format('unixTimestamp2000').description('UnixTimestamp2000')
unixTimestampMillis = (): this =>
this.format('unixTimestampMillis').description('UnixTimestampMillis')
unixTimestampMillis2000 = (): this =>
this.format('unixTimestampMillis2000').description('UnixTimestampMillis2000')
utcOffset = (): this => this.format('utcOffset')
utcOffsetHours = (): this => this.format('utcOffsetHours')
}
export class JsonSchemaStringBuilder<T extends string = string> extends JsonSchemaAnyBuilder<
T,
JsonSchemaString<T>
> {
constructor() {
super({
type: 'string',
})
}
pattern(pattern: string): this {
Object.assign(this.schema, { pattern })
return this
}
min(minLength: number): this {
Object.assign(this.schema, { minLength })
return this
}
max(maxLength: number): this {
Object.assign(this.schema, { maxLength })
return this
}
length(minLength: number, maxLength: number): this {
Object.assign(this.schema, { minLength, maxLength })
return this
}
format(format: string): this {
Object.assign(this.schema, { format })
return this
}
email = (): this => this.format('email')
isoDate = (): this => this.format('date').description('IsoDate') // todo: make it custom isoDate instead
url = (): this => this.format('url')
ipv4 = (): this => this.format('ipv4')
ipv6 = (): this => this.format('ipv6')
password = (): this => this.format('password')
id = (): this => this.format('id')
slug = (): this => this.format('slug')
semVer = (): this => this.format('semVer')
languageTag = (): this => this.format('languageTag')
countryCode = (): this => this.format('countryCode')
currency = (): this => this.format('currency')
trim = (trim = true): this => this.transformModify('trim', trim)
toLowerCase = (toLowerCase = true): this => this.transformModify('toLowerCase', toLowerCase)
toUpperCase = (toUpperCase = true): this => this.transformModify('toUpperCase', toUpperCase)
private transformModify(t: 'trim' | 'toLowerCase' | 'toUpperCase', add: boolean): this {
if (add) {
this.schema.transform = _uniq([...(this.schema.transform || []), t])
} else {
this.schema.transform = this.schema.transform?.filter(s => s !== t)
}
return this
}
// contentMediaType?: string
// contentEncoding?: string
}
export class JsonSchemaObjectBuilder<T extends AnyObject> extends JsonSchemaAnyBuilder<
T,
JsonSchemaObject<T>
> {
constructor() {
super({
type: 'object',
properties: {} as T,
required: [],
additionalProperties: false,
})
}
addProperties(props: { [k in keyof T]: JsonSchemaBuilder<T[k]> }): this {
Object.entries(props).forEach(([k, builder]: [keyof T, JsonSchemaBuilder]) => {
const schema = builder.build()
if (!schema.optionalField) {
this.schema.required.push(k)
} else {
schema.optionalField = undefined
}
this.schema.properties[k] = schema
})
this.required(this.schema.required) // ensure it's sorted and _uniq
return this
}
/**
* Ensures `required` is always sorted and _uniq
*/
required(required: (keyof T)[]): this {
Object.assign(this.schema, { required })
this.schema.required = _uniq(required).sort()
return this
}
addRequired(required: (keyof T)[]): this {
return this.required([...this.schema.required, ...required])
}
minProps(minProperties: number): this {
Object.assign(this.schema, { minProperties })
return this
}
maxProps(maxProperties: number): this {
Object.assign(this.schema, { maxProperties })
return this
}
additionalProps(additionalProperties: boolean): this {
Object.assign(this.schema, { additionalProperties })
return this
}
baseDBEntity(): JsonSchemaObjectBuilder<T & BaseDBEntity> {
Object.assign(this.schema.properties, {
id: { type: 'string' },
created: { type: 'number', format: 'unixTimestamp2000' },
updated: { type: 'number', format: 'unixTimestamp2000' },
})
return this.addRequired(['id', 'created', 'updated']) as any
}
extend<T2 extends AnyObject>(s2: JsonSchemaObjectBuilder<T2>): JsonSchemaObjectBuilder<T & T2> {
const builder = new JsonSchemaObjectBuilder<T & T2>()
Object.assign(builder.schema, _deepCopy(this.schema))
mergeJsonSchemaObjects(builder.schema, s2.schema)
return builder
}
}
export class JsonSchemaArrayBuilder<ITEM> extends JsonSchemaAnyBuilder<
ITEM[],
JsonSchemaArray<ITEM>
> {
constructor(itemsSchema: JsonSchemaBuilder<ITEM>) {
super({
type: 'array',
items: itemsSchema.build(),
})
}
min(minItems: number): this {
Object.assign(this.schema, { minItems })
return this
}
max(maxItems: number): this {
Object.assign(this.schema, { maxItems })
return this
}
unique(uniqueItems: number): this {
Object.assign(this.schema, { uniqueItems })
return this
}
}
export class JsonSchemaTupleBuilder<T extends any[]> extends JsonSchemaAnyBuilder<
T,
JsonSchemaTuple<T>
> {
constructor(items: JsonSchemaBuilder[]) {
super({
type: 'array',
items: items.map(b => b.build()),
minItems: items.length,
maxItems: items.length,
})
}
}