From 23ee9c9ebe64c27d567776225b88e8cd5c4c78fc Mon Sep 17 00:00:00 2001 From: Alexandre Bodin Date: Mon, 25 Nov 2019 11:30:49 +0100 Subject: [PATCH] Clear API with rollback system --- .../services/ContentTypes.js | 133 +++++---------- .../services/clear-api.js | 159 ++++++++++++++++++ .../services/schema-manager/schema-builder.js | 38 +++-- .../services/schema-manager/schema-handler.js | 6 +- .../utils/attributes.js | 8 +- 5 files changed, 239 insertions(+), 105 deletions(-) create mode 100644 packages/strapi-plugin-content-type-builder/services/clear-api.js diff --git a/packages/strapi-plugin-content-type-builder/services/ContentTypes.js b/packages/strapi-plugin-content-type-builder/services/ContentTypes.js index 5685af1a36..667eed261b 100644 --- a/packages/strapi-plugin-content-type-builder/services/ContentTypes.js +++ b/packages/strapi-plugin-content-type-builder/services/ContentTypes.js @@ -1,15 +1,34 @@ 'use strict'; const _ = require('lodash'); -const path = require('path'); -const fse = require('fs-extra'); const pluralize = require('pluralize'); const generator = require('strapi-generate'); -const { formatAttributes } = require('../utils/attributes'); const getSchemaManager = require('./schema-manager'); +const apiCleaner = require('./clear-api'); +const { formatAttributes } = require('../utils/attributes'); const { nameToSlug } = require('../utils/helpers'); +/** + * Format a contentType info to be used by the front-end + * @param {Object} contentType + */ +const formatContentType = contentType => { + const { uid, plugin, connection, collectionName, info } = contentType; + + return { + uid, + plugin, + schema: { + name: _.get(info, 'name') || _.upperFirst(pluralize(uid)), + description: _.get(info, 'description', ''), + connection, + collectionName, + attributes: formatAttributes(contentType), + }, + }; +}; + /** * Creates a component and handle the nested components sent with it * @param {Object} params params object @@ -34,9 +53,8 @@ const createContentType = async ({ contentType, components = [] }) => { }; /** - * Generate a squeleton API - * @param {*} name - * @param {*} contentType + * Generate an API squeleton + * @param {string} name */ const generateAPI = name => { return new Promise((resolve, reject) => { @@ -80,96 +98,31 @@ const editContentType = (uid, { contentType, components = [] }) => { }); }; +/** + * Deletes a content type and the api files related to it + * @param {string} uid content type uid + */ const deleteContentType = async uid => { - const compo = await getSchemaManager().edit(ctx => - ctx.deleteContentType(uid) - ); + // make a backup + await apiCleaner.backup(uid); - try { - await clearApiFolder(uid); - return compo; - } catch (err) { - throw new Error(`Error clearing the api folder ${err.message}`); - } -}; + return getSchemaManager().edit(async ctx => { + const component = ctx.deleteContentType(uid); -const clearApiFolder = async uid => { - const { apiName, __filename__ } = strapi.contentTypes[uid]; + try { + await ctx.flush(); + await apiCleaner.clear(uid); + } catch (error) { + await ctx.rollback(); + await apiCleaner.rollback(uid); - const baseName = path.basename(__filename__, '.settings.json'); - const apiFolder = path.join(strapi.dir, 'api', apiName); - - const deleteFile = async filePath => { - const fileName = path.basename(filePath); - - if (_.startsWith(_.toLower(fileName), _.toLower(baseName) + '.')) { - await fse.remove(filePath); + throw new Error( + `Error delete ContentType: ${error.message}. Changes were rollbacked` + ); } - if (fileName === 'routes.json') { - const { routes } = await fse.readJSON(filePath); - - const clearedRoutes = routes.filter(route => { - return !_.startsWith( - _.toLower(route.handler), - _.toLower(baseName) + '.' - ); - }); - - if (clearedRoutes.length === 0) { - await fse.remove(filePath); - } else { - await fse.writeJSON( - filePath, - { - routes: clearedRoutes, - }, - { - spaces: 2, - } - ); - } - } - }; - - const recursiveRemoveFiles = async folder => { - const filesName = await fse.readdir(folder); - - for (const fileName of filesName) { - const filePath = path.join(folder, fileName); - - const stat = await fse.stat(filePath); - - if (stat.isDirectory()) { - await recursiveRemoveFiles(filePath); - } else { - await deleteFile(filePath); - } - } - - const files = await fse.readdir(folder); - if (files.length === 0) { - await fse.remove(folder); - } - }; - - await recursiveRemoveFiles(apiFolder); -}; - -const formatContentType = contentType => { - const { uid, plugin, connection, collectionName, info } = contentType; - - return { - uid, - plugin, - schema: { - name: _.get(info, 'name') || _.upperFirst(pluralize(uid)), - description: _.get(info, 'description', ''), - connection, - collectionName, - attributes: formatAttributes(contentType), - }, - }; + return component; + }); }; module.exports = { diff --git a/packages/strapi-plugin-content-type-builder/services/clear-api.js b/packages/strapi-plugin-content-type-builder/services/clear-api.js new file mode 100644 index 0000000000..38451e02b5 --- /dev/null +++ b/packages/strapi-plugin-content-type-builder/services/clear-api.js @@ -0,0 +1,159 @@ +'use strict'; + +const path = require('path'); +const fse = require('fs-extra'); +const _ = require('lodash'); + +/** + * Deletes the API folder of a contentType + * @param {string} uid content type uid + */ +async function clear(uid) { + const { apiName, __filename__ } = strapi.contentTypes[uid]; + + const apiFolder = path.join(strapi.dir, 'api', apiName); + + // base name of the model file that will be use as comparator + const baseName = path.basename(__filename__, '.settings.json'); + + await recursiveRemoveFiles(apiFolder, createDeleteApiFunction(baseName)); + await deleteBackup(uid); +} + +/** + * Backups the API folder of a contentType + * @param {string} uid content type uid + */ +async function backup(uid) { + const { apiName } = strapi.contentTypes[uid]; + + const apiFolder = path.join(strapi.dir, 'api', apiName); + const backupFolder = path.join(strapi.dir, 'api', '.backup', apiName); + + // backup the api folder + await fse.copy(apiFolder, backupFolder); +} + +/** + * Deletes an API backup folder + * @param {string} uid content type uid + */ +async function deleteBackup(uid) { + const { apiName } = strapi.contentTypes[uid]; + + const backupFolder = path.join(strapi.dir, 'api', '.backup'); + const apiBackupFolder = path.join(strapi.dir, 'api', '.backup', apiName); + + await fse.remove(apiBackupFolder); + + const list = await fse.readdir(backupFolder); + if (list.length === 0) { + await fse.remove(backupFolder); + } +} + +/** + * Rollbacks the API folder of a contentType + * @param {string} uid content type uid + */ +async function rollback(uid) { + const { apiName } = strapi.contentTypes[uid]; + + const apiFolder = path.join(strapi.dir, 'api', apiName); + const backupFolder = path.join(strapi.dir, 'api', '.backup', apiName); + + const exists = await fse.exists(backupFolder); + + if (!exists) { + throw new Error('Cannot rollback api that was not backed up'); + } + + await fse.remove(apiFolder); + await fse.copy(backupFolder, apiFolder); + await deleteBackup(uid); +} + +/** + * Creates a delete function to clear an api folder + * @param {str} baseName + */ +const createDeleteApiFunction = baseName => { + /** + * Delets a file in an api. + * Will only update routes.json instead of deleting it if other routes are present + * @param {string} filePath file path to delete + */ + return async filePath => { + const fileName = path.basename(filePath); + const startWithBaseName = startWithName(baseName + '.'); + + if (startWithBaseName(fileName)) return fse.remove(filePath); + + if (fileName === 'routes.json') { + const { routes } = await fse.readJSON(filePath); + + const routesToKeep = routes.filter( + route => !startWithBaseName(route.handler) + ); + + if (routesToKeep.length === 0) { + return fse.remove(filePath); + } + + await fse.writeJSON( + filePath, + { + routes: routesToKeep, + }, + { + spaces: 2, + } + ); + } + }; +}; + +/** + * Returns a function that checks if the passed string starts with the name + * @param {string} prefix + * @returns {Function} a comparing function + */ +const startWithName = prefix => { + /** + * Checks if str starts with prefix case insensitive + * @param {string} str string to compare + */ + return str => _.startsWith(_.toLower(str), _.toLower(prefix)); +}; + +/** + * Deletes a folder recursively using a delete function + * @param {string} folder folder to delete + * @param {Function} deleteFn function to call with the file path to delete + */ +const recursiveRemoveFiles = async (folder, deleteFn) => { + const filesName = await fse.readdir(folder); + + for (const fileName of filesName) { + const filePath = path.join(folder, fileName); + + const stat = await fse.stat(filePath); + + if (stat.isDirectory()) { + await recursiveRemoveFiles(filePath, deleteFn); + } else { + await deleteFn(filePath); + } + } + + const files = await fse.readdir(folder); + if (files.length === 0) { + await fse.remove(folder); + } +}; + +module.exports = { + clear, + backup, + rollback, +}; diff --git a/packages/strapi-plugin-content-type-builder/services/schema-manager/schema-builder.js b/packages/strapi-plugin-content-type-builder/services/schema-manager/schema-builder.js index 5b5228be99..efadf42413 100644 --- a/packages/strapi-plugin-content-type-builder/services/schema-manager/schema-builder.js +++ b/packages/strapi-plugin-content-type-builder/services/schema-manager/schema-builder.js @@ -8,6 +8,9 @@ module.exports = function createSchemaBuilder({ components, contentTypes }) { const tmpComponents = new Map(); const tmpContentTypes = new Map(); + let flushed = false; + let rollbacked = false; + // init temporary ContentTypes Object.keys(contentTypes).forEach(key => { tmpContentTypes.set( @@ -36,20 +39,31 @@ module.exports = function createSchemaBuilder({ components, contentTypes }) { ...createContentTypeBuilder({ tmpComponents, tmpContentTypes }), flush() { - return Promise.all( - [ - ...Array.from(tmpComponents.values()), - ...Array.from(tmpContentTypes.values()), - ].map(schema => schema.flush()) - ); + if (!flushed) { + flushed = true; + + return Promise.all( + [ + ...Array.from(tmpComponents.values()), + ...Array.from(tmpContentTypes.values()), + ].map(schema => schema.flush()) + ); + } + + return Promise.resolve(); }, rollback() { - return Promise.all( - [ - ...Array.from(tmpComponents.values()), - ...Array.from(tmpContentTypes.values()), - ].map(schema => schema.rollback()) - ); + if (!rollbacked) { + rollbacked = true; + return Promise.all( + [ + ...Array.from(tmpComponents.values()), + ...Array.from(tmpContentTypes.values()), + ].map(schema => schema.rollback()) + ); + } + + return Promise.resolve(); }, }; diff --git a/packages/strapi-plugin-content-type-builder/services/schema-manager/schema-handler.js b/packages/strapi-plugin-content-type-builder/services/schema-manager/schema-handler.js index dc46e17ac3..e7fa842580 100644 --- a/packages/strapi-plugin-content-type-builder/services/schema-manager/schema-handler.js +++ b/packages/strapi-plugin-content-type-builder/services/schema-manager/schema-handler.js @@ -3,6 +3,7 @@ const path = require('path'); const fse = require('fs-extra'); const _ = require('lodash'); +const { toUID } = require('../../utils/attributes'); module.exports = function createSchemaHandler(infos) { const { modelName, plugin, uid, dir, filename, schema } = infos; @@ -96,8 +97,11 @@ module.exports = function createSchemaHandler(infos) { Object.keys(attributes).forEach(key => { const attr = attributes[key]; const target = attr.model || attr.collection; + const plugin = attr.plugin; - if (target === uid) { + const relationUID = toUID(target, plugin); + + if (relationUID === uid) { this.unset(['attributes', key]); } }); diff --git a/packages/strapi-plugin-content-type-builder/utils/attributes.js b/packages/strapi-plugin-content-type-builder/utils/attributes.js index 268034cc4a..f7dcd71112 100644 --- a/packages/strapi-plugin-content-type-builder/utils/attributes.js +++ b/packages/strapi-plugin-content-type-builder/utils/attributes.js @@ -6,8 +6,12 @@ const MODEL_RELATIONS = ['oneWay', 'oneToOne', 'manyToOne']; const COLLECTION_RELATIONS = ['manyWay', 'manyToMany', 'oneToMany']; const toUID = (name, plugin) => { - const model = strapi.getModel(name, plugin); - return model.uid; + const modelUID = Object.keys(strapi.contentTypes).find(key => { + const ct = strapi.contentTypes[key]; + if (ct.modelName === name && ct.plugin === plugin) return true; + }); + + return modelUID; }; const fromUID = uid => {