GraphQL Mutations, Aggregations, Decimals

Merging Mutations and Aggregations. Fixed Mongoose Hook to use Decimals (2 decimal places) and Floats (20 decimal places).
This commit is contained in:
Jason Lei 2018-09-10 16:05:00 +08:00
parent b4dedc12f1
commit 3378403954
14 changed files with 2409 additions and 1463 deletions

View File

@ -5,7 +5,8 @@
*/
module.exports = mongoose => {
require('mongoose-float').loadType(mongoose);
var Decimal = require('mongoose-float').loadType(mongoose, 2);
var Float = require('mongoose-float').loadType(mongoose, 20);
return {
convertType: mongooseType => {
@ -22,9 +23,9 @@ module.exports = mongoose => {
case 'timestamp':
return Date;
case 'decimal':
return 'Float';
return Decimal;
case 'float':
return mongoose.Schema.Types.Decimal128;
return Float;
case 'json':
return 'Mixed';
case 'biginteger':
@ -39,8 +40,7 @@ module.exports = mongoose => {
case 'text':
return 'String';
default:
}
}
},
};
};

View File

@ -1 +1 @@
# Strapi plugin
# Strapi plugin

View File

@ -8,13 +8,12 @@
const _ = require('lodash');
const path = require('path');
const glob = require('glob');
const { graphqlKoa } = require('apollo-server-koa');
const koaPlayground = require('graphql-playground-middleware-koa').default;
const { ApolloServer } = require('apollo-server-koa');
const depthLimit = require('graphql-depth-limit');
module.exports = strapi => {
return {
beforeInitialize: async function(){
beforeInitialize: async function() {
// Try to inject this hook just after the others hooks to skip the router processing.
if (!_.get(strapi.config.hook.load, 'after')) {
_.set(strapi.config.hook.load, 'after', []);
@ -23,46 +22,73 @@ module.exports = strapi => {
strapi.config.hook.load.after.push('graphql');
// Load core utils.
const utils = require(path.resolve(strapi.config.appPath, 'node_modules', 'strapi', 'lib', '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);
}
glob(
'./config/*.graphql',
{
cwd: strapi.config.appPath,
},
(err, files) => {
if (err) {
return reject(err);
}
utils.loadConfig.call(strapi, files, true).then(resolve).catch(reject);
});
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);
}
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);
});
utils.loadConfig
.call(strapi, files, true)
.then(resolve)
.catch(reject);
},
);
}),
// Load plugins configurations.
new Promise((resolve, reject) => {
glob('./plugins/*/config/*.graphql', {
cwd: strapi.config.appPath
}, (err, files) => {
if (err) {
return reject(err);
}
glob(
'./plugins/*/config/*.graphql',
{
cwd: strapi.config.appPath,
},
(err, files) => {
if (err) {
return reject(err);
}
utils.loadConfig.call(strapi, files, true).then(resolve).catch(reject);
});
})
utils.loadConfig
.call(strapi, files, true)
.then(resolve)
.catch(reject);
},
);
}),
]);
/*
@ -70,66 +96,93 @@ module.exports = strapi => {
*/
// Set path with initial state.
_.set(strapi.plugins.graphql, 'config._schema.graphql', { definition: '', query: '', type : {}, resolver: {} });
_.set(strapi.plugins.graphql, 'config._schema.graphql', {
definition: '',
query: '',
mutation: '',
type: {},
resolver: {},
});
// Merge user API.
Object.keys(strapi.api || {}).reduce((acc, current) => {
const { definition, query, type, resolver } = _.get(strapi.api[current], 'config.schema.graphql', {});
const { definition, query, mutation, type, resolver } = _.get(
strapi.api[current],
'config.schema.graphql',
{},
);
acc.definition += definition || '';
acc.query += query || '';
acc.mutation += mutation || '';
return _.merge(acc, {
type,
resolver
resolver,
});
}, strapi.plugins.graphql.config._schema.graphql);
// Merge plugins API.
Object.keys(strapi.plugins || {}).reduce((acc, current) => {
const { definition, query, type, resolver } = _.get(strapi.plugins[current], 'config.schema.graphql', {});
const { definition, query, mutation, type, resolver } = _.get(
strapi.plugins[current],
'config.schema.graphql',
{},
);
acc.definition += definition || '';
acc.query += query || '';
acc.mutation += mutation || '';
return _.merge(acc, {
type,
resolver
resolver,
});
}, strapi.plugins.graphql.config._schema.graphql);
},
initialize: function(cb) {
const schema = strapi.plugins.graphql.services.graphql.generateSchema();
const {
typeDefs,
resolvers,
} = strapi.plugins.graphql.services.schema.generateSchema();
if (_.isEmpty(schema)) {
strapi.log.warn('GraphQL schema has not been generated because it\'s empty');
if (_.isEmpty(typeDefs)) {
strapi.log.warn(
'GraphQL schema has not been generated because it\'s empty',
);
return cb();
}
const router = strapi.koaMiddlewares.routerJoi();
router.post(strapi.plugins.graphql.config.endpoint, async (ctx, next) => graphqlKoa({
schema,
context: ctx,
validationRules: [ depthLimit(strapi.plugins.graphql.config.depthLimit) ]
})(ctx, next));
router.get(strapi.plugins.graphql.config.endpoint, async (ctx, next) => graphqlKoa({
schema,
context: ctx,
validationRules: [ depthLimit(strapi.plugins.graphql.config.depthLimit) ]
})(ctx, next));
const serverParams = {
typeDefs,
resolvers,
context: async ({ ctx }) => ({
context: ctx,
}),
validationRules: [depthLimit(strapi.plugins.graphql.config.depthLimit)],
playground: false,
};
// Disable GraphQL Playground in production environment.
if (strapi.config.environment !== 'production' || strapi.plugins.graphql.config.playgroundAlways) {
router.get('/playground', koaPlayground({ endpoint: strapi.plugins.graphql.config.endpoint}));
if (
strapi.config.environment !== 'production' ||
strapi.plugins.graphql.config.playgroundAlways
) {
serverParams.playground = {
endpoint: strapi.plugins.graphql.config.endpoint,
};
}
strapi.app.use(router.middleware());
const server = new ApolloServer(serverParams);
server.applyMiddleware({
app: strapi.app,
path: strapi.plugins.graphql.config.endpoint,
});
cb();
}
},
};
};

