UNPKG

@alt3/sequelize-to-json-schemas

Version:

Convert Sequelize models into various JSON Schema variants (using the Strategy Pattern)

642 lines (542 loc) 19.1 kB
const { capitalize, omit, pick } = require('./utils/lodash-natives'); const StrategyInterface = require('./strategy-interface'); const TypeMapper = require('./type-mapper'); const { checkTypeOptional, checkTypeRequired } = require('./utils/type-checks'); /** * Merged constructor options. * * @private */ const _options = new WeakMap(); /** * Merged model options. * * @private */ const _modelOptions = new WeakMap(); /** * Strategy instance passed to the `generate()` method. * * @private */ const _strategy = new WeakMap(); /** * Sequelize model instance passed to the `generate()` method. * * @private */ const _model = new WeakMap(); /** * Class responsible for generating the various schemas. * * @copyright Copyright (c) 2019 ALT3 B.V. * @license Licensed under the MIT License */ class SchemaManager { /** * @param {object} options User options. * @param {string} options.baseUri Base URI prefixed to generated paths, defaults to '/' * @param {string} options.absolutePaths False to generate relative paths, defaults to true * @param {string} options.secureSchemaUri False to render a HTTP link to the strategy-specific schema, defaults to true (HTTPS) * @param {string} options.disableComments True to render attribute property 'comment', defaults to false */ constructor(options) { const defaultOptions = { baseUri: '/', absolutePaths: true, secureSchemaUri: true, disableComments: true, }; // eslint-disable-next-line prefer-object-spread this._verifyOptions(Object.assign({}, defaultOptions, options)); } /** * Generate json schema for the provided model, using any of the available strategies. * * @param {sequelize.Model} model Instance of Sequelize.Model * @param {strategyInterface} strategy Strategy instance * @param {object} options User options. * @param {string} options.title Name to be used as model property 'title' * @param {string} options.description Text to be used as model property 'description' * @param {array} options.exclude List of attribute names that will not be included in the generated schema * @param {array} options.include List of attribute names that will be included in the generated schema * @param {array} options.associations False to exclude all associations from the generated schema, defaults to true * @returns {object} Object contaiing the strategy-specific schema * @param {array} options.excludeAssociations List of association names that will not be included in the generated schema * @param {array} options.includeAssociations List of association names that will be included in the generated schema */ generate(model, strategy, options) { const defaultOptions = { title: null, description: null, include: [], exclude: [], associations: true, includeAssociations: [], excludeAssociations: [], }; if (model === undefined) throw new Error('Missing method argument'); if (strategy === undefined) throw new Error('Mising method argument'); // eslint-disable-next-line prefer-object-spread this._verifyModelOptions(Object.assign({}, defaultOptions, options)); this._verifyModel(model); this._verifyStrategy(strategy); // import attributes from the sequelize model const attributes = this._getRawAttributes(model); // construct the response const result = this._getModelContainer(); const requiredAttributes = []; // add property schema (type, nullable, etc) for (const attributeName of Object.keys(attributes)) { result.properties[attributeName] = this._getAttributeContainer( attributeName, attributes[attributeName], ); if (this.constructor._isRequiredProperty(attributes[attributeName])) { requiredAttributes.push(attributeName); } } // merge required if (requiredAttributes.length > 0) { result.required = requiredAttributes; } // skip adding associations completely if configured by the user if (_modelOptions.get(this).associations === false) { return result; } for (const association of Object.keys(this._getAssociations())) { Object.assign( result.properties, this._getModelPropertyForAssociation(association, model.associations[association]), ); } return result; } /** * Ensures constructor options are valid. * * @private * @param {object} options Merged default and user provided options. * @returns {null} */ _verifyOptions(options) { if (!options.baseUri.endsWith('/')) { options.baseUri += '/'; // eslint-disable-line no-param-reassign } checkTypeRequired('absolutePaths', options.absolutePaths, 'boolean'); checkTypeRequired('secureSchemaUri', options.secureSchemaUri, 'boolean'); checkTypeRequired('disableComments', options.disableComments, 'boolean'); _options.set(this, options); } /** * Ensures model options are valid. * * @private * @param {object} options Merged default and user provided options. * @returns {null} */ _verifyModelOptions(options) { checkTypeOptional('title', options.title, 'string'); checkTypeOptional('description', options.description, 'string'); checkTypeRequired('include', options.include, 'array'); checkTypeRequired('exclude', options.exclude, 'array'); if (options.include.length > 0 && options.exclude.length > 0) { throw new Error("Model options 'include' and 'exclude' are mutually exclusive"); } checkTypeRequired('associations', options.associations, 'boolean'); checkTypeRequired('includeAssociations', options.includeAssociations, 'array'); checkTypeRequired('excludeAssociations', options.excludeAssociations, 'array'); if (options.includeAssociations.length > 0 && options.excludeAssociations.length > 0) { throw new Error( "Model options 'includeAssociations' and 'excludeAssociations' are mutually exclusive", ); } _modelOptions.set(this, options); } /** * Ensures the passed Sequelize model is valid. * * @private * @param {sequelize.Model} model Instance of Sequelize.Model * @returns {null} */ _verifyModel(model) { if ('rawAttributes' in model) { _model.set(this, model); return null; } if ('attributes' in model) { _model.set(this, model); return null; } throw new TypeError( 'Provided model does not match expected format. Are you sure this is a Sequelize model?', ); } /** * Enusures the passed strategy is valid. * * @private * @param {strategyInterface} strategy Strategy instance * @returns {null} */ _verifyStrategy(strategy) { if (!(strategy instanceof StrategyInterface)) throw new TypeError("Strategy must implement the 'StrategyInterface'"); _strategy.set(this, strategy); } /** * Returns the raw properties from a (v4 or v5+) Sequelize model. * * @private * @returns {object} Raw Sequelize attributes */ _getRawAttributes() { const model = _model.get(this); let attributes = {}; if ('rawAttributes' in model) { attributes = model.rawAttributes; // v5+ } else { attributes = model; // v4 } if (_modelOptions.get(this).include.length > 0) { return pick(attributes, _modelOptions.get(this).include); } if (_modelOptions.get(this).exclude.length > 0) { return omit(attributes, _modelOptions.get(this).exclude); } return attributes; } /** * Returns the associations for a Sequelize model. * * @private * @returns {object|null} List of associated models or null */ _getAssociations() { const model = _model.get(this); if (model.associations.length === 0) { return null; } return model.associations; } /** * Returns the strategy-specific schema structure for the model, ready for attribute insertion. * * @private * @see {@link https://json-schema.org/learn/getting-started-step-by-step.html#properties} * @returns {object} Schema structure */ _getModelContainer() { const result = {}; Object.assign(result, this._getPropertySchema()); // some schemas Object.assign(result, this._getPropertyId(this._getModelFilePath(_model.get(this).name))); // some schemas Object.assign(result, this._getModelPropertyTitle(_model.get(this))); // all schemas Object.assign(result, this._getModelPropertyDescription()); // all schemas but only if user passed the model option // identical for all models and schemas thus no need to over-engineer result.type = 'object'; result.properties = {}; return result; } /** * Returns the strategy-specific schema structure for the attribute. * * @private * @param {string} attributeName Name of the attribute * @param {object} attributeProperties The raw sequelize attribute properties * @returns {object} Schema structure */ _getAttributeContainer(attributeName, attributeProperties) { const typeMapper = new TypeMapper(); const result = {}; Object.assign(result, this._getPropertyId(this._getAttributePath(attributeName))); // `id`: some schemas Object.assign(result, typeMapper.map(attributeName, attributeProperties, _strategy.get(this))); // `type`: all schemas, strategy-specific types Object.assign( result, this._getAttributePropertyTypeOverride(attributeName, attributeProperties), ); // override the type generated by type-mapper if 'schema' exists Object.assign( result, this._getAttributePropertyDescription(attributeName, attributeProperties), // `description:` requires sequelize property jsonSchema.description ); Object.assign( result, this._getAttributePropertyComment(attributeName, attributeProperties), // `comment:` requires sequelize property jsonSchema.description ); Object.assign(result, this._getPropertyReadOrWriteOnly(attributeName, attributeProperties)); // `readOnly` or `writeOnly`: some schemas Object.assign(result, this._getAttributeExamples(attributeName, attributeProperties)); // `examples`: requires sequelize property jsonSchema.examples return result; } /** * Returns the model file name (e.g. `user.json`). * * @private * @returns {string} */ static _getModelFileName(modelName) { return `${modelName}.json`; } /** * Returns the model path as used by $id and $ref. * * @private * @param {string} modelName Name of the model * @returns {string} */ _getModelFilePath(modelName) { const path = this.constructor._getModelFileName(modelName); if (_options.get(this).absolutePaths) { return `${_options.get(this).baseUri}${path}`; } return `/${path}`; } /** * Returns the `schema` property for the model. * * @private * @param {boolean} secureSchemaUri True for HTTPS, false for HTTP * @returns {object} */ _getPropertySchema() { return _strategy.get(this).getPropertySchema(_options.get(this).secureSchemaUri); } /** * Returns the `id` property for the model. * * @private * @param {string} Path to the json file * @returns {object} */ _getPropertyId(path) { return _strategy.get(this).getPropertyId(path); } /** * Returns the `title` property for the model. Since this property * is supported by all schemas we do not need a strategy here. * * @private * @param {sequelize.Model} model Instance of Sequelize.Model * @returns {object} */ _getModelPropertyTitle(model) { let { title } = _modelOptions.get(this); if (!title) { title = capitalize(model.options.name.singular); } return { title, }; } /** * Returns the `description` property for the model. Since this property * is supported by all schemas we do not need a strategy here. * * @private * @returns {object|null} Null if the user did not pass the option. */ _getModelPropertyDescription() { const { description } = _modelOptions.get(this); if (!description) { return null; } return { description, }; } /** * Checks if the given attribute property is required * * @private * @param {object} attributeProperties The raw sequelize attribute properties * @returns {boolean} True if attribute is required */ static _isRequiredProperty(attributeProperties) { if (attributeProperties.allowNull === false) { return true; } if (attributeProperties.defaultValue !== undefined) { return true; } return false; } /** * Return the custom Sequelize attribute property as configured in `jsonSchema`. * * @private * @param {string} propertyName Name of the custom attribute property to search for * @param {object} attributeProperties Raw Sequelize attribute properties * @returns {*} Null if the custom attribute does not exist */ static _getCustomPropertyValue(propertyName, attributeProperties) { const { jsonSchema } = attributeProperties; if (jsonSchema === undefined) { return null; } if (jsonSchema[propertyName] === undefined) { return null; } return jsonSchema[propertyName]; } /** * Returns a user-defined schema object that overrides the type object * created by the TypeMapper class. This is necessary for any sequelize type * that is mapped to the ANY array while using the OpenAPI strategy. * * @param {string} attributeName Name of the attribute * @param {object} attributeProperties Raw sequelize attribute properties * @returns {object|null} */ _getAttributePropertyTypeOverride(attributeName, attributeProperties) { const schema = this.constructor._getCustomPropertyValue('schema', attributeProperties); if (!schema) { return null; } if (typeof schema === 'object' && typeof schema.type === 'string') { return schema; } throw new TypeError( `Custom property 'schema' for sequelize attribute '${attributeName}' should be an object with a 'type' key`, ); } /** * Returns the attribute path as used by $id and $ref * * @private * @returns {string} */ _getAttributePath(attributeName) { if (_options.get(this).absolutePaths) { return `${_options.get(this).baseUri}properties/${attributeName}`; } return `/properties/${attributeName}`; } /** * Returns the user-defined attribute description. Since this property * is supported by all schemas we do not need a strategy here. * * @private * @param {string} attributeName Name of the attribute * @param {object} attributeProperties Raw sequelize attribute properties * @returns {string} */ _getAttributePropertyDescription(attributeName, attributeProperties) { const description = this.constructor._getCustomPropertyValue( 'description', attributeProperties, ); if (description === null) { return null; } checkTypeRequired('description', description, 'string'); return { description, }; } /** * Returns the user-defined attribute comment. * * @private * @param {string} attributeName Name of the attribute * @param {object} attributeProperties Raw sequelize attribute properties * @returns {string} */ _getAttributePropertyComment(attributeName, attributeProperties) { if (_options.get(this).disableComments === true) { return null; } const comment = this.constructor._getCustomPropertyValue('comment', attributeProperties); if (comment === null) { return null; } checkTypeRequired('comment', comment, 'string'); return _strategy.get(this).getPropertyComment(comment); } /** * Returns one of the user-defined attribute properties 'readOnly' or 'writeOnly'. * * @private * @param {string} attributeName Name of the attribute * @param {object} attributeProperties Raw sequelize attribute properties * @returns {object|null} */ _getPropertyReadOrWriteOnly(attributeName, attributeProperties) { const readOnly = this.constructor._getCustomPropertyValue('readOnly', attributeProperties); const writeOnly = this.constructor._getCustomPropertyValue('writeOnly', attributeProperties); if (readOnly === null && writeOnly === null) { return null; } if (readOnly && writeOnly) { throw new TypeError( `Custom properties 'readOnly' and 'writeOnly' for sequelize attribute '${attributeName}' are mutually exclusive`, ); } if (readOnly) { checkTypeRequired('readOnly', readOnly, 'boolean'); return { readOnly: true, }; } // still here so writeOnly checkTypeRequired('writeOnly', writeOnly, 'boolean'); return { writeOnly: true, }; } /** * Returns the user-defined attribute examples (strategy-specific) * * @private * @param {string} attributeName Name of the attribute * @param {object} attributeProperties Raw sequelize attribute properties * @returns {string} */ _getAttributeExamples(attributeName, attributeProperties) { const examples = this.constructor._getCustomPropertyValue('examples', attributeProperties); if (examples === null) { return null; } // see https://json-schema.org/understanding-json-schema/reference/generic.html if (!Array.isArray(examples)) { throw new TypeError("The 'examples' property MUST be an array"); } return _strategy.get(this).getPropertyExamples(examples); } /** * Returns the property for the given association. * * @private * @param {string} association name * @param {Sequelize.Association} association Sequelize Association * @returns {object|null} Object if HasOne, BelongsTo or HasMany and not excluded, null otherwise */ _getModelPropertyForAssociation(associationName, association) { const options = _modelOptions.get(this); if ( options.excludeAssociations.length > 0 && options.excludeAssociations.includes(associationName) ) { return null; } if ( options.includeAssociations.length > 0 && options.includeAssociations.includes(associationName) === false ) { return null; } switch (association.associationType) { case 'HasOne': return _strategy.get(this).getPropertyForHasOneAssociation(associationName, association); case 'BelongsTo': return _strategy.get(this).getPropertyForBelongsToAssociation(associationName, association); case 'HasMany': return _strategy.get(this).getPropertyForHasManyAssociation(associationName, association); case 'BelongsToMany': return _strategy .get(this) .getPropertyForBelongsToManyAssociation(associationName, association); default: return null; } } } module.exports = SchemaManager;