UNPKG

arrest

Version:

OpenAPI v3 compliant REST framework for Node.js, with support for MongoDB and JSON-Schema

394 lines 17.3 kB
import { ForbiddenError, subject } from '@casl/ability'; import { deleteProperty, getProperty, setProperty } from 'dot-prop'; import { dot } from 'eredita'; import _ from 'lodash'; import { DateTime } from 'luxon'; import { ObjectId } from 'mongodb'; import { API } from './api.js'; /* * Rebasing patterns */ const otherRef = new RegExp('^(.+)#/definitions/(.+)', 'g'); const selfRef = new RegExp('^#/definitions/(.+)', 'g'); const otherPropRef = new RegExp('^(.+)#/properties/(.+)', 'g'); const selfPropRef = new RegExp('^#/properties/(.+)', 'g'); const nameRef = new RegExp('^([^#]+[^#]+)$', 'g'); const namePlusHash = new RegExp('^([^#]+)#+$', 'g'); const absoluteRef = new RegExp('^(http:|https:)', 'gi'); /** * Rebase a obj.$ref value, following the rules: * * <ext_schema_name>#/definitions/<schema_name> to #/components/schemas/<ext_schema_name>/definitions/<schema_name> * #/definitions/<schema_def_name> to #/components/schemas/<schema_name>/definitions/<schema_def_name> * <ext_schema_name>#/properties/<prop_name> to #/components/schemas/<ext_schema_name>/properties/<prop_name> * #/properties/<prop_name> to #/components/schemas/<schema_name>/definitions/<prop_name> * <ext_schema_name># to #/components/schemas/<ext_schema_name> * <ext_schema_name> to #/components/schemas/<ext_schema_name> * * @export * @param {string} schemaName * @param {*} obj * @returns {*} the modified obj with rebased $ref property */ export function refsRebaser(schemaName, obj) { let rebasedRef = obj.$ref; if (obj.$ref.match(selfRef)) { rebasedRef = obj.$ref.replace(selfRef, `#/components/schemas/${schemaName}/definitions/$1`); } else if (obj.$ref.match(otherRef)) { rebasedRef = obj.$ref.replace(otherRef, '#/components/schemas/$1/definitions/$2'); } else if (obj.$ref.match(otherPropRef)) { rebasedRef = obj.$ref.replace(otherPropRef, '#/components/schemas/$1/properties/$2'); } else if (obj.$ref.match(selfPropRef)) { rebasedRef = obj.$ref.replace(selfPropRef, `#/components/schemas/${schemaName}/properties/$1`); } else if (obj.$ref.match(nameRef) && !obj.$ref.match(absoluteRef)) { rebasedRef = obj.$ref.replace(nameRef, '#/components/schemas/$1'); } else if (obj.$ref.match(namePlusHash) && !obj.$ref.match(absoluteRef)) { rebasedRef = obj.$ref.replace(namePlusHash, '#/components/schemas/$1'); } obj['$ref'] = rebasedRef; return obj; } /** * Recursively move all OpenAPI spec schemas' definitions properties and rebase $refs accordingly * * @export * @param {*} fullSpec - the full OpenAPI spec object eventually containing definitions properties * @returns {*} - the transformed spec with rebased definitions */ export function rebaseOASDefinitions(fullSpec) { try { let specCopy = fullSpec; if (specCopy.components && specCopy.components.schemas) { const components = specCopy.components; for (const schemaKey in components.schemas) { let schemas = components.schemas; let path = `components.schemas.${schemaKey}`; specCopy = rebaseOASDefinition(specCopy, schemaKey, schemas[schemaKey], path, [schemaKey]); } } return specCopy; } catch (err) { throw new Error('Unable to rebase schema definitions, check the Schema.'); } } function rebaseOASDefinition(fullSpec, schemaKey, schema, path, definitionsPath) { if (schema.definitions) { for (const defKey in schema.definitions) { // current recursive definition path chain const chain = [...definitionsPath, defKey]; const newPath = `${path}.definitions.${defKey}`; const definition = getProperty(fullSpec, newPath); fullSpec = rebaseOASDefinition(fullSpec, defKey, definition, newPath, chain); const newSchemaName = `${chain.join('_')}`; // move to components/schemas and get a new ref related to the new path fullSpec = moveDefinition(fullSpec, newSchemaName, newPath); } deleteProperty(fullSpec, `${path}.definitions`); } return fullSpec; } function moveDefinition(spec, newSchemaKey, path) { // copy the definition to components.schemas root const defSchema = getProperty(spec, path); setProperty(spec, `components.schemas.${newSchemaKey}`, defSchema); // delete the definition at current path deleteProperty(spec, path); const newRef = `#/components/schemas/${newSchemaKey}`; return updateRefs(spec, `#/${path.split('.').join('/')}`, newRef); } function updateRefs(spec, originalRef, newRef) { const regexp = new RegExp(originalRef, 'g'); let specString = JSON.stringify(spec); let newSpecString = specString.replace(regexp, newRef); let newSpec = JSON.parse(newSpecString); return newSpec; } /** * Remove $schema property from a JSON Schema * * @param obj - the JSON Schema object * @returns Object - the JSON Schema without $schema definition property */ export function removeSchemaDeclaration(obj) { if (obj.hasOwnProperty('$schema')) { delete obj['$schema']; } return obj; } /** * Remove all schema definitions from spec.components.schemas, from spec.components.parameters, * and from spec.components.responses when there isn't any $ref pointing to them. * The function modifies the document passed in input. * * @param spec - the OpenAPIV3.Document to "clean" * @returns the clean OpenAPIV3.Document */ export function removeUnusedSchemas(spec) { // init occurrences table object const occurrences = { schemas: {}, parameters: {}, responses: {} }; if (spec.components && spec.components.schemas) { for (const schema of Object.keys(spec.components.schemas)) { occurrences.schemas[schema] = { count: 0, referencedBy: [] }; } } if (spec.components && spec.components.parameters) { for (const param of Object.keys(spec.components.parameters)) { occurrences.parameters[param] = { count: 0, referencedBy: [] }; } } if (spec.components && spec.components.responses) { for (const response of Object.keys(spec.components.responses)) { occurrences.responses[response] = { count: 0, referencedBy: [] }; } } try { // build the occurrences table (function findAndCount(obj, path = []) { for (const key of Object.keys(obj)) { const currentPath = [...path, key]; if (key === '$ref') { const refPath = obj[key].split('/'); // check if ref path is relative to the current spec AND if it points to an existing schema element if (obj[key].startsWith('#/components/schemas') && !getProperty(spec, refPath.slice(1).join('.'))) { throw new Error(`Referenced path "${obj[key]}" doesn't exist in spec.`); } let type; let schemaName; // set the type of the reference: to "schemas", to "parameters", or to "responses" // in case of a reference to a property, type is set to "schemas". if (refPath[refPath.length - 2] !== 'properties') { // if ref path is like: #/components/schemas/A type = refPath[refPath.length - 2]; } else { // or, if ref path is like: #/components/schemas/A/properties/a type = refPath[refPath.length - 4]; } if (['schemas', 'parameters', 'responses'].includes(refPath[refPath.length - 2])) { // in case of a ref like #/components/schemas/A or #/components/parameters/A or #/components/responses/A // schema name is the last element of the path schemaName = refPath[refPath.length - 1]; } else if (refPath[refPath.length - 2] === 'properties') { // in case of a ref like #/components/schemas/A/properties/B // schema name is A, then: schemaName = refPath[refPath.length - 3]; } if (['schemas', 'parameters', 'responses'].includes(type)) { // check if the reference points to an existing path in spec if (!getProperty(spec, refPath.slice(1).join('.'))) { throw new Error(`Referenced path "${obj[key]}" doesn't exist in spec.`); } else { occurrences[type][schemaName]['count'] += 1; occurrences[type][schemaName]['referencedBy'].push(currentPath.slice(0, 3).join('.')); } } } const prop = obj[key]; // recursively find and count... if (prop && typeof prop === 'object') { if (!Array.isArray(prop)) { findAndCount(prop, currentPath); } else { // the property value is an array for (let i = 0; i < prop.length; i++) { findAndCount(prop[i], currentPath); } } } } })(spec); // delete unreferenced elements from openapi spec spec = deleteUnreferencedElements(occurrences, spec); } catch (error) { throw new Error('Error removing unreferenced schemas. Check openapi spec document and schemas. ' + error.message); } return spec; } function deleteUnreferencedElements(occurrences, spec) { spec = deleteUnreferencedSchemas(occurrences, spec); spec = deleteUnreferencedParameters(occurrences, spec); return spec; } function deleteUnreferencedSchemas(occurrences, spec) { // remove all schemas with count === 0 in occurrences table let toDelete = Object.keys(_.pickBy(occurrences['schemas'], (value) => value.count === 0)); for (const key of toDelete) { // current schema path to delete from spec const pathToDelete = `components.schemas.${key}`; // first, find if the current schema is referenced by other schemas const referencedBy = _.pickBy(occurrences['schemas'], (value, key) => _.find(value.referencedBy, (value) => value === pathToDelete)); if (referencedBy && !_.isEmpty(referencedBy)) { // in case of existing references to the current schema const referencedByKey = Object.keys(referencedBy)[0]; const referencedByPath = `components.schemas.${referencedByKey}`; if (referencedBy[referencedByKey].count - 1 === 0) { // current schema was the last reference in the other schema referencing it, then remove also that schema deleteProperty(spec, referencedByPath); } else { // or, update the referencing schema removing the current schema: // remove the entry from the referencedBy array in occurrences table _.remove(referencedBy[referencedByKey].referencedBy, (v) => v === referencedByPath); // update occurrence to save in occurrences table const updatedOccurrence = { count: referencedBy[referencedByKey].count - 1, referencedBy: referencedBy[referencedByKey].referencedBy, }; setProperty(occurrences['schemas'], referencedByKey, updatedOccurrence); } } // finally, delete the current schema with count === 0 deleteProperty(spec, pathToDelete); } return spec; } function deleteUnreferencedParameters(occurrences, spec) { const toDelete = Object.keys(_.pickBy(occurrences['parameters'], (value) => value.count === 0)); for (const paramName of toDelete) { deleteProperty(spec, `components.parameters.${paramName}`); } return spec; } export function checkAbility(ability, resource, action, data, filterFields, filterData) { if (!data) { ForbiddenError.from(ability).throwUnlessCan(action, resource); return undefined; } else { const fieldsCache = new Map(); function innerCheckAbility(data, path) { let foundOne = false; if (typeof data === 'object' && data !== null && !ObjectId.isValid(data) && !(data instanceof Date)) { if (Array.isArray(data)) { data = data.filter((i) => { try { innerCheckAbility(i, path); return true; } catch (err) { if (filterFields) { return false; } else { API.fireError(403, 'insufficient privileges', undefined, err); } } }); foundOne = !!data.length; } else { Object.entries(data).forEach(([key, value]) => { try { data[key] = innerCheckAbility(value, (path || []).concat(key)); foundOne = true; } catch (err) { if (filterFields) { delete data[key]; } else { API.fireError(403, 'insufficient privileges', undefined, err); } } }); } } if (path && !foundOne) { const pathAsString = path.join('.'); let isPermitted = fieldsCache.get(pathAsString); if (typeof isPermitted === 'undefined') { isPermitted = ability.can(action, resource, pathAsString); fieldsCache.set(pathAsString, isPermitted); } if (!isPermitted) { throw ForbiddenError.from(ability); } } return data; } if (filterData) { if (Array.isArray(data)) { data = data.filter((i) => ability.can(action, subject(resource, i))); } else if (!ability.can(action, subject(resource, data))) { return undefined; } } return innerCheckAbility(data); } } export function toCSV(data, options) { let out = []; let fieldMap; if (Array.isArray(options.fields)) { fieldMap = options.fields.reduce((o, i) => { o[i] = i; return o; }, {}); } else { fieldMap = options.fields; } if (options.header) { out.push(Object.values(fieldMap)); } data.forEach((originalItem) => { const unwound = []; if (options.unwind) { if (options.unwind[0] === '!') { options.forceUnwind = true; options.unwind = options.unwind.substring(1); } if (originalItem[options.unwind]?.length) { originalItem[options.unwind].forEach((i) => { unwound.push({ ...originalItem, [options.unwind]: i, }); }); } else if (options.forceUnwind) { originalItem[options.unwind] = undefined; unwound.push(originalItem); } } else { unwound.push(originalItem); } unwound.forEach((item) => { const l = []; // TODO optimize with a transversal map for (let k in fieldMap) { const value = dot(item, k); if (options.decimal && typeof value === 'number') { l.push(value.toString().replace('.', options.decimal)); } else if (value instanceof Date) { l.push(options.dateFormat ? DateTime.fromJSDate(value) .setZone(options.timezone || 'UTC') .toFormat(options.dateFormat) : value.toISOString()); } else { l.push((typeof value !== 'undefined' && value !== null ? value : '').toString()); } } out.push(l); }); }); return out .map((l) => l.map((f) => (options.quotes ? `"${f.replace('"', `${options.escape || '\\'}"`)}"` : f)).join(options.separator || ',')) .join(options.eol || '\n'); } //# sourceMappingURL=util.js.map