UNPKG

@windingtree/wt-read-api

Version:

API to interact with the Winding Tree platform

225 lines (214 loc) 9.3 kB
/* eslint-disable no-prototype-builtins */ const fetch = require('node-fetch'); const path = require('path'); const YAML = require('yamljs'); const semver = require('semver'); const _ = require('lodash'); const Validator = require('swagger-model-validator'); const { HttpValidationError, MisconfigurationError, } = require('../errors'); const { config, } = require('../config'); /** * Utility class for data format validation. */ class DataFormatValidator { /** * Static method to validate data against specific model in schema. * @param data * @param modelName * @param schemas The components.schemas part of swagger definition * @param desiredDataFormatVersion Version range that the declared data format version is checked * @param declaredDataFormatVersion If the data doesn't contain `dataFormatVersion` field, you may provide an overriding value here. * @param type human-readable name that is used in error messages * @param fields Fields to resolve */ static validate (data, modelName, schemas, desiredDataFormatVersion, declaredDataFormatVersion = undefined, type = 'model', fields = []) { let dataFormatVersion = declaredDataFormatVersion; // don't validate dataFormatVersion when only fetching on-chain data if (!fields || !fields.length || !(fields.length === 1 && fields[0] === 'id')) { dataFormatVersion = data.dataFormatVersion || declaredDataFormatVersion; if (!dataFormatVersion) { const error = new HttpValidationError(); error.data = { valid: false, errors: [`Missing property \`dataFormatVersion\` in ${type} data for id ${data.id || data.data.id}`], data: { id: data.id || (data.data && data.data.id), }, }; throw error; } if (!semver.satisfies(dataFormatVersion, desiredDataFormatVersion)) { const error = new HttpValidationError(); error.data = { valid: true, errors: [`Unsupported data format version ${dataFormatVersion}. Supported versions: ${desiredDataFormatVersion}`], data: { id: data.id || (data.data && data.data.id), }, }; throw error; } } if (!schemas.hasOwnProperty(modelName)) { const error = new HttpValidationError(); error.data = { valid: false, errors: [`Model ${modelName} not found in schemas.`] }; throw error; } const validation = (new Validator()).validate(data, schemas[modelName], schemas, true, false); if (!validation.valid) { const error = new HttpValidationError(); error.data = validation; throw error; } } /** * Static method to load schema from URI. Use this to prevent multiple loading and improve performance. * @param schemaPath * @param schemaModel Main model schema to validate against * @param fields Fields asked for in the request. Remove other required fields from schema to prevent validation errors. * @param fieldsMapping * @returns {Promise<String>} */ static async loadSchemaFromPath (schemaPath, schemaModel, fields = undefined, fieldsMapping = {}) { if (!config.dataFormatVersions) { throw new MisconfigurationError('config.dataFormatVersions is not configured, check API deployment.'); } let mainSchemaDocument; if (DataFormatValidator.CACHE.hasOwnProperty(schemaPath)) { mainSchemaDocument = _.cloneDeep(DataFormatValidator.CACHE[schemaPath]); } else { mainSchemaDocument = YAML.load(path.resolve(schemaPath)); mainSchemaDocument = await this._loadSchema(mainSchemaDocument); DataFormatValidator.CACHE[schemaPath] = _.cloneDeep(mainSchemaDocument); } mainSchemaDocument.components.schemas = this._intersectRequiredFields(mainSchemaDocument.components.schemas, schemaModel, fields, fieldsMapping); return mainSchemaDocument; } static async _loadSchema (mainSchemaDocument) { const modelReferences = _.uniq(this._collectRemoteRefs(mainSchemaDocument.components.schemas)); const schemasToLoad = _.uniq(modelReferences.map((ref) => { return ref.substring(0, ref.indexOf('#')); })); for (const schemaUri of schemasToLoad) { let schema = await this._fetchFileFromUri(schemaUri); schema = await this._loadSchema(schema); for (const key of Object.keys(schema.components.schemas)) { mainSchemaDocument.components.schemas[key] = schema.components.schemas[key]; } } return mainSchemaDocument; } static async _fetchFileFromUri (uri) { const content = await fetch(uri).then(res => res.text()); return YAML.parse(content); } static _collectRemoteRefs (data) { let res = []; if (data && data.hasOwnProperty('$ref') && data.$ref.startsWith('http')) { res.push(data.$ref); data.$ref = data.$ref.substring(data.$ref.indexOf('#')); } if (Array.isArray(data)) { for (const item of data) { res = res.concat(this._collectRemoteRefs(item)); } } else if (typeof data === 'object') { for (const key in data) { res = res.concat(this._collectRemoteRefs(data[key])); } } return res; } /** * Remove fields the client did not ask for from required to prevent validation errors. * @param data Schemas definition * @param modelName * @param fields Fields asked for in request * @param reversedFieldMapping * @returns {*} Updated schemas definition * @private */ static _intersectRequiredFields (data, modelName, fields = undefined, reversedFieldMapping = {}) { const nestedBaseFields = {}; if (fields) { for (const field of fields) { if (field.indexOf('.') > -1) { let [base, ...rest] = field.split('.'); rest = rest.join('.'); if (base in reversedFieldMapping) { base = reversedFieldMapping[base]; } nestedBaseFields[base] = nestedBaseFields[base] || []; nestedBaseFields[base].push(rest); } } } if (data[modelName] && data[modelName].hasOwnProperty('required') && Array.isArray(data[modelName].required)) { if (!_.isUndefined(fields)) { data[modelName].required = _.intersection(data[modelName].required, fields); } } if (data[modelName] && data[modelName].hasOwnProperty('properties')) { for (const nestedField of Object.keys(nestedBaseFields)) { if (Object.keys(data[modelName].properties).indexOf(nestedField) > -1) { if (data[modelName].properties[nestedField].hasOwnProperty('$ref')) { const refName = this._getReferenceBaseName(data[modelName].properties[nestedField].$ref); data = this._intersectRequiredFields(data, refName, nestedBaseFields[nestedField], reversedFieldMapping); } else if (data[modelName].properties[nestedField].type === 'array') { if (data[modelName].properties[nestedField].items.hasOwnProperty('$ref')) { const refName = this._getReferenceBaseName(data[modelName].properties[nestedField].items.$ref); data = this._intersectRequiredFields(data, refName, nestedBaseFields[nestedField], reversedFieldMapping); } else { const refName = `${modelName}.${nestedField}`; data[refName] = data[modelName].properties[nestedField].items; data = this._intersectRequiredFields(data, refName, nestedBaseFields[nestedField], reversedFieldMapping); } } else { data[modelName] = this._intersectRequiredFields(data[modelName], nestedField, nestedBaseFields[nestedField], reversedFieldMapping); } } } } if (data[modelName] && data[modelName].hasOwnProperty('$ref')) { const refName = this._getReferenceBaseName(data[modelName].$ref); data = this._intersectRequiredFields(data, refName, fields, reversedFieldMapping); } if (data[modelName] && data[modelName].type === 'array') { if (data[modelName] && data[modelName].items.hasOwnProperty('$ref')) { const refName = this._getReferenceBaseName(data[modelName].items.$ref); data = this._intersectRequiredFields(data, refName, fields, reversedFieldMapping); } else { const refName = `${modelName}.0`; data[refName] = data[modelName].items; data = this._intersectRequiredFields(data, refName, fields, reversedFieldMapping); } } if (data[modelName] && data[modelName].hasOwnProperty('allOf')) { let i = 0; for (const part of data[modelName].allOf) { if (part.hasOwnProperty('$ref')) { const refName = this._getReferenceBaseName(part.$ref); data = this._intersectRequiredFields(data, refName, fields, reversedFieldMapping); } else { const propertiesModelName = `${modelName}.${i}`; data[propertiesModelName] = part; data = this._intersectRequiredFields(data, propertiesModelName, fields, reversedFieldMapping); } i += 1; } } return data; } static _getReferenceBaseName (ref) { return ref.substring(ref.indexOf(('#'))).replace('#/components/schemas/', ''); } } DataFormatValidator.CACHE = {}; module.exports = { DataFormatValidator, };