From dd61fc8262b3c8461dd96d8153ff0132c5513e8f Mon Sep 17 00:00:00 2001 From: Aurelsicoko Date: Fri, 30 Mar 2018 17:05:24 +0200 Subject: [PATCH] Add description and execute policies before the resolver --- .../config/settings.json | 3 +- .../hooks/graphql/index.js | 51 ++++++- packages/strapi-plugin-graphql/package.json | 3 +- .../strapi-plugin-graphql/services/GraphQL.js | 137 ++++++++++++++++-- packages/strapi-utils/lib/index.js | 1 + packages/strapi-utils/lib/policy.js | 84 +++++++++++ .../middlewares/router/utils/routerChecker.js | 85 +---------- packages/strapi/lib/utils/index.js | 6 +- 8 files changed, 272 insertions(+), 98 deletions(-) create mode 100644 packages/strapi-utils/lib/policy.js diff --git a/packages/strapi-plugin-graphql/config/settings.json b/packages/strapi-plugin-graphql/config/settings.json index 64de847c61..4fa039be1e 100644 --- a/packages/strapi-plugin-graphql/config/settings.json +++ b/packages/strapi-plugin-graphql/config/settings.json @@ -1,3 +1,4 @@ { - "endpoint": "/graphql" + "endpoint": "/graphql", + "shadowCRUD": true } diff --git a/packages/strapi-plugin-graphql/hooks/graphql/index.js b/packages/strapi-plugin-graphql/hooks/graphql/index.js index 86f85ac5ff..4a6939ed49 100644 --- a/packages/strapi-plugin-graphql/hooks/graphql/index.js +++ b/packages/strapi-plugin-graphql/hooks/graphql/index.js @@ -6,14 +6,59 @@ // Public node modules. const _ = require('lodash'); +const path = require('path'); +const glob = require('glob'); const { graphqlKoa, graphiqlKoa } = require('apollo-server-koa'); module.exports = strapi => { return { - beforeInitialize: function() { + beforeInitialize: async function() { // Try to inject this hook just after the others hooks to skip the router processing. strapi.config.hook.load.order = strapi.config.hook.load.order.concat(Object.keys(strapi.hook).filter(hook => hook !== 'graphql')); strapi.config.hook.load.order.push('graphql'); + + // Load core utils. + const utils = require(path.resolve(strapi.config.appPath, 'node_modules', 'strapi', 'lib', 'utils')); + + // Set '*.graphql' files configurations in the global variable. + await Promise.all([ + // Load root configurations. + new Promise((resolve, reject) => { + glob('./config/**/*.*(graphql)', { + cwd: strapi.config.appPath + }, (err, files) => { + if (err) { + return reject(err); + } + + utils.loadConfig.call(strapi, files, true).then(resolve).catch(reject); + }); + }), + // Load APIs configurations. + new Promise((resolve, reject) => { + glob('./api/*/config/**/*.*(graphql)', { + cwd: strapi.config.appPath + }, (err, files) => { + if (err) { + return reject(err); + } + + utils.loadConfig.call(strapi, files, true).then(resolve).catch(reject); + }); + }), + // Load plugins configurations. + new Promise((resolve, reject) => { + glob('./plugins/*/config/!(generated)/*.*(graphql)', { + cwd: strapi.config.appPath + }, (err, files) => { + if (err) { + return reject(err); + } + + utils.loadConfig.call(strapi, files, true).then(resolve).catch(reject); + }); + }) + ]); }, initialize: function(cb) { @@ -27,8 +72,8 @@ module.exports = strapi => { const router = strapi.koaMiddlewares.routerJoi(); - router.post(strapi.plugins.graphql.config.endpoint, graphqlKoa({ schema })); - router.get(strapi.plugins.graphql.config.endpoint, graphqlKoa({ schema })); + router.post(strapi.plugins.graphql.config.endpoint, async (ctx, next) => graphqlKoa({ schema, context: ctx })(ctx, next)); + router.get(strapi.plugins.graphql.config.endpoint, async (ctx, next) => graphqlKoa({ schema, context: ctx })(ctx, next)); router.get('/graphiql', graphiqlKoa({ endpointURL: strapi.plugins.graphql.config.endpoint })); diff --git a/packages/strapi-plugin-graphql/package.json b/packages/strapi-plugin-graphql/package.json index 53b59fe096..ae1b2ef7b4 100644 --- a/packages/strapi-plugin-graphql/package.json +++ b/packages/strapi-plugin-graphql/package.json @@ -26,7 +26,8 @@ "apollo-server-koa": "^1.3.3", "graphql": "^0.13.2", "graphql-tools": "^2.23.1", - "pluralize": "^7.0.0" + "pluralize": "^7.0.0", + "strapi-utils": "3.0.0-alpha.11.1" }, "devDependencies": { "strapi-helper-plugin": "3.0.0-alpha.11.1" diff --git a/packages/strapi-plugin-graphql/services/GraphQL.js b/packages/strapi-plugin-graphql/services/GraphQL.js index 575d7440eb..06feb02bd1 100644 --- a/packages/strapi-plugin-graphql/services/GraphQL.js +++ b/packages/strapi-plugin-graphql/services/GraphQL.js @@ -6,16 +6,73 @@ * @description: A set of functions similar to controller's actions to avoid code duplication. */ +const policyUtils = require('strapi-utils').policy; + const fs = require('fs'); const path = require('path'); - const _ = require('lodash'); const pluralize = require('pluralize'); const { makeExecutableSchema } = require('graphql-tools'); module.exports = { - formatGQL: (str) => JSON.stringify(str, null, 2).replace(/['"]+/g, ''), + /** + * Receive an Object and return a string which is following the GraphQL specs. + * + * @return String + */ + + formatGQL: function (fields, description, type = 'field') { + const typeFields = JSON.stringify(fields, null, 2).replace(/['",]+/g, ''); + const lines = typeFields.split('\n'); + + // Try to add description for field. + if (type === 'field') { + return lines + .map((line, index) => { + if ([0, lines.length - 1].includes(index)) { + return line; + } + + const split = line.split(':'); + const attribute = _.trim(split[0]); + const info = description[attribute]; + + if (info) { + return ` """\n ${info}\n """\n${line}`; + } + + return line; + }) + .join('\n'); + } + + return typeFields; + }, + + /** + * Retrieve description from variable and return a string which follow the GraphQL specs. + * + * @return String + */ + + getDescription: (description) => { + const format = `"""\n`; + + const str = _.get(description, `_description`) || description; + + if (str) { + return `${format}${str}\n${format}`; + } + + return ``; + }, + + /** + * Convert Strapi type to GraphQL type. + * + * @return String + */ convertType: (type) => { switch (type) { @@ -24,11 +81,44 @@ module.exports = { return 'String'; case 'boolean': return 'Boolean'; + case 'integer': + return 'Int'; default: return 'String'; } }, + /** + * Execute policies before the specified resolver. + * + * @return Promise or Error. + */ + + composeResolver: async (context, plugin, policies = [], resolver) => { + const policiesFn = []; + + // Populate policies. + policies.forEach(policy => policyUtils.get(policy, plugin, policiesFn, 'GraphQL error')); + + // Execute policies stack. + const policy = await strapi.koaMiddlewares.compose(policiesFn)(context); + + // Policy doesn't always return errors but they update the current context. + if (_.isError(context.response.body) || _.get(context.response.body, 'isBoom')) { + return context.response.body; + } + + // When everything is okay, the policy variable should be undefined + // so it will return the resolver instead. + return policy || resolver; + }, + + /** + * Construct the GraphQL query & definition and apply the right resolvers. + * + * @return Object + */ + shadowCRUD: function (models) { const initialState = { definition: ``, query: {}, resolver: {} }; // Retrieve generic service from the Content Manager plugin. @@ -39,11 +129,12 @@ module.exports = { } return models.reduce((acc, model) => { + const plugin = undefined; const params = { model }; - const query = {}; + const queryOpts = {}; // Setup initial state with default attribute that should be displayed // but these attributes are not properly defined in the models. @@ -59,6 +150,11 @@ module.exports = { }); } + const globalId = strapi.models[model].globalId; + + // Retrieve user customisation. + const { resolver = {}, query, definition, _type = {} } = _.get(strapi.api, `${model}.config.schema.graphql`, {}); + // Convert our layer Model to the GraphQL DL. const attributes = Object.keys(strapi.models[model].attributes) .reduce((acc, attribute) => { @@ -68,7 +164,7 @@ module.exports = { return acc; }, initialState); - acc.definition += `type ${strapi.models[model].globalId} ${this.formatGQL(attributes)}\n\n`; + acc.definition += `${this.getDescription(_type[globalId])}type ${globalId} ${this.formatGQL(attributes, _type[globalId])}\n\n`; Object.assign(acc.query, { [`${pluralize.plural(model)}`]: `[${strapi.models[model].globalId}]`, @@ -76,23 +172,38 @@ module.exports = { }); // TODO - // - Apply and execute policies first. // - Handle mutations. Object.assign(acc.resolver, { - [`${pluralize.plural(model)}`]: (_, options) => resolvers.fetchAll(params, {...query, ...options}), - [`${pluralize.singular(model)}`]: (_, { id }) => resolvers.fetch({ ...params, id }, query) + [pluralize.plural(model)]: (obj, options, context) => this.composeResolver( + context, + plugin, + _.get(resolver, `Query.${pluralize.plural(model)}.policy`), + resolvers.fetchAll(params, {...queryOpts, ...options}) + ), + [pluralize.singular(model)]: (obj, { id }, context) => this.composeResolver( + context, + plugin, + _.get(resolver, `Query.${pluralize.singular(model)}.policy`), + resolvers.fetch({ ...params, id }, queryOpts) + ) }); return acc; }, initialState); }, + /** + * Generate GraphQL schema. + * + * @return Schema + */ + generateSchema: function () { // Exclude core models. const models = Object.keys(strapi.models).filter(model => model !== 'core_store'); // Generate type definition and query/mutation for models. - const shadowCRUD = true ? this.shadowCRUD(models) : {}; + const shadowCRUD = strapi.plugins.graphql.config.shadowCRUD !== false ? this.shadowCRUD(models) : {}; // Build resolvers. const resolvers = { @@ -105,7 +216,9 @@ module.exports = { } // Concatenate. - const typeDefs = shadowCRUD.definition + `type Query ${this.formatGQL(shadowCRUD.query)}`; + const typeDefs = shadowCRUD.definition + `type Query ${this.formatGQL(shadowCRUD.query, {}, 'query')}`; + + console.log(typeDefs); // Write schema. this.writeGenerateSchema(typeDefs); @@ -119,6 +232,12 @@ module.exports = { return schema; }, + /** + * Save into a file the readable GraphQL schema. + * + * @return void + */ + writeGenerateSchema(schema) { // Disable auto-reload. strapi.reload.isWatching = false; diff --git a/packages/strapi-utils/lib/index.js b/packages/strapi-utils/lib/index.js index d94298a129..d44e888a3d 100755 --- a/packages/strapi-utils/lib/index.js +++ b/packages/strapi-utils/lib/index.js @@ -13,5 +13,6 @@ module.exports = { knex: require('./knex'), logger: require('./logger'), models: require('./models'), + policy: require('./policy'), regex: require('./regex') }; diff --git a/packages/strapi-utils/lib/policy.js b/packages/strapi-utils/lib/policy.js new file mode 100644 index 0000000000..2c31942043 --- /dev/null +++ b/packages/strapi-utils/lib/policy.js @@ -0,0 +1,84 @@ + +// Public dependencies. +const _ = require('lodash'); + +module.exports = { + get: (policy, plugin, policies = [], endpoint) => { + // Define global policy prefix. + const globalPolicyPrefix = 'global.'; + const pluginPolicyPrefix = 'plugins.'; + const policySplited = policy.split('.'); + + // Looking for global policy or namespaced. + if ( + _.startsWith(policy, globalPolicyPrefix, 0) && + !_.isEmpty( + strapi.config.policies, + policy.replace(globalPolicyPrefix, '') + ) + ) { + // Global policy. + return policies.push( + strapi.config.policies[ + policy.replace(globalPolicyPrefix, '').toLowerCase() + ] + ); + } else if ( + _.startsWith(policy, pluginPolicyPrefix, 0) && + strapi.plugins[policySplited[1]] && + !_.isUndefined( + _.get( + strapi.plugins, + policySplited[1] + + '.config.policies.' + + policySplited[2].toLowerCase() + ) + ) + ) { + // Plugin's policies can be used from app APIs with a specific syntax (`plugins.pluginName.policyName`). + return policies.push( + _.get( + strapi.plugins, + policySplited[1] + + '.config.policies.' + + policySplited[2].toLowerCase() + ) + ); + } else if ( + !_.startsWith(policy, globalPolicyPrefix, 0) && + plugin && + !_.isUndefined( + _.get( + strapi.plugins, + plugin + '.config.policies.' + policy.toLowerCase() + ) + ) + ) { + // Plugin policy used in the plugin itself. + return policies.push( + _.get( + strapi.plugins, + plugin + '.config.policies.' + policy.toLowerCase() + ) + ); + } else if ( + !_.startsWith(policy, globalPolicyPrefix, 0) && + !_.isUndefined( + _.get( + strapi.api, + currentApiName + '.config.policies.' + policy.toLowerCase() + ) + ) + ) { + // API policy used in the API itself. + return policies.push( + _.get( + strapi.api, + currentApiName + '.config.policies.' + policy.toLowerCase() + ) + ); + } + + strapi.log.error(`Ignored attempt to bind route ${endpoint} with unknown policy ${policy}`); + } +}; diff --git a/packages/strapi/lib/middlewares/router/utils/routerChecker.js b/packages/strapi/lib/middlewares/router/utils/routerChecker.js index 2ae0fc38c3..61f8cab66b 100755 --- a/packages/strapi/lib/middlewares/router/utils/routerChecker.js +++ b/packages/strapi/lib/middlewares/router/utils/routerChecker.js @@ -11,7 +11,7 @@ const _ = require('lodash'); const finder = require('strapi-utils').finder; const regex = require('strapi-utils').regex; const joijson = require('strapi-utils').joijson; - +const policyUtils = require('strapi-utils').policy; // Middleware used for every routes. // Expose the endpoint in `this`. @@ -67,88 +67,7 @@ module.exports = strapi => function routerChecker(value, endpoint, plugin) { !_.isEmpty(_.get(value, 'config.policies')) ) { _.forEach(value.config.policies, policy => { - // Define global policy prefix. - const globalPolicyPrefix = 'global.'; - const pluginPolicyPrefix = 'plugins.'; - const policySplited = policy.split('.'); - - // Looking for global policy or namespaced. - if ( - _.startsWith(policy, globalPolicyPrefix, 0) && - !_.isEmpty( - strapi.config.policies, - policy.replace(globalPolicyPrefix, '') - ) - ) { - // Global policy. - return policies.push( - strapi.config.policies[ - policy.replace(globalPolicyPrefix, '').toLowerCase() - ] - ); - } else if ( - _.startsWith(policy, pluginPolicyPrefix, 0) && - strapi.plugins[policySplited[1]] && - !_.isUndefined( - _.get( - strapi.plugins, - policySplited[1] + - '.config.policies.' + - policySplited[2].toLowerCase() - ) - ) - ) { - // Plugin's policies can be used from app APIs with a specific syntax (`plugins.pluginName.policyName`). - return policies.push( - _.get( - strapi.plugins, - policySplited[1] + - '.config.policies.' + - policySplited[2].toLowerCase() - ) - ); - } else if ( - !_.startsWith(policy, globalPolicyPrefix, 0) && - plugin && - !_.isUndefined( - _.get( - strapi.plugins, - plugin + '.config.policies.' + policy.toLowerCase() - ) - ) - ) { - // Plugin policy used in the plugin itself. - return policies.push( - _.get( - strapi.plugins, - plugin + '.config.policies.' + policy.toLowerCase() - ) - ); - } else if ( - !_.startsWith(policy, globalPolicyPrefix, 0) && - !_.isUndefined( - _.get( - strapi.api, - currentApiName + '.config.policies.' + policy.toLowerCase() - ) - ) - ) { - // API policy used in the API itself. - return policies.push( - _.get( - strapi.api, - currentApiName + '.config.policies.' + policy.toLowerCase() - ) - ); - } - - strapi.log.error( - 'Ignored attempt to bind route `' + - endpoint + - '` with unknown policy `' + - policy + - '`.' - ); + policyUtils.get(policy, plugin, policies, endpoint); }); } diff --git a/packages/strapi/lib/utils/index.js b/packages/strapi/lib/utils/index.js index 1be9480652..37672718fa 100755 --- a/packages/strapi/lib/utils/index.js +++ b/packages/strapi/lib/utils/index.js @@ -71,8 +71,12 @@ module.exports = { .toLowerCase(); }, - loadConfig: function(files) { + loadConfig: function(files, shouldBeAggregated = false) { const aggregate = files.filter(p => { + if (shouldBeAggregated) { + return true; + } + if (intersection(p.split('/').map(p => p.replace('.json', '')), ['environments', 'database', 'security', 'request', 'response', 'server']).length === 2) { return true; }