UNPKG

@bscotch/schema-builder

Version:
261 lines 8.46 kB
/** * @file Extensions to TypeBox for convenience features * and specific use-cases. */ import { assert } from '@bscotch/utility'; import fs, { promises as fsPromises } from 'fs'; import { RefKind, TypeBuilder, UnionKind, } from './typebox'; import { createAjvInstance } from './validation.js'; export * from './typebox'; /** * A `SchemaBuilder` instance provides storage for schema * definitions (for use in `$ref`s) and for creating a Schema * that uses them, all with full Typescript support. * * The API is designed to enable * * (Powered by TypeBox) */ export class SchemaBuilder extends TypeBuilder { $defs = {}; _root = undefined; /** * AJV Validator cache */ _validator; validatorOptions; constructor(options) { super(); if (options?.lib) { this.addDefinitions(options.lib); } this.validatorOptions = options?.validatorOptions || {}; } /** * The root Schema, used for validation, if set by * {@link SchemaBuilder.setRoot}. */ get root() { return this._root; } /** * Get the validator cached from the last call * to {@link SchemaBuilder.compileValidator}. * * If no validator has been cached, one will be * automatically created and cached with default options. */ get validate() { return this._validator || this.compileValidator(); } /** * Create an AJV validator for the root schema, which will * include any defs on this SchemaBuilder and all of * the `ajv-formats`. Additional formats, keywords, * and other AJV options can be provided via the optional * `options` and `extensions` arguments. */ compileValidator(options) { this._validator = createAjvInstance(options || this.validatorOptions).compile(this.WithDefs()); return this._validator; } /** * Test data against the root schema, returning * an array of errors or `undefined` if the data is valid. */ hasErrors(data) { const isValid = this.isValid(data); return isValid ? undefined : this.validate.errors; } /** * Test data against the root schema, throwing an error * if the check fails. If the data is valid, it is returned. */ assertIsValid(data) { const errors = this.hasErrors(data); if (!errors) { return data; } console.error(errors.join('\n')); throw new Error('Data does not match schema'); } /** * Test data against the root schema, returning `true` * if it is valid, and `false` if it is not. */ isValid(data) { assert(this.root, 'No root schema defined'); return this.validate(data); } /** * Set one of this SchemaBuilder's definitions as the * "root" definition, which will cause a `$ref` schema * pointing to it to be used as the default output schema. */ setRoot(defName) { this._validator = undefined; this._root = this.DefRef(defName); return this; } /** * Create a `$ref` reference to a schema definition that * this `SchemaBuilder` knows about * (e.g. it was provided via `addDefinition`). * * If no `name` argument is provided, returns the root * schema (set by {@link setRoot}). */ DefRef(name) { if (typeof name === 'undefined') { assert(this.root, 'No root schema set'); return this.root; } this.tryFindDef(name); return { kind: RefKind, $ref: `#/$defs/${name}` }; } /** * Create an enum schema from an array of literals, * resulting in a union type of those values. */ LiteralUnion(items, options = {}) { return this.Store({ ...options, kind: UnionKind, enum: items }); } /** * Find a definition schema by name. If no such * definition found, returns `undefined`. * * To throw an error instead, use {@link tryFindDef}. */ findDef(name) { return this.$defs[name]; } /** * Find a definition schema by name. If no such * definition found, throw an error. * * To return `undefined` instead of throwing, use * {@link findDef}. */ tryFindDef(name) { const def = this.findDef(name); assert(def, `Definition '${name}' does not exist`); return def; } /** * Add a new schema to the stored definitions, for use * in local references for a final schema. */ addDefinition(name, schema) { this._validator = undefined; // @ts-expect-error The `[name]` index does not exist on `this.$defs` // until the newly-typed `this` is returned this.$defs[name] = typeof schema === 'function' ? schema.bind(this)() : schema; return this; } /** * Add a new schema to the stored definitions, for use * in local references for a final schema. */ addDefinitions(newDefs) { const lib = typeof newDefs === 'function' ? newDefs.bind(this)() : newDefs instanceof SchemaBuilder ? newDefs.$defs : newDefs; for (const [name, schema] of Object.entries(lib)) { this.addDefinition(name, schema); } return this; } /** * Like JavaScript's `Function.prototype.call()`, except that * `this` is already provided as this `SchemaBuilder` instance. * * This is useful when you want to reference a schema definition * created earlier in the chain that Typescript doesn't know about, * or when you want to create Schemas within the build-chain when * you don't have a variable name to reference. * * @example * * ```ts * const lib = new SchemaBuilder().addDefinition('aString', function () { * return this.String(); * }); * * const mySchema = new SchemaBuilder({lib}) * .use(function () { * return this.addDefinition('nums', this.LiteralUnion([1, 2, 3])) * .addDefinition('moreNums', this.Array(this.Number())) * .addDefinition('deeper', function () { * return this.Object({ * deepArray: this.Array(this.DefRef('nums')), * libRef: this.Array(this.DefRef('aString')), * }); * }); * }); * ``` */ use(func) { return func.bind(this)(); } /** * @alias SchemaBuilder.WithDefs * * Any external functions that attempt to call `toJSON` on * objects during serialization (such as `JSON.stringify`) * will end up with the serialized root schema, which will * include all definitions in a `$defs` field. */ toJSON() { return this.WithDefs(); } WithDefs(schema) { schema = schema || this.root; assert(schema, 'No root schema set'); return { ...schema, $defs: this.$defs, }; } /** * Write this SchemaBuilder's schemas to file as a valid * JSON Schema document, with definitions listed in a `$defs` * field alongside the root schema (if set). */ async writeSchema(outPath, schema) { await this._write(false, outPath, schema); return this; } /** * Synchronous version of {@link writeSchema}. */ writeSchemaSync(outPath, schema) { this._write(true, outPath, schema); return this; } /** * Load data from file, ensuring that it is valid according * to the root schema. */ async readData(path) { return this.assertIsValid(await this._readDataFile(false, path)); } /** * Synchronous version of {@link readData}. */ readDataSync(path) { return this.assertIsValid(this._readDataFile(true, path)); } _readDataFile(sync, path) { assert(this.root, 'Cannot read data file with validation unless root schema is defined.'); const reader = sync ? fs.readFileSync : fsPromises.readFile; const data = reader(path, 'utf8'); return typeof data == 'string' ? JSON.parse(data) : data.then(JSON.parse); } _write(sync, outPath, schema) { return (sync ? fs.writeFileSync : fsPromises.writeFile)(outPath, JSON.stringify(this.WithDefs(schema), null, 2)); } } //# sourceMappingURL=schemaBuilder.js.map