Jakub Skořepa b7edcffff8
Fix the fix
It turns out that in some situations it actually is only the id instead of object (nested queries).
2018-08-08 00:05:33 +02:00

857 lines
26 KiB
JavaScript

'use strict';
/**
* GraphQL.js service
*
* @description: A set of functions similar to controller's actions to avoid code duplication.
*/
const fs = require('fs');
const path = require('path');
const _ = require('lodash');
const pluralize = require('pluralize');
const graphql = require('graphql');
const { makeExecutableSchema } = require('graphql-tools');
const GraphQLJSON = require('graphql-type-json');
const GraphQLDateTime = require('graphql-type-datetime');
const policyUtils = require('strapi-utils').policy;
module.exports = {
/**
* Receive an Object and return a string which is following the GraphQL specs.
*
* @return String
*/
formatGQL: function (fields, description = {}, model = {}, 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 => {
if (['{', '}'].includes(line)) {
return '';
}
const split = line.split(':');
const attribute = _.trim(split[0]);
const info = (_.isString(description[attribute]) ? description[attribute] : _.get(description[attribute], 'description')) || _.get(model, `attributes.${attribute}.description`);
const deprecated = _.get(description[attribute], 'deprecated') || _.get(model, `attributes.${attribute}.deprecated`);
// Snakecase an attribute when we find a dash.
if (attribute.indexOf('-') !== -1) {
line = ` ${_.snakeCase(attribute)}: ${_.trim(split[1])}`;
}
if (info) {
line = ` """\n ${info}\n """\n${line}`;
}
if (deprecated) {
line = `${line} @deprecated(reason: "${deprecated}")`;
}
return line;
})
.join('\n');
} else if (type === 'query') {
return lines
.map((line, index) => {
if (['{', '}'].includes(line)) {
return '';
}
const split = Object.keys(fields)[index - 1].split('(');
const attribute = _.trim(split[0]);
const info = _.get(description[attribute], 'description');
const deprecated = _.get(description[attribute], 'deprecated');
// Snakecase an attribute when we find a dash.
if (attribute.indexOf('-') !== -1) {
line = ` ${_.snakeCase(attribute)}(${_.trim(split[1])}`;
}
if (info) {
line = ` """\n ${info}\n """\n${line}`;
}
if (deprecated) {
line = `${line} @deprecated(reason: "${deprecated}")`;
}
return line;
})
.join('\n');
}
return lines
.map((line, index) => {
if ([0, lines.length - 1].includes(index)) {
return '';
}
return line;
})
.join('\n');
},
/**
* Retrieve description from variable and return a string which follow the GraphQL specs.
*
* @return String
*/
getDescription: (description, model = {}) => {
const format = '"""\n';
const str = _.get(description, '_description') ||
_.isString(description) ? description : undefined ||
_.get(model, 'info.description');
if (str) {
return `${format}${str}\n${format}`;
}
return '';
},
convertToParams: (params) => {
return Object.keys(params).reduce((acc, current) => {
return Object.assign(acc, {
[`_${current}`]: params[current]
});
}, {});
},
/**
* Security to avoid infinite limit.
*
* @return String
*/
amountLimiting: (params) => {
if (params.limit && params.limit < 0) {
params.limit = 0;
} else if (params.limit && params.limit > 100) {
params.limit = 100;
}
return params;
},
/**
* Convert Strapi type to GraphQL type.
* @param {Object} attribute Information about the attribute.
* @param {Object} attribute.definition Definition of the attribute.
* @param {String} attribute.modelName Name of the model which owns the attribute.
* @param {String} attribute.attributeName Name of the attribute.
* @return String
*/
convertType: function ({ definition = {}, modelName = '', attributeName = '' }) {
// Type
if (definition.type) {
let type = 'String';
switch (definition.type) {
// TODO: Handle fields of type Array, Perhaps default to [Int] or [String] ...
case 'boolean':
type = 'Boolean';
break;
case 'integer':
type = 'Int';
break;
case 'float':
type = 'Float';
break;
case 'json':
type = 'JSON';
break;
case 'time':
case 'date':
case 'datetime':
case 'timestamp':
type = 'DateTime';
break;
case 'enumeration':
type = this.convertEnumType(definition, modelName, attributeName);
break;
}
if (definition.required) {
type += '!';
}
return type;
}
const ref = definition.model || definition.collection;
// Association
if (ref && ref !== '*') {
// Add bracket or not
const globalId = definition.plugin ?
strapi.plugins[definition.plugin].models[ref].globalId:
strapi.models[ref].globalId;
const plural = !_.isEmpty(definition.collection);
if (plural) {
return `[${globalId}]`;
}
return globalId;
}
return definition.model ? 'Morph' : '[Morph]';
},
/**
* Convert Strapi enumeration to GraphQL Enum.
* @param {Object} definition Definition of the attribute.
* @param {String} model Name of the model which owns the attribute.
* @param {String} field Name of the attribute.
* @return String
*/
convertEnumType: (definition, model, field) => definition.enumName ? definition.enumName : `ENUM_${model.toUpperCase()}_${field.toUpperCase()}`,
/**
* Execute policies before the specified resolver.
*
* @return Promise or Error.
*/
composeResolver: function (_schema, plugin, name, isSingular) {
const params = {
model: name
};
const queryOpts = plugin ? { source: plugin } : {}; // eslint-disable-line no-unused-vars
const model = plugin ?
strapi.plugins[plugin].models[name]:
strapi.models[name];
// Retrieve generic service from the Content Manager plugin.
const resolvers = strapi.plugins['content-manager'].services['contentmanager']; // eslint-disable-line no-unused-vars
// Extract custom resolver or type description.
const { resolver: handler = {} } = _schema;
let queryName;
if (isSingular === 'force') {
queryName = name;
} else {
queryName = isSingular ?
pluralize.singular(name):
pluralize.plural(name);
}
// Retrieve policies.
const policies = _.get(handler, `Query.${queryName}.policies`, []);
// Retrieve resolverOf.
const resolverOf = _.get(handler, `Query.${queryName}.resolverOf`, '');
const policiesFn = [];
// Boolean to define if the resolver is going to be a resolver or not.
let isController = false;
// Retrieve resolver. It could be the custom resolver of the user
// or the shadow CRUD resolver (aka Content-Manager).
const resolver = (() => {
// Try to retrieve custom resolver.
const resolver = _.get(handler, `Query.${queryName}.resolver`);
if (_.isString(resolver) || _.isPlainObject(resolver)) {
const { handler = resolver } = _.isPlainObject(resolver) ? resolver : {};
// Retrieve the controller's action to be executed.
const [ name, action ] = handler.split('.');
const controller = plugin ?
_.get(strapi.plugins, `${plugin}.controllers.${_.toLower(name)}.${action}`):
_.get(strapi.controllers, `${_.toLower(name)}.${action}`);
if (!controller) {
return new Error(`Cannot find the controller's action ${name}.${action}`);
}
// We're going to return a controller instead.
isController = true;
// Push global policy to make sure the permissions will work as expected.
policiesFn.push(
policyUtils.globalPolicy(undefined, {
handler: `${name}.${action}`
}, undefined, plugin)
);
// Return the controller.
return controller;
} else if (resolver) {
// Function.
return resolver;
}
// We're going to return a controller instead.
isController = true;
const controllers = plugin ? strapi.plugins[plugin].controllers : strapi.controllers;
// Try to find the controller that should be related to this model.
const controller = isSingular ?
_.get(controllers, `${name}.findOne`):
_.get(controllers, `${name}.find`);
if (!controller) {
return new Error(`Cannot find the controller's action ${name}.${isSingular ? 'findOne' : 'find'}`);
}
// Push global policy to make sure the permissions will work as expected.
// We're trying to detect the controller name.
policiesFn.push(
policyUtils.globalPolicy(undefined, {
handler: `${name}.${isSingular ? 'findOne' : 'find'}`
}, undefined, plugin)
);
// Make the query compatible with our controller by
// setting in the context the parameters.
if (isSingular) {
return async (ctx, next) => {
ctx.params = {
...params,
[model.primaryKey]: ctx.params.id
};
// Return the controller.
return controller(ctx, next);
};
}
// Plural.
return async (ctx, next) => {
ctx.params = this.amountLimiting(ctx.params);
ctx.query = Object.assign(
this.convertToParams(_.omit(ctx.params, 'where')),
ctx.params.where
);
return controller(ctx, next);
};
})();
// The controller hasn't been found.
if (_.isError(resolver)) {
return resolver;
}
// Force policies of another action on a custom resolver.
if (_.isString(resolverOf) && !_.isEmpty(resolverOf)) {
// Retrieve the controller's action to be executed.
const [ name, action ] = resolverOf.split('.');
const controller = plugin ?
_.get(strapi.plugins, `${plugin}.controllers.${_.toLower(name)}.${action}`):
_.get(strapi.controllers, `${_.toLower(name)}.${action}`);
if (!controller) {
return new Error(`Cannot find the controller's action ${name}.${action}`);
}
policiesFn[0] = policyUtils.globalPolicy(undefined, {
handler: `${name}.${action}`
}, undefined, plugin);
}
if (strapi.plugins['users-permissions']) {
policies.push('plugins.users-permissions.permissions');
}
// Populate policies.
policies.forEach(policy => policyUtils.get(policy, plugin, policiesFn, `GraphQL query "${queryName}"`, name));
return async (obj, options, context) => {
// Hack to be able to handle permissions for each query.
const ctx = Object.assign(_.clone(context), {
request: Object.assign(_.clone(context.request), {
graphql: null
})
});
// Execute policies stack.
const policy = await strapi.koaMiddlewares.compose(policiesFn)(ctx);
// Policy doesn't always return errors but they update the current context.
if (_.isError(ctx.request.graphql) || _.get(ctx.request.graphql, 'isBoom')) {
return ctx.request.graphql;
}
// Something went wrong in the policy.
if (policy) {
return policy;
}
// Resolver can be a function. Be also a native resolver or a controller's action.
if (_.isFunction(resolver)) {
context.query = this.convertToParams(options);
context.params = this.amountLimiting(options);
if (isController) {
const values = await resolver.call(null, context);
if (ctx.body) {
return ctx.body;
}
return values && values.toJSON ? values.toJSON() : values;
}
return resolver.call(null, obj, options, context);
}
// Resolver can be a promise.
return resolver;
};
},
/**
* Construct the GraphQL query & definition and apply the right resolvers.
*
* @return Object
*/
shadowCRUD: function (models, plugin) {
// Retrieve generic service from the Content Manager plugin.
const resolvers = strapi.plugins['content-manager'].services['contentmanager'];
const initialState = { definition: '', query: {}, resolver: { Query : {} } };
if (_.isEmpty(models)) {
return initialState;
}
return models.reduce((acc, name) => {
const model = plugin ?
strapi.plugins[plugin].models[name]:
strapi.models[name];
// Setup initial state with default attribute that should be displayed
// but these attributes are not properly defined in the models.
const initialState = {
[model.primaryKey]: 'ID!'
};
const globalId = model.globalId;
const _schema = _.cloneDeep(_.get(strapi.plugins, 'graphql.config._schema.graphql', {}));
if (!acc.resolver[globalId]) {
acc.resolver[globalId] = {};
}
// Add timestamps attributes.
if (_.get(model, 'options.timestamps') === true) {
Object.assign(initialState, {
createdAt: 'DateTime!',
updatedAt: 'DateTime!'
});
Object.assign(acc.resolver[globalId], {
createdAt: (obj, options, context) => { // eslint-disable-line no-unused-vars
return obj.createdAt || obj.created_at;
},
updatedAt: (obj, options, context) => { // eslint-disable-line no-unused-vars
return obj.updatedAt || obj.updated_at;
}
});
}
// Retrieve user customisation.
const { type = {}, resolver = {} } = _schema;
// Convert our layer Model to the GraphQL DL.
const attributes = Object.keys(model.attributes)
.filter(attribute => model.attributes[attribute].private !== true)
.reduce((acc, attribute) => {
// Convert our type to the GraphQL type.
acc[attribute] = this.convertType({
definition: model.attributes[attribute],
modelName: globalId,
attributeName: attribute,
});
return acc;
}, initialState);
// Detect enum and generate it for the schema definition
const enums = Object.keys(model.attributes)
.filter(attribute => model.attributes[attribute].type === 'enumeration')
.map((attribute) => {
const definition = model.attributes[attribute];
return `enum ${this.convertEnumType(definition, globalId, attribute)} { ${definition.enum.join(' \n ')} }`;
}).join(' ');
acc.definition += enums;
// Add parameters to optimize association query.
(model.associations || [])
.filter(association => association.type === 'collection')
.forEach(association => {
attributes[`${association.alias}(sort: String, limit: Int, start: Int, where: JSON)`] = attributes[association.alias];
delete attributes[association.alias];
});
acc.definition += `${this.getDescription(type[globalId], model)}type ${globalId} {${this.formatGQL(attributes, type[globalId], model)}}\n\n`;
// Add definition to the schema but this type won't be "queriable".
if (type[model.globalId] === false || _.get(type, `${model.globalId}.enabled`) === false) {
return acc;
}
// Build resolvers.
const queries = {
singular: _.get(resolver, `Query.${pluralize.singular(name)}`) !== false ? this.composeResolver(
_schema,
plugin,
name,
true
) : null,
plural: _.get(resolver, `Query.${pluralize.plural(name)}`) !== false ? this.composeResolver(
_schema,
plugin,
name,
false
) : null
};
// TODO:
// - Handle mutations.
Object.keys(queries).forEach(type => {
// The query cannot be built.
if (_.isError(queries[type])) {
console.error(queries[type]);
strapi.stop();
}
// Only create query if the function is available.
if (_.isFunction(queries[type])) {
if (type === 'singular') {
Object.assign(acc.query, {
[`${pluralize.singular(name)}(id: ID!)`]: model.globalId
});
} else {
Object.assign(acc.query, {
[`${pluralize.plural(name)}(sort: String, limit: Int, start: Int, where: JSON)`]: `[${model.globalId}]`
});
}
_.merge(acc.resolver.Query, {
[type === 'singular' ? pluralize.singular(name) : pluralize.plural(name)]: queries[type]
});
}
});
// Build associations queries.
(model.associations || []).forEach(association => {
switch (association.nature) {
case 'oneToManyMorph':
return _.merge(acc.resolver[globalId], {
[association.alias]: async (obj) => {
const withRelated = await resolvers.fetch({
id: obj[model.primaryKey],
model: name
}, plugin, [association.alias], false);
const entry = withRelated && withRelated.toJSON ? withRelated.toJSON() : withRelated;
// Set the _type only when the value is defined
if (entry[association.alias]) {
entry[association.alias]._type = _.upperFirst(association.model);
}
return entry[association.alias];
}
});
case 'manyMorphToOne':
case 'manyMorphToMany':
case 'manyToManyMorph':
return _.merge(acc.resolver[globalId], {
[association.alias]: async (obj, options, context) => { // eslint-disable-line no-unused-vars
const [ withRelated, withoutRelated ] = await Promise.all([
resolvers.fetch({
id: obj[model.primaryKey],
model: name
}, plugin, [association.alias], false),
resolvers.fetch({
id: obj[model.primaryKey],
model: name
}, plugin, [])
]);
const entry = withRelated && withRelated.toJSON ? withRelated.toJSON() : withRelated;
// TODO:
// - Handle sort, limit and start (lodash or inside the query)
entry[association.alias].map((entry, index) => {
const type = _.get(withoutRelated, `${association.alias}.${index}.kind`) ||
_.upperFirst(_.camelCase(_.get(withoutRelated, `${association.alias}.${index}.${association.alias}_type`))) ||
_.upperFirst(_.camelCase(association[association.type]));
entry._type = type;
return entry;
});
return entry[association.alias];
}
});
default:
}
_.merge(acc.resolver[globalId], {
[association.alias]: async (obj, options, context) => { // eslint-disable-line no-unused-vars
// Construct parameters object to retrieve the correct related entries.
const params = {
model: association.model || association.collection,
};
const queryOpts = {
source: association.plugin
};
if (association.type === 'model') {
const rel = obj[association.alias];
params.id = typeof rel === 'object' && 'id' in rel ? rel.id : rel;
} else {
// Get refering model.
const ref = association.plugin ?
strapi.plugins[association.plugin].models[params.model]:
strapi.models[params.model];
// Apply optional arguments to make more precise nested request.
const convertedParams = strapi.utils.models.convertParams(name, this.convertToParams(this.amountLimiting(options)));
const where = strapi.utils.models.convertParams(name, options.where || {});
// Limit, order, etc.
Object.assign(queryOpts, convertedParams);
// Skip.
queryOpts.skip = convertedParams.start;
switch (association.nature) {
case 'manyToMany': {
if (association.dominant) {
const arrayOfIds = (obj[association.alias] || []).map(related => {
return related[ref.primaryKey] || related;
});
// Where.
queryOpts.query = strapi.utils.models.convertParams(name, {
// Construct the "where" query to only retrieve entries which are
// related to this entry.
[ref.primaryKey]: arrayOfIds,
...where.where
}).where;
}
break;
// falls through
}
default:
// Where.
queryOpts.query = strapi.utils.models.convertParams(name, {
// Construct the "where" query to only retrieve entries which are
// related to this entry.
[association.via]: obj[ref.primaryKey],
...where.where
}).where;
}
}
const value = await (association.model ?
resolvers.fetch(params, association.plugin, []):
resolvers.fetchAll(params, { ...queryOpts, populate: [] }));
return value && value.toJSON ? value.toJSON() : value;
}
});
});
return acc;
}, initialState);
},
/**
* Generate GraphQL schema.
*
* @return Schema
*/
generateSchema: function () {
// Generate type definition and query/mutation for models.
const shadowCRUD = strapi.plugins.graphql.config.shadowCRUD !== false ? (() => {
// Exclude core models.
const models = Object.keys(strapi.models).filter(model => model !== 'core_store');
// Reproduce the same pattern for each plugin.
return Object.keys(strapi.plugins).reduce((acc, plugin) => {
const { definition, query, resolver } = this.shadowCRUD(Object.keys(strapi.plugins[plugin].models), plugin);
// We cannot put this in the merge because it's a string.
acc.definition += definition || '';
return _.merge(acc, {
query,
resolver
});
}, this.shadowCRUD(models));
})() : { definition: '', query: '', resolver: '' };
// Extract custom definition, query or resolver.
const { definition, query, resolver = {} } = strapi.plugins.graphql.config._schema.graphql;
// Polymorphic.
const { polymorphicDef, polymorphicResolver } = this.addPolymorphicUnionType(definition, shadowCRUD.definition);
// Build resolvers.
const resolvers = _.omitBy(_.merge(shadowCRUD.resolver, resolver, polymorphicResolver), _.isEmpty) || {};
// Transform object to only contain function.
Object.keys(resolvers).reduce((acc, type) => {
return Object.keys(acc[type]).reduce((acc, resolver) => {
// Disabled this query.
if (acc[type][resolver] === false) {
delete acc[type][resolver];
return acc;
}
if (!_.isFunction(acc[type][resolver])) {
acc[type][resolver] = acc[type][resolver].resolver;
}
if (_.isString(acc[type][resolver]) || _.isPlainObject(acc[type][resolver])) {
const { plugin = '' } = _.isPlainObject(acc[type][resolver]) ? acc[type][resolver] : {};
acc[type][resolver] = this.composeResolver(
strapi.plugins.graphql.config._schema.graphql,
plugin,
resolver,
'force' // Avoid singular/pluralize and force query name.
);
}
return acc;
}, acc);
}, resolvers);
// Return empty schema when there is no model.
if (_.isEmpty(shadowCRUD.definition) && _.isEmpty(definition)) {
return {};
}
// Concatenate.
const typeDefs = `
${definition}
${shadowCRUD.definition}
type Query {${shadowCRUD.query && this.formatGQL(shadowCRUD.query, resolver.Query, null, 'query')}${query}}
${this.addCustomScalar(resolvers)}
${polymorphicDef}
`;
// Build schema.
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
// Write schema.
this.writeGenerateSchema(graphql.printSchema(schema));
return schema;
},
/**
* Add custom scalar type such as JSON.
*
* @return void
*/
addCustomScalar: (resolvers) => {
Object.assign(resolvers, {
JSON: GraphQLJSON,
DateTime: GraphQLDateTime,
});
return 'scalar JSON \n scalar DateTime';
},
/**
* Add Union Type that contains the types defined by the user.
*
* @return string
*/
addPolymorphicUnionType: (customDefs, defs) => {
const types = graphql.parse(customDefs + defs).definitions
.filter(def => def.kind === 'ObjectTypeDefinition' && def.name.value !== 'Query')
.map(def => def.name.value);
if (types.length > 0) {
return {
polymorphicDef: `union Morph = ${types.join(' | ')}`,
polymorphicResolver: {
Morph: {
__resolveType(obj, context, info) { // eslint-disable-line no-unused-vars
return obj.kind || obj._type;
}
}
}
};
}
return {
polymorphicDef: '',
polymorphicResolver: {}
};
},
/**
* Save into a file the readable GraphQL schema.
*
* @return void
*/
writeGenerateSchema(schema) {
// Disable auto-reload.
strapi.reload.isWatching = false;
const generatedFolder = path.resolve(strapi.config.appPath, 'plugins', 'graphql', 'config', 'generated');
// Create folder if necessary.
try {
fs.accessSync(generatedFolder, fs.constants.R_OK | fs.constants.W_OK);
} catch (err) {
if (err && err.code === 'ENOENT') {
fs.mkdirSync(generatedFolder);
} else {
console.error(err);
}
}
fs.writeFileSync(path.join(generatedFolder, 'schema.graphql'), schema);
strapi.reload.isWatching = true;
}
};