@bscotch/schema-builder
Version:
Tools for creating and managing JSON Schemas.
261 lines • 8.46 kB
JavaScript
/**
* @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