* Copyright (c) 2025 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { zip } from "./util.js"
export interface JsonSchemaLeaf {
type?: string
description?: string
}
export interface JsonSchemaArray extends JsonSchemaLeaf {
items: JsonSchemaNode
}
export interface JsonSchemaTuple extends JsonSchemaLeaf {
items: JsonSchemaNode[]
}
export interface JsonSchemaUnion extends JsonSchemaLeaf {
oneOf: JsonSchemaNode[]
}
export interface JsonSchemaLiteral extends JsonSchemaLeaf {
'const': string | number | boolean | null
}
export interface JsonSchemaMap extends JsonSchemaLeaf {
additionalProperties: JsonSchemaNode
}
export interface JsonSchemaObject extends JsonSchemaLeaf {
properties: Record<string, JsonSchemaNode>
required: string[]
additionalProperties: boolean
}
export type JsonSchemaNode =
JsonSchemaLeaf
| JsonSchemaArray
| JsonSchemaTuple
| JsonSchemaMap
| JsonSchemaObject
| JsonSchemaUnion
| JsonSchemaLiteral
export interface JsonSchema {
$ref: string,
$schema: string
definitions: Record<string, JsonSchemaNode>
}
type UnwrapConfigDescriberLeaf<T> = T extends ConfigDescriberLeaf<infer K> ? K : never
type ObjectDescriptionToType<T extends {}> = { [x in keyof T]: UnwrapConfigDescriberLeaf<T[x]> }
type TraverseTuple<Ts extends any[]> = Ts extends []
? []
: Ts extends [infer Head, ...infer Rest]
? [UnwrapConfigDescriberLeaf<Head>, ...TraverseTuple<Rest>]
: never
type TraverseUnion<Ts extends any[]> = Ts extends []
? never
: Ts extends [infer Head, ...infer Rest]
? UnwrapConfigDescriberLeaf<Head> | TraverseUnion<Rest>
: never
export interface ValidationSuccess<T> {
success: true
value: T
}
export interface ValidationFailure {
success: false
errorMessage: string
}
export type ValidationResult<T> =
ValidationSuccess<T>
| ValidationFailure
class ValidationBox<T> {
constructor(
public box: ValidationResult<T>
) { }
static fail<T>(errorMessage: string): ValidationBox<T> {
return new ValidationBox({ success: false, errorMessage })
}
static ok<T>(value: T): ValidationBox<T> {
return new ValidationBox({ success: true, value })
}
success(): boolean {
return this.box.success
}
unwrap(message?:string): T {
if (this.box.success) {
return this.box.value
}
throw new Error(message ?? 'panic')
}
error(): string {
if (!this.box.success) {
return this.box.errorMessage
}
throw new Error("")
}
get(): ValidationResult<T> {
return this.box
}
or<U>(x: U): ValidationBox<U | T> {
if (this.box.success) {
return new ValidationBox(this.box)
}
return new ValidationBox({
success: true,
value: x
})
}
}
export interface ConfigDescriberBaseConfig {
mergeStrategy: 'replace' | 'merge'
}
class ConfigDescriberBase {
$: ConfigDescriberBaseConfig = {
mergeStrategy: 'merge'
}
onMerge(strategy:ConfigDescriberBaseConfig['mergeStrategy']): this {
this.$.mergeStrategy = strategy
return this
}
}
class ConfigDescriberLeaf<T> extends ConfigDescriberBase {
constructor(
public validate: (x: unknown) => ValidationBox<T>,
public printSchema: () => JsonSchemaNode,
) { super() }
}
class ConfigDescriberOptionalLeaf<T> extends ConfigDescriberLeaf<T> {
constructor(
validate: (x: unknown) => ValidationBox<T>,
printSchema: () => JsonSchemaNode,
) { super(validate, printSchema) }
}
class ConfigDescriberObjectLeaf<T> extends ConfigDescriberLeaf<T> {
constructor(
validate: (x: unknown) => ValidationBox<T>,
printSchema: () => JsonSchemaNode,
public schema: Record<string, ConfigDescriberLeaf<T>>,
) { super(validate, printSchema) }
}
export type ConfigSchema<T> = ConfigDescriberLeaf<T>
export interface ConfigDescriberFieldInfo {
mergeStrategy: ConfigDescriberBaseConfig['mergeStrategy']
}
export function inspectSchema(schema:ConfigDescriberLeaf<any>) {
return {
inspectPath(name:string): ConfigDescriberFieldInfo | undefined {
if (name === '') {
return {
mergeStrategy: schema.$.mergeStrategy
}
}
const path = name.split('.')
let leaf = schema
while (leaf instanceof ConfigDescriberObjectLeaf && path.length > 0) {
const key = path.shift()!
leaf = leaf.schema[key]
if (!leaf) {
return undefined
}
}
return { mergeStrategy: leaf.$.mergeStrategy }
}
}
}
export interface CommonBuilderConfig<T> {
default?: T
description?: string
}
function mk<T>(typeName:string, check: (x: unknown) => boolean, config?: CommonBuilderConfig<T>) {
return new ConfigDescriberLeaf<T>(
x => {
if (check(x)) {
return new ValidationBox({
success: true,
value: x as T
})
}
if (config !== undefined && 'default' in config) {
return new ValidationBox({
success: true,
value: config.default as T
})
}
return new ValidationBox({
success: false,
errorMessage: `Expected "${typeName}" but got "${typeof x}"`
})
},
() => {
const base: any = {
type: typeName,
}
if (config?.description) {
base.description = config?.description
}
return base
}
)
}
export const D = {
number(config?: CommonBuilderConfig<number>): ConfigDescriberLeaf<number> {
return mk('number', x => typeof x === 'number', config)
},
string(config?: CommonBuilderConfig<string>): ConfigDescriberLeaf<string> {
return mk('string', x => typeof x === 'string', config)
},
boolean(config?: CommonBuilderConfig<boolean>): ConfigDescriberLeaf<boolean> {
return mk('boolean', x => typeof x === 'boolean', config)
},
bigint(config?: CommonBuilderConfig<bigint>): ConfigDescriberLeaf<bigint> {
return mk('bigint', x => typeof x === 'bigint', config)
},
null(config?: CommonBuilderConfig<null>): ConfigDescriberLeaf<null> {
return mk('null', x => x === null, config)
},
undefined(config?: CommonBuilderConfig<undefined>): ConfigDescriberLeaf<undefined> {
return mk('undefined', x => x === undefined, config)
},
object<K extends Record<string, ConfigDescriberLeaf<any>>>(schema: K): ConfigDescriberObjectLeaf<ObjectDescriptionToType<K>> {
return new ConfigDescriberObjectLeaf(x => {
if (x !== undefined) {
if (typeof x !== 'object') {
return ValidationBox.fail(`Expected object, but got "${typeof x}"`)
}
if (x === null) {
return ValidationBox.fail(`Expected object, but got "null"`)
}
}
const obj = x as any
const sh = schema
const result = {} as any
const errors: string[] = []
for (const key in schema) {
const box = sh[key].validate(
obj === undefined ? undefined : obj[key]
)
if (box.success()) {
const val = box.unwrap()
if (val === undefined && obj !== undefined && !(key in obj)) {
continue
}
result[key] = val
} else {
errors.push(`"${key}":\n${box.error().split('\n').map(s => '\t' + s).join('\n')}`)
}
}
if (errors.length) {
return ValidationBox.fail(errors.join('\n'))
}
return new ValidationBox({
success: true,
value: result
})
},
() => {
const properties: Record<string, JsonSchemaNode> = {}
const required: string[] = []
for (const key in schema) {
const leaf = schema[key]
properties[key] = leaf.printSchema()
if (!(leaf instanceof ConfigDescriberOptionalLeaf)) {
required.push(key)
}
}
return {
additionalProperties: false,
properties,
required,
type: "object"
}
},
schema)
},
maybe<T>(type: ConfigDescriberLeaf<T>): ConfigDescriberOptionalLeaf<T | undefined> {
return new ConfigDescriberOptionalLeaf(x => {
if (x === undefined) {
return ValidationBox.ok(undefined)
}
return type.validate(x)
}, () => {
return type.printSchema()
})
},
default<T>(type: ConfigDescriberLeaf<T>, def: T): ConfigDescriberOptionalLeaf<T> {
return new ConfigDescriberOptionalLeaf(x => {
return type.validate(x).or(def)
}, () => {
return type.printSchema()
})
},
array<T>(type: ConfigDescriberLeaf<T>, initAsEmpty: boolean = true): ConfigDescriberLeaf<T[]> {
return new ConfigDescriberLeaf(xs => {
if ((xs === undefined || xs === null) && initAsEmpty) {
return ValidationBox.ok([])
}
if (!Array.isArray(xs)) {
return ValidationBox.fail("Expected array")
}
const result: T[] = []
for (const x of xs) {
const box = type.validate(x)
if (!box.success()) {
return ValidationBox.fail("Array item: " + box.error())
}
result.push(box.unwrap())
}
return ValidationBox.ok(result)
}, () => {
return {
type: 'array',
items: type.printSchema()
}
})
},
map<K, V>(keySchema: ConfigDescriberLeaf<K>, valSchema: ConfigDescriberLeaf<V>): ConfigDescriberLeaf<Map<K, V>> {
return new ConfigDescriberLeaf(x => {
if (x === undefined) {
return ValidationBox.fail(`Expected Map, but got "undefined"`)
}
if (typeof x !== 'object' || x === null) {
return ValidationBox.fail(`Expected Map, but got "${x === null ? 'null' : typeof x}"`)
}
const result = new Map()
const iterable = x instanceof Map ? x : Object.entries(x)
for (const [key, val] of iterable) {
const keyResult = keySchema.validate(key)
if (!keyResult.success()) {
return ValidationBox.fail("Map key: " + keyResult.error())
}
const valResult = valSchema.validate(val)
if (!valResult.success()) {
return ValidationBox.fail("Map value: " + valResult.error())
}
result.set(keyResult.unwrap(), valResult.unwrap())
}
return ValidationBox.ok(result)
}, () => {
return {
type: 'object',
additionalProperties: valSchema.printSchema()
}
})
},
tuple<Ts extends ConfigDescriberLeaf<any>[]>(...items:Ts): ConfigDescriberLeaf<TraverseTuple<Ts>> {
return new ConfigDescriberLeaf(xs => {
if (!Array.isArray(xs)) {
return ValidationBox.fail('Expected tuple')
}
if (xs.length !== items.length) {
return ValidationBox.fail(`Expected tuple of size ${items.length}, but size was ${xs.length}`)
}
const result: any[] = []
zip(xs, items).forEach(([val, leaf], i) => {
const r = leaf.validate(val)
if (!r.success()) {
return ValidationBox.fail(`Tuple position ${i}: ${r.error()}`)
}
result.push(r.unwrap())
})
return ValidationBox.ok(result as TraverseTuple<Ts>)
}, () => {
return {
items: items.map(it => it.printSchema())
}
})
},
union<Ts extends ConfigDescriberLeaf<any>[]>(...items:Ts): ConfigDescriberLeaf<TraverseUnion<Ts>> {
return new ConfigDescriberLeaf(xs => {
for (const item of items) {
const r = item.validate(xs)
if (r.success()) {
return r
}
}
return ValidationBox.fail('Not matched')
}, () => {
return {
oneOf: items.map(it => it.printSchema())
}
})
},
literal: {
string<T extends string>(x:T): ConfigDescriberLeaf<T> {
return new ConfigDescriberLeaf<T>(xs => {
if (typeof xs === 'string' && x === xs) {
return ValidationBox.ok(xs as T)
}
return ValidationBox.fail('Not matched')
}, () => {
return {
'const': x
}
})
},
number<T extends number>(x:T): ConfigDescriberLeaf<T> {
return new ConfigDescriberLeaf<T>(xs => {
if (typeof xs === 'number' && x === xs) {
return ValidationBox.ok(xs as T)
}
return ValidationBox.fail('Not matched')
}, () => {
return {
'const': x
}
})
},
boolean<T extends boolean>(x:T): ConfigDescriberLeaf<T> {
return new ConfigDescriberLeaf<T>(xs => {
if (typeof xs === 'boolean' && x === xs) {
return ValidationBox.ok(xs as T)
}
return ValidationBox.fail('Not matched')
}, () => {
return {
'const': x
}
})
},
null(): ConfigDescriberLeaf<null> {
return new ConfigDescriberLeaf<null>(xs => {
if (typeof xs === 'object' && xs === null) {
return ValidationBox.ok<null>(null)
}
return ValidationBox.fail('Expected null')
}, () => {
return {
'const': null
}
})
}
},
combine<A, B>(a: ConfigDescriberObjectLeaf<A>, b: ConfigDescriberObjectLeaf<B>): ConfigDescriberObjectLeaf<A & B> {
const keysA = new Set(Object.keys(a.schema))
const keysB = Object.keys(b.schema)
for (const key of keysB) {
if (keysA.has(key)) {
throw new Error(`Can not combine objects with same keys. Key: "${key}"`)
}
}
return D.object({
...a.schema,
...b.schema,
}) as ConfigDescriberObjectLeaf<A & B>
},
printJSONSchema<T>(schema: ConfigDescriberLeaf<T>): string {
const configSchema = schema.printSchema()
if ("properties" in configSchema) {
configSchema.properties.$schema = {
type: "string",
description: "The schema to verify this document against."
}
}
const json: JsonSchema = {
$schema: "http://json-schema.org/draft-07/schema#",
$ref: "#/definitions/configSchema",
definitions: {
configSchema
}
}
return JSON.stringify(json, null, 4)
}
}
export type ConfigTypeInfer<T> = UnwrapConfigDescriberLeaf<T>