View File

@ -0,0 +1,563 @@
'use strict';
/**
* Aggregator.js service
*
* @description: A set of functions similar to controller's actions to avoid code duplication.
*/
const _ = require('lodash');
const pluralize = require('pluralize');
const Schema = require('./Schema.js');
module.exports = {
/**
* Returns all fields of type primitive
*
* @returns {Boolean}
*/
isPrimitiveType: _type => {
const type = _type.replace('!', '');
return (
type === 'Int' ||
type === 'Float' ||
type === 'String' ||
type === 'Boolean' ||
type === 'DateTime' ||
type === 'JSON'
);
},
/**
* Checks if the field is of type enum
*
* @returns {Boolean}
*/
isEnumType: type => {
return type === 'enumeration';
},
/**
* Returns all fields that are not of type array
*
* @returns {Boolean}
*
* @example
*
* isNotOfTypeArray([String])
* // => false
* isNotOfTypeArray(String!)
* // => true
*/
isNotOfTypeArray: type => {
return !/(\[\w+!?\])/.test(type);
},
/**
* Returns all fields of type Integer or float
*/
isNumberType: type => {
return type === 'Int' || type === 'Float';
},
/**
* Convert parameters to valid filters parameters.
*
* @return Object
*/
convertToParams: params => {
return Object.keys(params).reduce((acc, current) => {
return Object.assign(acc, {
[`_${current}`]: params[current],
});
}, {});
},
/**
* Returns a list of fields that have type included in fieldTypes.
*/
getFieldsByTypes: (fields, typeCheck, returnType) => {
return _.reduce(
fields,
(acc, fieldType, fieldName) => {
if (typeCheck(fieldType)) {
acc[fieldName] = returnType(fieldType, fieldName);
}
return acc;
},
{},
);
},
/**
* Use the field resolver otherwise fall through the field value
*
* @returns {function}
*/
fieldResolver: (field, key) => {
return object => {
const resolver =
field.resolve ||
function resolver(obj, options, context) {
// eslint-disable-line no-unused-vars
return obj[key];
};
return resolver(object);
};
},
/**
* Create fields resolvers
*
* @return {Object}
*/
createFieldsResolver: function(fields, resolver, typeCheck) {
return Object.keys(fields).reduce((acc, fieldKey) => {
const field = fields[fieldKey];
// Check if the field is of the correct type
if (typeCheck(field)) {
return _.set(acc, fieldKey, (obj, options, context) => {
return resolver(
obj,
options,
context,
this.fieldResolver(field, fieldKey),
fieldKey,
obj,
field,
);
});
}
return acc;
}, {});
},
/**
* Convert non-primitive type to string (non-primitive types corresponds to a reference to an other model)
*
* @returns {String}
*
* @example
*
* extractType(String!)
* // => String
*
* extractType(user)
* // => ID
*
* extractType(ENUM_TEST_FIELD, enumeration)
* // => String
*
*/
extractType: function(_type, attributeType) {
return this.isPrimitiveType(_type)
? _type.replace('!', '')
: this.isEnumType(attributeType)
? 'String'
: 'ID';
},
/**
* Build the mongoose aggregator by applying the filters
*/
getModelAggregator: function(model, filters = {}) {
const aggregation = model.aggregate();
if (!_.isEmpty(filters.where)) {
aggregation.match(filters.where);
}
if (filters.limit) {
aggregation.limit(filters.limit);
}
return aggregation;
},
/**
* Create the resolvers for each aggregation field
*
* @return {Object}
*
* @example
*
* const model = // Strapi model
*
* const fields = {
* username: String,
* age: Int,
* }
*
* const typeCheck = (type) => type === 'Int' || type === 'Float',
*
* const fieldsResoler = createAggregationFieldsResolver(model, fields, 'sum', typeCheck);
*
* // => {
* age: function ageResolver() { .... }
* }
*/
createAggregationFieldsResolver: function(
model,
fields,
operation,
typeCheck,
) {
return this.createFieldsResolver(
fields,
async (obj, options, context, fieldResolver, fieldKey) => {
// eslint-disable-line no-unused-vars
const result = await this.getModelAggregator(model, obj).group({
_id: null,
[fieldKey]: { [`$${operation}`]: `$${fieldKey}` },
});
return _.get(result, `0.${fieldKey}`);
},
typeCheck,
);
},
/**
* Correctly format the data returned by the group by
*/
preProcessGroupByData: function({ result, fieldKey, filters, modelName }) {
const _result = _.toArray(result);
return _.map(_result, value => {
const params = Object.assign(
{},
this.convertToParams(_.omit(filters, 'where')),
filters.where,
{
[fieldKey]: value._id,
},
);
return {
key: value._id,
connection: strapi.utils.models.convertParams(modelName, params),
};
});
},
/**
* Create the resolvers for each group by field
*
* @return {Object}
*
* @example
*
* const model = // Strapi model
* const fields = {
* username: [UserConnectionUsername],
* email: [UserConnectionEmail],
* }
* const fieldsResoler = createGroupByFieldsResolver(model, fields);
*
* // => {
* username: function usernameResolver() { .... }
* email: function emailResolver() { .... }
* }
*/
createGroupByFieldsResolver: function(model, fields, name) {
return this.createFieldsResolver(
fields,
async (obj, options, context, fieldResolver, fieldKey) => {
const result = await this.getModelAggregator(model, obj).group({
_id: `$${fieldKey}`,
});
return this.preProcessGroupByData({
result,
fieldKey,
filters: obj,
modelName: name,
});
},
() => true,
);
},
/**
* This method is the entry point to the GraphQL's Aggregation.
* It takes as param the model and its fields and it'll create the aggregation types and resolver to it
* Example:
* type User {
* username: String,
* age: Int,
* }
*
* It'll create
* type UserConnection {
* values: [User],
* groupBy: UserGroupBy,
* aggreate: UserAggregate
* }
*
* type UserAggregate {
* count: Int
* sum: UserAggregateSum
* avg: UserAggregateAvg
* }
*
* type UserAggregateSum {
* age: Float
* }
*
* type UserAggregateAvg {
* age: Float
* }
*
* type UserGroupBy {
* username: [UserConnectionUsername]
* age: [UserConnectionAge]
* }
*
* type UserConnectionUsername {
* key: String
* connection: UserConnection
* }
*
* type UserConnectionAge {
* key: Int
* connection: UserConnection
* }
*
*/
formatModelConnectionsGQL: function(fields, model, name, modelResolver) {
const { globalId } = model;
const connectionGlobalId = `${globalId}Connection`;
const aggregatorFormat = this.formatConnectionAggregator(
fields,
model,
name,
);
const groupByFormat = this.formatConnectionGroupBy(fields, model, name);
const connectionFields = {
values: `[${globalId}]`,
groupBy: `${globalId}GroupBy`,
aggregate: `${globalId}Aggregator`,
};
let modelConnectionTypes = `type ${connectionGlobalId} {${Schema.formatGQL(
connectionFields,
)}}\n\n`;
if (aggregatorFormat) {
modelConnectionTypes += aggregatorFormat.type;
}
modelConnectionTypes += groupByFormat.type;
return {
globalId: connectionGlobalId,
type: modelConnectionTypes,
query: {
[`${pluralize.plural(
name,
)}Connection(sort: String, limit: Int, start: Int, where: JSON)`]: connectionGlobalId,
},
resolver: {
Query: {
[`${pluralize.plural(name)}Connection`]: (obj, options, context) => {
// eslint-disable-line no-unused-vars
const params = Object.assign(
{},
this.convertToParams(_.omit(options, 'where')),
options.where,
);
return strapi.utils.models.convertParams(name, params);
},
},
[connectionGlobalId]: {
values: (obj, option, context) => {
// Object here contains the key/value of the field that has been grouped-by
// for instance obj = { where: { country: 'USA' } } so the values here needs to be filtered according to the parent value
return modelResolver(obj, obj, context);
},
groupBy: (obj, option, context) => {
// eslint-disable-line no-unused-vars
// There is noting to resolve here, it's the aggregation resolver that will take care of it
return obj;
},
aggregate: (obj, option, context) => {
// eslint-disable-line no-unused-vars
// There is noting to resolve here, it's the aggregation resolver that will take care of it
return obj;
},
},
...aggregatorFormat.resolver,
...groupByFormat.resolver,
},
};
},
/**
* Returns a list of fields that have type included in fieldTypes.
*/
getFieldsByTypes: (fields, typeCheck, returnType) => {
return _.reduce(
fields,
(acc, fieldType, fieldName) => {
if (typeCheck(fieldType)) {
acc[fieldName] = returnType(fieldType, fieldName);
}
return acc;
},
{},
);
},
/**
* Generate the connection type of each non-array field of the model
*
* @return {String}
*/
generateConnectionFieldsTypes: function(fields, model) {
const { globalId, attributes } = model;
const primitiveFields = this.getFieldsByTypes(
fields,
this.isNotOfTypeArray,
(type, name) => this.extractType(type, (attributes[name] || {}).type),
);
const connectionFields = _.mapValues(primitiveFields, fieldType => ({
key: fieldType,
connection: `${globalId}Connection`,
}));
return Object.keys(primitiveFields)
.map(
fieldKey =>
`type ${globalId}Connection${_.upperFirst(
fieldKey,
)} {${Schema.formatGQL(connectionFields[fieldKey])}}`,
)
.join('\n\n');
},
formatConnectionGroupBy: function(fields, model, name) {
const { globalId } = model;
const groupByGlobalId = `${globalId}GroupBy`;
// Extract all primitive fields and change their types
const groupByFields = this.getFieldsByTypes(
fields,
this.isNotOfTypeArray,
(fieldType, fieldName) =>
`[${globalId}Connection${_.upperFirst(fieldName)}]`,
);
// Get the generated field types
let groupByTypes = `type ${groupByGlobalId} {${Schema.formatGQL(
groupByFields,
)}}\n\n`;
groupByTypes += this.generateConnectionFieldsTypes(fields, model);
return {
globalId: groupByGlobalId,
type: groupByTypes,
resolver: {
[groupByGlobalId]: this.createGroupByFieldsResolver(
model,
groupByFields,
name,
),
},
};
},
formatConnectionAggregator: function(fields, model) {
const { globalId } = model;
// Extract all fields of type Integer and Float and change their type to Float
const numericFields = this.getFieldsByTypes(
fields,
this.isNumberType,
() => 'Float',
);
// Don't create an aggregator field if the model has not number fields
const aggregatorGlobalId = `${globalId}Aggregator`;
const initialFields = {
count: 'Int',
};
// Only add the aggregator's operations if there are some numeric fields
if (!_.isEmpty(numericFields)) {
['sum', 'avg', 'min', 'max'].forEach(agg => {
initialFields[agg] = `${aggregatorGlobalId}${_.startCase(agg)}`;
});
}
const gqlNumberFormat = Schema.formatGQL(numericFields);
let aggregatorTypes = `type ${aggregatorGlobalId} {${Schema.formatGQL(
initialFields,
)}}\n\n`;
let resolvers = {
[aggregatorGlobalId]: {
count: async (obj, options, context) => {
// eslint-disable-line no-unused-vars
// Object here corresponds to the filter that needs to be applied to the aggregation
const result = await this.getModelAggregator(model, obj).group({
_id: null,
count: { $sum: 1 },
});
return _.get(result, '0.count');
},
},
};
// Only add the aggregator's operations types and resolver if there are some numeric fields
if (!_.isEmpty(numericFields)) {
// Returns the actual object and handle aggregation in the query resolvers
const defaultAggregatorFunc = (obj, options, context) => {
// eslint-disable-line no-unused-vars
return obj;
};
aggregatorTypes += `type ${aggregatorGlobalId}Sum {${gqlNumberFormat}}\n\n`;
aggregatorTypes += `type ${aggregatorGlobalId}Avg {${gqlNumberFormat}}\n\n`;
aggregatorTypes += `type ${aggregatorGlobalId}Min {${gqlNumberFormat}}\n\n`;
aggregatorTypes += `type ${aggregatorGlobalId}Max {${gqlNumberFormat}}\n\n`;
_.merge(resolvers[aggregatorGlobalId], {
sum: defaultAggregatorFunc,
avg: defaultAggregatorFunc,
min: defaultAggregatorFunc,
max: defaultAggregatorFunc,
});
resolvers = {
...resolvers,
[`${aggregatorGlobalId}Sum`]: this.createAggregationFieldsResolver(
model,
fields,
'sum',
this.isNumberType,
),
[`${aggregatorGlobalId}Avg`]: this.createAggregationFieldsResolver(
model,
fields,
'avg',
this.isNumberType,
),
[`${aggregatorGlobalId}Min`]: this.createAggregationFieldsResolver(
model,
fields,
'min',
this.isNumberType,
),
[`${aggregatorGlobalId}Max`]: this.createAggregationFieldsResolver(
model,
fields,
'max',
this.isNumberType,
),
};
}
return {
globalId: aggregatorGlobalId,
type: aggregatorTypes,
resolver: resolvers,
};
},
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,240 @@
'use strict';
/**
* Mutation.js service
*
* @description: A set of functions similar to controller's actions to avoid code duplication.
*/
const _ = require('lodash');
const pluralize = require('pluralize');
const policyUtils = require('strapi-utils').policy;
const Query = require('./Query.js');
module.exports = {
/**
* Convert parameters to valid filters parameters.
*
* @return Object
*/
convertToParams: params => {
return Object.keys(params).reduce((acc, current) => {
return Object.assign(acc, {
[`_${current}`]: params[current],
});
}, {});
},
/**
* Execute policies before the specified resolver.
*
* @return Promise or Error.
*/
composeMutationResolver: function(_schema, plugin, name, action) {
// Extract custom resolver or type description.
const { resolver: handler = {} } = _schema;
const queryName = `${action}${_.capitalize(name)}`;
// Retrieve policies.
const policies = _.get(handler, `Mutation.${queryName}.policies`, []);
// Retrieve resolverOf.
const resolverOf = _.get(handler, `Mutation.${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, `Mutation.${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 = _.get(
controllers,
`${name}.${action === 'delete' ? 'destroy' : action}`,
);
if (!controller) {
return new Error(
`Cannot find the controller's action ${name}.${action}`,
);
}
// 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}.${action === 'delete' ? 'destroy' : action}`,
},
undefined,
plugin,
),
);
// Make the query compatible with our controller by
// setting in the context the parameters.
return async (ctx, next) => {
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.params = Query.convertToParams(options.input.where || {});
context.request.body = options.input.data || {};
if (isController) {
const values = await resolver.call(null, context);
if (ctx.body) {
return {
[pluralize.singular(name)]: ctx.body,
};
}
const body = values && values.toJSON ? values.toJSON() : values;
return {
[pluralize.singular(name)]: body,
};
}
return resolver.call(null, obj, options, context);
}
// Resolver can be a promise.
return resolver;
};
},
};

