src/schema.js
/* eslint-disable class-methods-use-this, arrow-body-style */
import _ from 'lodash';
import { parseReference, lookupReference, dereference } from './json-schema';
export function idForDataStructure(reference) {
return `definitions/${parseReference(reference)}`;
}
/*
* Data Structure Generator
* Generates a dataStructure element from a Swagger Schema.
*
* >>> const generator = new DataStructureGenerator(minimNamespace);
* >>> const dataStructure = generator.generateDataStructure({type: 'string'});
*/
export class DataStructureGenerator {
constructor(minim, root) {
this.minim = minim;
this.root = root;
}
// Generates a data structure element representing the given schema
generateDataStructure(schema) {
const element = this.generateElement(schema);
if (!element) {
return null;
}
const { DataStructure } = this.minim.elements;
const dataStructure = new DataStructure(element);
return dataStructure;
}
// Generates a member element for a property in a schema
generateMember(name, property) {
const {
String: StringElement,
Member: MemberElement,
} = this.minim.elements;
const member = new MemberElement();
member.key = new StringElement(name);
member.value = this.generateElement(property);
if (property.description) {
member.description = property.description;
}
return member;
}
// Generates an enum element for the given enum schema
generateEnum(schema) {
const { Enum: EnumElement } = this.minim.elements;
const element = new EnumElement();
element.enumerations = schema.enum;
// eslint-disable-next-line no-restricted-syntax
for (const enumeration of element.enumerations) {
enumeration.attributes.set('typeAttributes', ['fixed']);
}
return element;
}
// Generates an object element from the given object schema
generateObject(schema) {
const {
Object: ObjectElement,
} = this.minim.elements;
const element = new ObjectElement();
let properties = schema.properties || {};
let required = schema.required || [];
if (schema.allOf && Array.isArray(schema.allOf)) {
// Merge all of the object allOf into properties and required
const allOf = schema.allOf.filter(subschema => subschema.type === 'object');
const allProperties = allOf
.filter(subschema => subschema.properties)
.map(subschema => subschema.properties);
properties = Object.assign(properties, ...allProperties);
required = allOf
.filter(subschema => subschema.required)
.map(subschema => subschema.required)
.reduce((accumulator, property) => accumulator.concat(property), required);
const refs = schema.allOf
.filter(subschema => subschema.$ref)
.map(subschema => idForDataStructure(subschema.$ref));
if (refs.length === 1) {
// allOf contains ref, let's treat it as our base
element.element = refs[0];
} else if (refs.length > 1) {
const { Ref: RefElement } = this.minim.elements;
const refElements = refs.map(ref => new RefElement(ref));
element.content = element.content.concat(refElements);
}
}
element.content = element.content.concat(_.map(properties, (subschema, property) => {
const member = this.generateMember(property, subschema);
const isRequired = required.includes(property);
member.attributes.set('typeAttributes', [
isRequired ? 'required' : 'optional',
]);
return member;
}));
return element;
}
// Generates an array element from the given array schema
generateArray(schema) {
const { Array: ArrayElement } = this.minim.elements;
const element = new ArrayElement();
if (schema.items) {
if (_.isArray(schema.items)) {
schema.items.forEach((item) => {
const itemElement = this.generateElement(item);
if (itemElement) {
element.push(itemElement);
}
});
} else {
const itemElement = this.generateElement(schema.items);
if (itemElement) {
element.push(itemElement);
}
}
}
return element;
}
// Generates an array of descriptions for each validation rule in the given schema.
generateValidationDescriptions(schema) {
const validations = {
// String
pattern: value => `Matches regex pattern: \`${value}\``,
maxLength: value => `Length of string must be less than, or equal to ${value}`,
minLength: value => `Length of string must be greater than, or equal to ${value}`,
// Number
multipleOf: value => `Number must be a multiple of ${value}`,
maximum: value => `Number must be less than, or equal to ${value}`,
minimum: value => `Number must be more than, or equal to ${value}`,
exclusiveMaximum: value => `Number must be less than ${value}`,
exclusiveMinimum: value => `Number must be more than ${value}`,
// Object
minProperties: value => `Object must have more than, or equal to ${value} properties`,
maxProperties: value => `Object must have less than, or equal to ${value} properties`,
// Array
maxItems: value => `Array length must be less than, or equal to ${value}`,
minItems: value => `Array length must be more than, or equal to ${value}`,
uniqueItems: () => 'Array contents must be unique',
// Other
format: value => `Value must be of format '${value}'`,
};
return _
.chain(validations)
.map((value, key) => {
if (schema[key]) {
return value(schema[key]);
}
return null;
})
.compact()
.value();
}
/**
* Retrieve the type from the schema
* In the case where there is no provided type, the allOf types are matched.
* @param {object} schema
* @returns {string} type
*/
typeForSchema(schema) {
if (schema.$ref) {
// Peek into the reference, if we're calling typeForSchema from the
// allOf case below then we will need to know the destination type
const ref = lookupReference(schema.$ref, this.root);
return this.typeForSchema(ref.referenced);
}
if (schema.type === undefined) {
if (schema.allOf && schema.allOf.length > 0) {
// Try to infer type from allOf values
const allTypes = schema.allOf.map(this.typeForSchema, this);
const uniqueTypes = _.uniq(allTypes);
if (uniqueTypes.length === 1) {
return uniqueTypes[0];
}
}
if (schema.properties) {
// Assume user meant object
return 'object';
}
}
return schema.type;
}
// Generates an element representing the given schema
generateElement(schema) {
const {
String: StringElement,
Number: NumberElement,
Boolean: BooleanElement,
Null: NullElement,
Enum: EnumElement,
// Ref: RefElement,
} = this.minim.elements;
const typeGeneratorMap = {
boolean: BooleanElement,
string: StringElement,
number: NumberElement,
integer: NumberElement,
null: NullElement,
file: StringElement,
};
if (schema.allOf && schema.allOf.length === 1 && schema.definitions &&
Object.keys(schema).length === 2) {
// Since we can't have $ref at root with definitions.
// `allOf` with a single item is used as a work around for this type of schema
// We can safely ignore the allOf and unwrap it as normal schema in this case
return this.generateElement(schema.allOf[0]);
}
const type = this.typeForSchema(schema);
let element;
if (schema.$ref) {
// element = new RefElement(idForDataStructure(schema.$ref));
element = new this.minim.elements.Element();
element.element = idForDataStructure(schema.$ref);
return element;
} else if (schema.enum) {
element = this.generateEnum(schema);
} else if (type === 'array') {
element = this.generateArray(schema);
} else if (type === 'object') {
element = this.generateObject(schema);
} else if (type && typeGeneratorMap[type]) {
element = new typeGeneratorMap[type]();
} else if (type) {
throw new Error(`Unhandled schema type '${type}'`);
}
if (element) {
if (schema.title) {
element.title = new StringElement(schema.title);
}
if (schema.description) {
element.description = new StringElement(schema.description);
}
if (schema['x-nullable']) {
element.attributes.set('typeAttributes', ['nullable']);
}
let def = schema.default;
if (def !== undefined && !_.isArray(def) && !_.isObject(def)) {
// TODO Support defaults for arrays and objects
if (schema.enum) {
def = new EnumElement(def);
def.content.attributes.set('typeAttributes', ['fixed']);
}
element.attributes.set('default', def);
}
let samples = [];
if (schema.example) {
samples = [dereference(schema.example, this.root)];
}
if (samples.length > 0) {
if (schema.enum) {
samples = samples.map((item) => {
const enumeration = new EnumElement(item);
enumeration.content.attributes.set('typeAttributes', ['fixed']);
return enumeration;
});
}
const hasSample = samples.length === 1 && samples[0];
const emptyContent = !element.content || element.content.length === 0;
if (hasSample && emptyContent) {
// Convert the sample value to an element as a cheap and easy way to
// check the type matches our element. It will also refract
// object/array items that are the sample value as members/elements
// for us so we can grab its content
const example = this.minim.toElement(samples[0]);
if (element.element === example.element) {
element.content = example.content;
} else {
element.attributes.set('samples', samples);
}
} else {
element.attributes.set('samples', samples);
}
}
const validationDescriptions = this.generateValidationDescriptions(schema);
if (validationDescriptions.length > 0) {
const description = validationDescriptions.map(value => `- ${value}`);
if (element.description && element.description.toValue()) {
description.splice(0, 0, `${element.description.toValue()}\n`);
}
element.description = new StringElement(description.join('\n'));
}
}
return element;
}
}