converter.js

'use strict';

const set = require('lodash.set');
const get = require('lodash.get');

const TYPES = require('./types');

module.exports = Converter;

/**
 * Create a new Converter.
 *
 * @constructor
 * @param {Object} [opts] Options for initializing this Converter
 * @param {Object} [opts.types] Custom handlers for different Joi types.
 * @param {String} [opts.ipFormat=ipv4] The specific format for IP formatted strings.
 * This should be `ipv4` or `ipv6`.
 * @param {Boolean} [opts.extract=false] When true, sub-trees with `extract`
 * metadata will be extracted from the output of {@link Converter#convert} and stored
 * within {@link Converter#schemas} while `$ref`s are inserted into their previous
 * positions.
 * @param {String} [opts.extractPath="/"] An optional prefix to append to `extract` when
 * extracting sub-trees from generated schema.
 * {@link Converter#schemas}, placing references in the generated schema instead.
 * @param {Boolean} [opts.ignoreUnknownRules=false] If this is true, the converter will
 * skip rules with unknown names rather than throwing an error.
 * @param {Boolean} [opts.ignoreUnknownTypes=false] If this is true, the converter will
 * skip objects with unknown types rather than throwing an error.
 * @param {Object} [schemas] A tree of schematics for use with references. This will be
 * populated as schemas with names are converted.
 *
 * @property {Object} schemas Any sub-trees that are extracted from schemas generated
 * with {@link Converter#convert} will be stored on this object for easy access
 * and re-use.
 */
function Converter(opts, schemas) {
	if ( !(this instanceof Converter) )
		return new Converter(opts);

	this.opts = opts || {};
	this.opts.ipFormat = this.opts.ipFormat || 'ipv4';
	this.opts.types = this.opts.types || {};
	this.opts.extractPath = this.opts.extractPath || '/';

	if ( ! this.opts.extractPath.startsWith('/') )
		throw new Error('opts.extractPath must start with "/"');

	this.schemas = schemas || {};
}


/**
 * Recursively convert a [Joi](https://github.com/hapijs/joi/) schema
 * object into a JSON schema and return the resulting schema. This supports
 * most of Joi, throwing errors when it encounters something it is
 * unable to handle.
 *
 * Metadata is passed through directly to the resulting object. Other
 * data is passed through type-specific handlers.
 *
 * If a Joi object has attached `extract` metadata, and the {@link Converter}
 * instance has extraction enabled, the resulting schema will be
 * extracted from the tree and placed into {@link Converter#schemas}.
 * A `$ref` will be inserted into the tree in its place.
 *
 * @example
 * my_converter.convert(Joi.string().min(3))
 *
 * // {
 * //     "type": "string",
 * //     "minLength": 3
 * // }
 *
 * @example
 * const my_converter = new Converter({
 *     extract: true,
 *     extractPath: '/components/schemas/'
 * });
 *
 * my_converter.convert(Joi.object({
 *     bar: Joi.string()
 * }).meta({
 *     extract: 'Foo'
 * }));
 *
 * // {
 * //     "$ref": "#/components/schemas/Foo"
 * // }
 *
 *
 * my_converter.schemas
 *
 * // {
 * //     components: {
 * //         schemas: {
 * //             Foo: {
 * //                 type: 'object',
 * //                 properties: {
 * //                     bar: {type: 'string'}
 * //                 }
 * //             }
 * //         }
 * //     }
 * // }
 *
 *
 * @param {Joi} schema The Joi schema to convert
 * @returns {Object} The converted JSON Schema
 * @throws {Error} If we encounter an unknown Joi type, an unknown rule,
 * or a rule that we are unable to correctly translate into JSON Schema,
 * an error is thrown.
 */
Converter.prototype.convert = function(schema) {
	if ( ! schema.isJoi )
		throw new TypeError('schema must be a Joi object');

	// Get a more reasonable representation of the schema to work with.
	schema = schema.describe();

	return this._convert(schema);
}

/**
 * The heart of the conversion process. This is the method that actually
 * builds the output and calls the type-specific handlers.
 *
 * @private
 * @param {Object} thing The schema to convert, output from {@link Joi#describe}.
 * @returns {Object} JSON Schema
 */
Converter.prototype._convert = function(thing) {
	const type = thing.type,
		fn = this.opts.types[type] || TYPES[type];

	if ( ! fn ) {
		if ( this.opts.ignoreUnknownTypes )
			return;

		throw new Error('unknown type for Joi object', type);
	}

	thing.rules = thing.rules || [];
	thing.valids = thing.valids || [];
	thing.flags = thing.flags || {};

	let out = {};

	if ( thing.meta )
		out.meta = Object.assign({}, ...thing.meta);

	const extract_name = this.opts.extract && out.meta && out.meta.extract,
		path = extract_name && (extract_name.startsWith('/') ? extract_name : this.opts.extractPath + extract_name),
		split_path = path && path.slice(1).split('/');

	if ( extract_name && get(this.schemas, split_path) )
		return {
			'$ref': `#${path}`
		}

	if ( thing.valids.includes(null) )
		out.nullable = true;

	if ( thing.flags.allowOnly && thing.valids.length ) {
		out.enum = thing.valids.filter(x => x !== null);
	}

	if ( thing.notes ) {
		const meta = out.meta = out.meta || {};
		meta.notes = (meta.notes || []).concat(thing.notes);
	}

	if ( thing.tags ) {
		const meta = out.meta = out.meta || {};
		meta.tags = (meta.tags || []).concat(thing.tags);
	}

	if ( thing.unit ) {
		const meta = out.meta = out.meta || {};
		meta.unit = thing.unit;
	}

	if ( thing.examples )
		out.examples = thing.examples;

	if ( thing.label )
		out.title = thing.label;

	if ( thing.description )
		out.description = thing.description;

	if ( thing.flags.default !== undefined )
		out.default = thing.flags.default;

	out = fn(thing, out, this);
	if ( ! out )
		return;

	if ( extract_name ) {
		delete out.meta.extract;
		if ( ! Object.keys(out.meta).length )
			delete out.meta;

		set(this.schemas, split_path, out);
		return {
			'$ref': `#${path}`
		}
	}

	return out;
}