View File

@ -0,0 +1,285 @@
'use strict';
/**
* Query.js service
*
* @description: A set of functions similar to controller's actions to avoid code duplication.
*/
const _ = require('lodash');
const pluralize = require('pluralize');
const policyUtils = require('strapi-utils').policy;
module.exports = {
/**
* Convert parameters to valid filters parameters.
*
* @return Object
*/
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;
},
/**
* Execute policies before the specified resolver.
*
* @return Promise or Error.
*/
composeQueryResolver: function(_schema, plugin, name, isSingular) {
const params = {
model: name,
};
const model = plugin
? strapi.plugins[plugin].models[name]
: strapi.models[name];
// 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;
};
},
};

View File

@ -0,0 +1,443 @@
'use strict';
/**
* GraphQL.js service
*
* @description: A set of functions similar to controller's actions to avoid code duplication.
*/
const _ = require('lodash');
const pluralize = require('pluralize');
const Aggregator = require('./Aggregator');
const Query = require('./Query.js');
const Mutation = require('./Mutation.js');
const Types = require('./Types.js');
const Schema = require('./Schema.js');
module.exports = {
/**
* 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: {},
mutation: {},
resolver: { Query: {}, Mutation: {} },
};
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] = Types.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 ${Types.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 += `${Schema.getDescription(
type[globalId],
model,
)}type ${globalId} {${Schema.formatGQL(
attributes,
type[globalId],
model,
)}}\n\n`;
// Add definition to the schema but this type won't be "queriable" or "mutable".
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
? Query.composeQueryResolver(_schema, plugin, name, true)
: null,
plural:
_.get(resolver, `Query.${pluralize.plural(name)}`) !== false
? Query.composeQueryResolver(_schema, plugin, name, false)
: null,
};
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],
});
}
});
// TODO:
// - Implement batch methods (need to update the content-manager as well).
// - Implement nested transactional methods (create/update).
const mutations = {
create:
_.get(resolver, `Mutation.create${_.capitalize(name)}`) !== false
? Mutation.composeMutationResolver(_schema, plugin, name, 'create')
: null,
update:
_.get(resolver, `Mutation.update${_.capitalize(name)}`) !== false
? Mutation.composeMutationResolver(_schema, plugin, name, 'update')
: null,
delete:
_.get(resolver, `Mutation.delete${_.capitalize(name)}`) !== false
? Mutation.composeMutationResolver(_schema, plugin, name, 'delete')
: null,
};
// Add model Input definition.
acc.definition += Types.generateInputModel(model, name);
Object.keys(mutations).forEach(type => {
if (_.isFunction(mutations[type])) {
let mutationDefinition;
let mutationName = `${type}${_.capitalize(name)}`;
// Generate the Input for this specific action.
acc.definition += Types.generateInputPayloadArguments(
model,
name,
type,
);
switch (type) {
case 'create':
mutationDefinition = {
[`${mutationName}(input: ${mutationName}Input)`]: `${mutationName}Payload`,
};
break;
case 'update':
mutationDefinition = {
[`${mutationName}(input: ${mutationName}Input)`]: `${mutationName}Payload`,
};
break;
case 'delete':
mutationDefinition = {
[`${mutationName}(input: ${mutationName}Input)`]: `${mutationName}Payload`,
};
break;
default:
// Nothing.
}
// Assign mutation definition to global definition.
Object.assign(acc.mutation, mutationDefinition);
// Assign resolver to this mutation and merge it with the others.
_.merge(acc.resolver.Mutation, {
[`${mutationName}`]: mutations[type],
});
}
});
// TODO:
// - Add support for Graphql Aggregation in Bookshelf ORM
if (model.orm === 'mongoose') {
// Generation the aggregation for the given model
const modelAggregator = Aggregator.formatModelConnectionsGQL(
attributes,
model,
name,
queries.plural,
);
if (modelAggregator) {
acc.definition += modelAggregator.type;
if (!acc.resolver[modelAggregator.globalId]) {
acc.resolver[modelAggregator.globalId] = {};
}
_.merge(acc.resolver, modelAggregator.resolver);
_.merge(acc.query, modelAggregator.query);
}
}
// 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') {
params.id = obj[association.alias];
} 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,
Query.convertToParams(Query.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);
},
};

View File

@ -0,0 +1,316 @@
'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 { gql, makeExecutableSchema } = require('apollo-server-koa');
const _ = require('lodash');
const graphql = require('graphql');
const Query = require('./Query.js');
const Mutation = require('./Mutation.js');
const Types = require('./Types.js');
const Resolvers = require('./Resolvers.js');
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' || type === 'mutation') {
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 '';
},
/**
* 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,
mutation,
resolver,
} = Resolvers.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,
mutation,
});
}, Resolvers.shadowCRUD(models));
})()
: { definition: '', query: '', mutation: '', resolver: '' };
// Extract custom definition, query or resolver.
const {
definition,
query,
mutation,
resolver = {},
} = strapi.plugins.graphql.config._schema.graphql;
// Polymorphic.
const {
polymorphicDef,
polymorphicResolver,
} = Types.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])) {
console.log(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]
: {};
switch (type) {
case 'Mutation':
// TODO: Verify this...
acc[type][resolver] = Mutation.composeMutationResolver(
strapi.plugins.graphql.config._schema.graphql,
plugin,
resolver,
);
break;
case 'Query':
default:
acc[type][resolver] = Query.composeQueryResolver(
strapi.plugins.graphql.config._schema.graphql,
plugin,
resolver,
'force', // Avoid singular/pluralize and force query name.
);
break;
}
}
return acc;
}, acc);
}, resolvers);
// Return empty schema when there is no model.
if (_.isEmpty(shadowCRUD.definition) && _.isEmpty(definition)) {
return {};
}
// Concatenate.
let typeDefs = `
${definition}
${shadowCRUD.definition}
type Query {${shadowCRUD.query &&
this.formatGQL(
shadowCRUD.query,
resolver.Query,
null,
'query',
)}${query}}
type Mutation {${shadowCRUD.mutation &&
this.formatGQL(
shadowCRUD.mutation,
resolver.Mutation,
null,
'mutation',
)}${mutation}}
${Types.addCustomScalar(resolvers)}
${Types.addInput()}
${polymorphicDef}
`;
// Build schema.
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
// Write schema.
this.writeGenerateSchema(graphql.printSchema(schema));
// Remove custom scaler (like Upload);
typeDefs = Types.removeCustomScalar(typeDefs, resolvers);
return {
typeDefs: gql(typeDefs),
resolvers,
};
},
/**
* 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;
},
};

View File

@ -0,0 +1,239 @@
'use strict';
/**
* Types.js service
*
* @description: A set of functions to make the schema easier to build.
*/
const _ = require('lodash');
const { GraphQLUpload } = require('apollo-server-koa');
const graphql = require('graphql');
const GraphQLJSON = require('graphql-type-json');
const GraphQLDateTime = require('graphql-type-datetime');
const pluralize = require('pluralize');
module.exports = {
/**
* 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 = '',
rootType = 'query',
}) {
// 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 'decimal':
type = 'Float';
break;
case 'float':
type = 'Float';
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) {
if (rootType === 'mutation') {
return '[ID]';
}
return `[${globalId}]`;
}
if (rootType === 'mutation') {
return 'ID';
}
return globalId;
}
if (rootType === 'mutation') {
return definition.model ? 'ID' : '[ID]';
}
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()}`,
/**
* Remove custom scalar type such as Upload because Apollo automatically adds it in the schema.
* but we need to add it to print the schema on our side.
*
* @return void
*/
removeCustomScalar: (typeDefs, resolvers) => {
delete resolvers.Upload;
return typeDefs.replace('scalar Upload', '');
},
/**
* Add custom scalar type such as JSON.
*
* @return void
*/
addCustomScalar: resolvers => {
Object.assign(resolvers, {
JSON: GraphQLJSON,
DateTime: GraphQLDateTime,
Upload: GraphQLUpload,
});
return 'scalar JSON \n scalar DateTime \n scalar Upload';
},
/**
* 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: {},
};
},
addInput: function() {
return `
input InputID { id: ID!}
`;
},
generateInputModel: function(model, name) {
const globalId = model.globalId;
const inputName = `${_.capitalize(name)}Input`;
/* eslint-disable */
return `
input ${inputName} {
${Object.keys(model.attributes)
.filter(attribute => model.attributes[attribute].private !== true)
.map(attribute => {
return `${attribute}: ${this.convertType({
definition: model.attributes[attribute],
modelName: globalId,
attributeName: attribute,
rootType: 'mutation',
})}`;
})
.join('\n')}
}
`;
/* eslint-enable */
},
generateInputPayloadArguments: function(model, name, type, resolver) {
if (_.get(resolver, `Mutation.${type}${_.capitalize(name)}`) === false) {
return '';
}
const inputName = `${_.capitalize(name)}Input`;
const payloadName = `${_.capitalize(name)}Payload`;
switch (type) {
case 'create':
return `
input ${type}${inputName} { data: ${inputName} }
type ${type}${payloadName} { ${pluralize.singular(name)}: ${
model.globalId
} }
`;
case 'update':
return `
input ${type}${inputName} { where: InputID, data: ${inputName} }
type ${type}${payloadName} { ${pluralize.singular(name)}: ${
model.globalId
} }
`;
case 'delete':
return `
input ${type}${inputName} { where: InputID }
type ${type}${payloadName} { ${pluralize.singular(name)}: ${
model.globalId
} }
`;
default:
// Nothing
}
},
};

