Skip to main content

Schema Usage

Trivial applications are trivially configured; they are not the focus of this library.

Complex applications that are built up from many internal components are much more complicated to configure, because configuration is a cross-cutting concern that can induce leaky abstractions:

  • either applications need to know a myriad of internal details of their components (implying that the components should cleanly expose those details, thus making them part of their API surface)
  • or else the components need to know details of their application execution environment (leading to inappropriate inverse coupling and limiting reuse).

This library enables you to formalize your configuration data using composable schemas. Your application components should be able to trust that the configuration data it receives has already been properly transformed and validated.

To enable Configurator to manage configuration, you will use a Schema to define your configuration data model. The Schema library is a separate Version Zero project, loaded from @versionzero/schema. This section will focus primarily on how Schema is used within Configurator. See the dedicated Schema section for details on its many capabilities.

To avoid requiring "framework-like" dependencies throughout your application components, the Configurator library allows you to decide how deeply to integrate. You could decide to define the entire Schema hierarchy at the application tier. Alternatively, you could have your components each expose their own schemas, either as actual Schema instances, or just static "schema-shaped" data to avoid introducing dependencies. You could even define your component schemas in external sidecar files: it is possible to build fully declarative schemas that serialize to simple object representations.

For the purposes of documentation, the example schemas will almost always be "monolithic" for simplicity, but in most real applications, they should be built by aggregating individual configuration schemas for each application component.

Defining Schemas

The Schema class lets you define your configuration data model. It can be simple:

import { Configurator, Schema } from '@versionzero/configurator';

const schema = new Schema('object')
.property('debug', new Schema('boolean')
.meta('description', 'enable debugging')
)

const configuration = await new Configurator({schema}).configure();

console.log(configuration);
% node schema1.js --help

Usage: command [options]
--config (-C) [path|-] - load configuration from file (or - for stdin)
--debug (-d) [true|false] - enable debugging
--help (-h) [advanced] - display help information
% node schema1.js -d

{ debug: true }

or more complex:

import { Configurator, Schema } from '@versionzero/configurator';

// server.js...
class BasicServer {
// ...
static schema = new Schema('object')
.property('token', new Schema('string')
.required()
.validator('$base64')
.meta('description', 'auth token')
.meta('secret')
.serializer('****')
)
.property('url', new Schema('string')
.default('http://127.0.0.1:3000')
.meta('flagHint', 'S')
.meta('valueDescription', 'url')
.meta('description', 'address (must be https in prod)')
.validator('$url')
.validator({$or: [
{$eq: [{$reference: '/prod'}, false]},
{$matches: /https:.+/}
]}
)
)
}

// app.js...

const Server = BasicServer; // e.g. dependency injected, or even driven from schema rules!

const appSchema = new Schema('object')
.property('version', Schema.literal('1.0.1').meta('internal').validator('$semver'))
.property('prod', new Schema('boolean')
.meta('description', 'enforce production rules')
.default(process.env['NODE_ENV'] === 'production')
)
.property('server', Server.schema)


const configuration = await new Configurator({schema:appSchema}).configure();

console.log(configuration);

% node schema2.js --help

Usage: command [options]
--config (-C) [path|-] - load configuration from file (or - for stdin)
--help (-h) [advanced] - display help information
--prod (-p) [true|false] - enforce production rules (default:false)
--server-token (--st) <base64> - auth token (required)
--server-url (-S) url - address (must be https in prod)
(default:http://127.0.0.1:3000)
% node schema2.js -p --server-url https://prod.example.com --st bGVtbWUgaW4h

{
server: { token: 'bGVtbWUgaW4h', url: 'https://prod.example.com/' },
prod: true,
version: '1.0.1'
}

The Version Zero Schema was tailored for the task of enabling user-friendly application configuration. To wit,

  • The schema hierarchy defines a corresponding configuration model. This configuration model directly mirrors an "ideal" configuration file format for the application, ensuring that you can "round-trip" the serialized model to disk and load it back as an input config file.
  • Customizable properties need reasonable mappings from sources like command line options and environment variables. For this reason, all configurable data is assumed to potentially start out as a string.
  • To support multiple independent prioritized sources of configuration assignments, schema conditionals and lazy evaluation are necessary to avoid unnecessary processing related to overridden assignments.
  • Value normalization, transformation, and validation all support asynchronous processing in order to offload common configuration needs away from application logic.

Schema 101

The Schema class provides a chainable fluent API that acts as a builder. Internally, Configurator will use an instance of a SchemaResolver to compile the Schema into its runtime form, a CompiledSchema.

The SchemaResolver provides a registry that allow schemas to be stored and retrieved by name. A small set of prebuilt "types" are provided: string, number, boolean, object, array, and more. These are used by specifying the name as the "base" in the constructor, e.g. new Schema('string'). You can also register your own schemas, or load entire libraries of prebuilt schemas.

Schemas define processing and validation rules. The simplest schema - new Schema() simply passes its input to its output; it isn't useful, except as a building block. More interesting behaviors are created by composing value processing pipelines in the handlers that correspond to different phases.

Value processors are functions that take an input value and either return an output value, throw an error, return undefined to signal the value cannot be yet returned, or null to signal that the value should be explicitly omitted from the output. Handlers like normalizer, transformer, or validator

SchemaResolver also allows value processors to be registered and retrieved by name.

import { Configurator, Schema, SchemaResolver } from '@versionzero/configurator';

function isPowerOfTwo(n) {
if (n > 0 && (n & (n - 1)) === 0) {
return n;
}
throw new Error('not a power of two!')
}

const resolver = new SchemaResolver();
resolver.registerValueProcessor('is-power-of-2', isPowerOfTwo);

const storageSizeSchema = new Schema()
.meta('description', 'storage size')
.meta('valueDescription', 'bytes')
.normalizer('$data-size')
.validator({$range: {min: 0}})
.validator('$is-power-of-2')

resolver.registerSchema('storageSize', storageSizeSchema);

const schema = new Schema('object')
.property('storageSize', new Schema('storage-size').required())

const configurator = new Configurator({schema, resolver});

// fake command line args by passing argv into the context
console.log( await configurator.configure({ argv: [ '--storage-size=128' ]}) );
// -> { storageSize: 128 }

console.log( await configurator.configure({ argv: [ '--storage-size', '1 MiB' ]}) );
// -> { storageSize: 1048576 }

console.log( await configurator.configure({ argv: [ '--storage-size=127' ]}) );
// throws -> ValidationError: Validation failed with value «127» at "storageSize"

Learn More

See the separate Schema documentation on this site for details on its many capabilities.