View File

@ -1,10 +1,44 @@
const _ = require('lodash');
module.exports = {
mutation: `
upload(refId: ID, ref: String, source: String, file: Upload!): UploadFile!
`,
resolver: {
Query: {
file: false,
files: {
resolver: 'Upload.find'
}
},
Mutation: {
createFile: false,
updateFile: false,
deleteFile: false,
upload: {
description: 'Upload one or many files',
resolver: async (obj, { file, ...fields}, { context }) => {
// Construct context to fit with koa-parser guidelines
// and avoid to update our business logic too much.
context.request.body = {
files: {
files: await file
},
fields
};
// Call controller action.
await strapi.plugins.upload.controllers.upload.upload(context);
// Handle case when the user is uploading only one file.
if (_.isArray(context.body) && context.body.length === 1) {
return context.body[0];
}
// Return response.
return context.body;
}
}
}
}
};

View File

@ -7,20 +7,10 @@
*/
const fs = require('fs');
const crypto = require('crypto');
const _ = require('lodash');
const toArray = require('stream-to-array');
const uuid = require('uuid/v4');
function niceHash(buffer) {
return crypto.createHash('sha256')
.update(buffer)
.digest('base64')
.replace(/=/g, '')
.replace(/\//g, '-')
.replace(/\+/, '_');
}
module.exports = {
bufferize: async files => {
if (_.isEmpty(files) === 0) {
@ -33,32 +23,47 @@ module.exports = {
// transform all files in buffer
return Promise.all(
files.map(async stream => {
const parts = await toArray(fs.createReadStream(stream.path));
const parts = stream.path
? await toArray(fs.createReadStream(stream.path))
: await toArray(stream.stream);
const buffers = parts.map(
part => _.isBuffer(part) ? part : Buffer.from(part)
part => (_.isBuffer(part) ? part : Buffer.from(part)),
);
const buffer = Buffer.concat(buffers);
if (!stream.path) {
stream.name = stream.filename;
stream.type = stream.mimetype;
stream.size = parseInt(stream.encoding.replace('bit', ''));
}
return {
name: stream.name,
sha256: niceHash(buffer),
hash: uuid().replace(/-/g, ''),
ext: stream.name.split('.').length > 1 ? `.${_.last(stream.name.split('.'))}` : '',
buffer,
ext:
stream.name.split('.').length > 1
? `.${_.last(stream.name.split('.'))}`
: '',
buffer: Buffer.concat(buffers),
mime: stream.type,
size: (stream.size / 1000).toFixed(2)
size: (stream.size / 1000).toFixed(2),
};
})
}),
);
},
upload: async (files, config) => {
// Get upload provider settings to configure the provider to use.
const provider = _.find(strapi.plugins.upload.config.providers, { provider: config.provider });
const provider = _.find(strapi.plugins.upload.config.providers, {
provider: config.provider,
});
if (!provider) {
throw new Error(`The provider package isn't installed. Please run \`npm install strapi-upload-${config.provider}\``);
throw new Error(
`The provider package isn't installed. Please run \`npm install strapi-upload-${
config.provider
}\``,
);
}
const actions = provider.init(config);
@ -74,16 +79,22 @@ module.exports = {
file.provider = provider.provider;
return await strapi.plugins['upload'].services.upload.add(file);
})
}),
);
},
add: async (values) => {
add: async values => {
// Use Content Manager business logic to handle relation.
if (strapi.plugins['content-manager']) {
return await strapi.plugins['content-manager'].services['contentmanager'].add({
model: 'file'
}, values, 'upload');
return await strapi.plugins['content-manager'].services[
'contentmanager'
].add(
{
model: 'file',
},
values,
'upload',
);
}
return strapi.query('file', 'upload').create(values);
@ -93,20 +104,26 @@ module.exports = {
// Use Content Manager business logic to handle relation.
if (strapi.plugins['content-manager']) {
params.model = 'file';
params.id = (params._id || params.id);
params.id = params._id || params.id;
return await strapi.plugins['content-manager'].services['contentmanager'].edit(params, values, 'upload');
return await strapi.plugins['content-manager'].services[
'contentmanager'
].edit(params, values, 'upload');
}
return strapi.query('file', 'upload').update(_.assign(params, values));
},
fetch: (params) => {
return strapi.query('file', 'upload').findOne(_.pick(params, ['_id', 'id']));
fetch: params => {
return strapi
.query('file', 'upload')
.findOne(_.pick(params, ['_id', 'id']));
},
fetchAll: (params) => {
return strapi.query('file', 'upload').find(strapi.utils.models.convertParams('file', params));
fetchAll: params => {
return strapi
.query('file', 'upload')
.find(strapi.utils.models.convertParams('file', params));
},
count: async () => {
@ -114,12 +131,16 @@ module.exports = {
},
remove: async (params, config) => {
params.id = (params._id || params.id);
params.id = params._id || params.id;
const file = await strapi.plugins['upload'].services.upload.fetch(params);
// get upload provider settings to configure the provider to use
const provider = _.cloneDeep(_.find(strapi.plugins.upload.config.providers, {provider: config.provider}));
const provider = _.cloneDeep(
_.find(strapi.plugins.upload.config.providers, {
provider: config.provider,
}),
);
_.assign(provider, config);
const actions = provider.init(config);
@ -132,48 +153,55 @@ module.exports = {
if (strapi.plugins['content-manager']) {
params.model = 'file';
await strapi.plugins['content-manager'].services['contentmanager'].delete(params, {source: 'upload'});
await strapi.plugins['content-manager'].services['contentmanager'].delete(
params,
{ source: 'upload' },
);
}
return strapi.query('file', 'upload').delete(params);
},
uploadToEntity: async function (params, files, source) {
uploadToEntity: async function(params, files, source) {
// Retrieve provider settings from database.
const config = await strapi.store({
environment: strapi.config.environment,
type: 'plugin',
name: 'upload'
}).get({ key: 'provider' });
const config = await strapi
.store({
environment: strapi.config.environment,
type: 'plugin',
name: 'upload',
})
.get({ key: 'provider' });
const model = source && source !== 'content-manager' ?
strapi.plugins[source].models[params.model]:
strapi.models[params.model];
const model =
source && source !== 'content-manager'
? strapi.plugins[source].models[params.model]
: strapi.models[params.model];
// Asynchronous upload.
await Promise.all(
Object.keys(files)
.map(async attribute => {
// Bufferize files per attribute.
const buffers = await this.bufferize(files[attribute]);
const enhancedFiles = buffers.map(file => {
const details = model.attributes[attribute];
Object.keys(files).map(async attribute => {
// Bufferize files per attribute.
const buffers = await this.bufferize(files[attribute]);
const enhancedFiles = buffers.map(file => {
const details = model.attributes[attribute];
// Add related information to be able to make
// the relationships later.
file[details.via] = [{
// Add related information to be able to make
// the relationships later.
file[details.via] = [
{
refId: params.id,
ref: params.model,
source,
field: attribute,
}];
},
];
return file;
});
return file;
});
// Make upload async.
return this.upload(enhancedFiles, config);
})
// Make upload async.
return this.upload(enhancedFiles, config);
}),
);
}
},
};

View File

@ -21,6 +21,35 @@ module.exports = {
return ctx.body.roles;
}
}
},
Mutation: {
createRole: {
description: 'Create a new role',
resolverOf: 'UsersPermissions.createRole',
resolver: async (obj, options, ctx) => {
await strapi.plugins['users-permissions'].controllers.userspermissions.createRole(ctx);
return { ok: true };
}
},
updateRole: {
description: 'Update an existing role',
resolverOf: 'UsersPermissions.updateRole',
resolver: async (obj, options, ctx) => {
await strapi.plugins['users-permissions'].controllers.userspermissions.updateRole(ctx);
return { ok: true };
}
},
deleteRole: {
description: 'Delete an existing role',
resolverOf: 'UsersPermissions.deleteRole',
resolver: async (obj, options, ctx) => {
await strapi.plugins['users-permissions'].controllers.userspermissions.deleteRole(ctx);
return { ok: true };
}
}
}
}
};

View File

@ -70,7 +70,6 @@ class Strapi extends EventEmitter {
port: process.env.PORT || 1337,
environment: toLower(process.env.NODE_ENV) || 'development',
environments: {},
admin: {},
paths: {
admin: 'admin',
api: 'api',
@ -111,21 +110,27 @@ class Strapi extends EventEmitter {
// Update source admin.
await admin.call(this);
// Launch server.
this.server.listen(this.config.port, async (err) => {
this.server.listen(this.config.port, err => {
if (err) {
this.log.debug(`⚠️ Server wasn't able to start properly.`);
this.log.debug('⚠️ Server wasn\'t able to start properly.');
this.log.error(err);
return this.stop();
}
this.log.info('Time: ' + new Date());
this.log.info('Launched in: ' + (Date.now() - this.config.launchedAt) + ' ms');
this.log.info(
'Launched in: ' + (Date.now() - this.config.launchedAt) + ' ms',
);
this.log.info('Environment: ' + this.config.environment);
this.log.info('Process PID: ' + process.pid);
this.log.info(`Version: ${this.config.info.strapi} (node v${this.config.info.node})`);
this.log.info(
`Version: ${this.config.info.strapi} (node v${
this.config.info.node
})`,
);
this.log.info('To shut down your server, press <CTRL> + C at any time');
console.log();
this.log.info(`☄️ Admin panel: ${this.config.admin.url}`);
this.log.info(`☄️ Admin panel: ${this.config.url}/admin`);
this.log.info(`⚡️ Server: ${this.config.url}`);
console.log();
@ -135,15 +140,11 @@ class Strapi extends EventEmitter {
if (cb && typeof cb === 'function') {
cb();
}
if (this.config.environment === 'development' && get(this.config.currentEnvironment, 'server.admin.autoOpen', true) !== false) {
await utils.openBrowser.call(this);
}
});
} catch (err) {
this.log.debug(`⛔️ Server wasn't able to start properly.`);
this.log.debug('⛔️ Server wasn\'t able to start properly.');
this.log.error(err);
console.log(err);
console.error(err);
this.stop();
}
}
@ -162,8 +163,10 @@ class Strapi extends EventEmitter {
this.server.on('error', err => {
if (err.code === 'EADDRINUSE') {
this.log.debug(`⛔️ Server wasn't able to start properly.`);
this.log.error(`The port ${err.port} is already used by another application.`);
this.log.debug('⛔️ Server wasn\'t able to start properly.');
this.log.error(
`The port ${err.port} is already used by another application.`,
);
this.stop();
return;
}
@ -187,7 +190,8 @@ class Strapi extends EventEmitter {
if (
cluster.isWorker &&
this.config.environment === 'development' &&
get(this.config, 'currentEnvironment.server.autoReload.enabled', true) === true
get(this.config, 'currentEnvironment.server.autoReload.enabled', true) ===
true
) {
process.send('stop');
}
@ -226,7 +230,10 @@ class Strapi extends EventEmitter {
await store.call(this);
// Initialize hooks and middlewares.
await Promise.all([initializeMiddlewares.call(this), initializeHooks.call(this)]);
await Promise.all([
initializeMiddlewares.call(this),
initializeHooks.call(this),
]);
// Harmonize plugins configuration.
await plugins.call(this);
@ -247,7 +254,11 @@ class Strapi extends EventEmitter {
if (
cluster.isWorker &&
this.config.environment === 'development' &&
get(this.config, 'currentEnvironment.server.autoReload.enabled', true) === true
get(
this.config,
'currentEnvironment.server.autoReload.enabled',
true,
) === true
) {
process.send('reload');
}
@ -280,8 +291,8 @@ class Strapi extends EventEmitter {
const timeoutMs = this.config.bootstrapTimeout || 3500;
const timer = setTimeout(() => {
this.log.warn(
`Bootstrap is taking unusually long to execute its callback ${timeoutMs} miliseconds).`,
);
`Bootstrap is taking unusually long to execute its callback ${timeoutMs} miliseconds).`,
);
this.log.warn('Perhaps you forgot to call it?');
}, timeoutMs);
@ -290,7 +301,9 @@ class Strapi extends EventEmitter {
try {
fn(err => {
if (ranBootstrapFn) {
this.log.error('You called the callback in `strapi.config.boostrap` more than once!');
this.log.error(
'You called the callback in `strapi.config.boostrap` more than once!',
);
return reject();
}
@ -302,7 +315,9 @@ class Strapi extends EventEmitter {
});
} catch (e) {
if (ranBootstrapFn) {
this.log.error('The bootstrap function threw an error after its callback was called.');
this.log.error(
'The bootstrap function threw an error after its callback was called.',
);
return reject(e);
}
@ -315,7 +330,9 @@ class Strapi extends EventEmitter {
});
return Promise.all(
Object.values(this.plugins).map(x => execBootstrap(get(x, 'config.functions.bootstrap'))),
Object.values(this.plugins).map(x =>
execBootstrap(get(x, 'config.functions.bootstrap')),
),
).then(() => execBootstrap(this.config.functions.bootstrap));
}
@ -335,14 +352,16 @@ class Strapi extends EventEmitter {
query(entity, plugin) {
if (!entity) {
return this.log.error(
`You can't call the query method without passing the model's name as a first argument.`,
'You can\'t call the query method without passing the model\'s name as a first argument.',
);
}
const model = entity.toLowerCase();
const Model =
get(strapi.plugins, [plugin, 'models', model]) || get(strapi, ['models', model]) || undefined;
get(strapi.plugins, [plugin, 'models', model]) ||
get(strapi, ['models', model]) ||
undefined;
if (!Model) {
return this.log.error(`The model ${model} can't be found.`);
@ -351,7 +370,9 @@ class Strapi extends EventEmitter {
const connector = Model.orm;
if (!connector) {
return this.log.error(`Impossible to determine the use ORM for the model ${model}.`);
return this.log.error(
`Impossible to determine the use ORM for the model ${model}.`,
);
}
// Get stack trace.
@ -363,7 +384,9 @@ class Strapi extends EventEmitter {
let pluginPath = undefined;
if (file.indexOf('strapi-plugin-') !== -1) {
pluginPath = file.split(path.sep).filter(x => x.indexOf('strapi-plugin-') !== -1)[0];
pluginPath = file
.split(path.sep)
.filter(x => x.indexOf('strapi-plugin-') !== -1)[0];
} else if (file.indexOf(path.sep + 'plugins' + path.sep) !== -1) {
const pathTerms = file.split(path.sep);
const index = pathTerms.indexOf('plugins');
@ -374,15 +397,22 @@ class Strapi extends EventEmitter {
}
if (!pluginPath) {
return this.log.error('Impossible to find the plugin where `strapi.query` has been called.');
return this.log.error(
'Impossible to find the plugin where `strapi.query` has been called.',
);
}
// Get plugin name.
const pluginName = pluginPath.replace('strapi-plugin-', '').toLowerCase();
const queries = get(this.plugins, `${pluginName}.config.queries.${connector}`);
const queries = get(
this.plugins,
`${pluginName}.config.queries.${connector}`,
);
if (!queries) {
return this.log.error(`There is no query available for the model ${model}.`);
return this.log.error(
`There is no query available for the model ${model}.`,
);
}
// Bind queries with the current model to allow the use of `this`.
@ -393,7 +423,7 @@ class Strapi extends EventEmitter {
{
orm: connector,
primaryKey: Model.primaryKey,
associations: Model.associations
associations: Model.associations,
},
);