Use objects instead of strings to declare queries & mutattions

This commit is contained in:
Alexandre Bodin 2021-04-07 12:06:32 +02:00
parent a408dc1940
commit 02342ace0a
10 changed files with 271 additions and 103 deletions

View File

@ -1,6 +1,6 @@
module.exports = ({ env }) => ({ module.exports = ({ env }) => ({
graphql: { graphql: {
amountLimit: 5, amountLimit: 50,
depthLimit: 10, depthLimit: 10,
apolloServer: { apolloServer: {
tracing: true, tracing: true,

View File

@ -50,9 +50,7 @@ module.exports = strapi => {
}); });
_.merge(strapi, { api, plugins }); _.merge(strapi, { api, plugins });
/* // Create a merge of all the GraphQL configuration.
* Create a merge of all the GraphQL configuration.
*/
const apisSchemas = Object.keys(strapi.api || {}).map(key => { const apisSchemas = Object.keys(strapi.api || {}).map(key => {
const schema = _.get(strapi.api[key], 'config.schema.graphql', {}); const schema = _.get(strapi.api[key], 'config.schema.graphql', {});
return attachMetadataToResolvers(schema, { api: key }); return attachMetadataToResolvers(schema, { api: key });
@ -68,10 +66,10 @@ module.exports = strapi => {
return attachMetadataToResolvers(schema, { plugin: key }); return attachMetadataToResolvers(schema, { plugin: key });
}); });
const baseSchema = mergeSchemas([...apisSchemas, ...pluginsSchemas, ...extensionsSchemas]); const baseSchema = mergeSchemas([...pluginsSchemas, ...extensionsSchemas, ...apisSchemas]);
// save the final schema in the plugin's config // save the final schema in the plugin's config
_.set(strapi, ['plugins', 'graphql', 'config', '_schema', 'graphql'], baseSchema); _.set(strapi.plugins.graphql, 'config._schema.graphql', baseSchema);
}, },
initialize() { initialize() {

View File

@ -494,8 +494,9 @@ const formatConnectionAggregator = function(fields, model, modelName) {
* } * }
* *
*/ */
const formatModelConnectionsGQL = function({ fields, model, name, resolver }) { const formatModelConnectionsGQL = function({ fields, model: contentType, name, resolver }) {
const { globalId } = model; const { globalId } = contentType;
const model = strapi.getModel(contentType.uid);
const connectionGlobalId = `${globalId}Connection`; const connectionGlobalId = `${globalId}Connection`;
@ -514,8 +515,6 @@ const formatModelConnectionsGQL = function({ fields, model, name, resolver }) {
} }
modelConnectionTypes += groupByFormat.type; modelConnectionTypes += groupByFormat.type;
const queryName = `${pluralName}Connection(sort: String, limit: Int, start: Int, where: JSON)`;
const connectionResolver = buildQueryResolver(`${pluralName}Connection.values`, resolver); const connectionResolver = buildQueryResolver(`${pluralName}Connection.values`, resolver);
const connectionQueryName = `${pluralName}Connection`; const connectionQueryName = `${pluralName}Connection`;
@ -524,7 +523,16 @@ const formatModelConnectionsGQL = function({ fields, model, name, resolver }) {
globalId: connectionGlobalId, globalId: connectionGlobalId,
definition: modelConnectionTypes, definition: modelConnectionTypes,
query: { query: {
[queryName]: connectionGlobalId, [`${pluralName}Connection`]: {
args: {
sort: 'String',
limit: 'Int',
start: 'Int',
where: 'JSON',
...(resolver.args || {}),
},
type: connectionGlobalId,
},
}, },
resolvers: { resolvers: {
Query: { Query: {

View File

@ -0,0 +1,8 @@
'use strict';
const { createAsyncSeriesWaterfallHook } = require('strapi-utils').hooks;
module.exports = {
createQuery: createAsyncSeriesWaterfallHook(),
createMutation: createAsyncSeriesWaterfallHook(),
};

View File

@ -75,6 +75,11 @@ const buildMutationContext = ({ options, graphqlContext }) => {
ctx.request.body = options; ctx.request.body = options;
} }
// pass extra args as ctx.query
if (options.input) {
ctx.query = convertToParams(_.omit(options, 'input'));
}
return ctx; return ctx;
}; };

View File

@ -71,14 +71,38 @@ const fieldsToSDL = ({ fields, configurations, model }) => {
const operationToSDL = ({ fields, configurations }) => { const operationToSDL = ({ fields, configurations }) => {
return Object.entries(fields) return Object.entries(fields)
.map(([key, value]) => { .map(([key, value]) => {
if (typeof value === 'string') {
const [attr] = key.split('('); const [attr] = key.split('(');
const attributeName = _.trim(attr); const attributeName = _.trim(attr);
return applyMetadatas(`${key}: ${value}`, configurations[attributeName]); return applyMetadatas(`${key}: ${value}`, configurations[attributeName]);
} else {
const { args = {}, type } = value;
const query = `${key}${argumentsToSDL(args)}: ${type}`;
return applyMetadatas(query, configurations[key]);
}
}) })
.join('\n'); .join('\n');
}; };
/**
* Converts an object of arguments into graphql SDL
* @param {object} args arguments
* @returns {string}
*/
const argumentsToSDL = args => {
if (_.isEmpty(args)) {
return '';
}
const sdlArgs = Object.entries(args)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
return `(${sdlArgs})`;
};
/** /**
* Applies description and deprecated to a field definition * Applies description and deprecated to a field definition
* @param {string} definition field definition * @param {string} definition field definition

View File

@ -12,8 +12,8 @@ const _ = require('lodash');
const graphql = require('graphql'); const graphql = require('graphql');
const PublicationState = require('../types/publication-state'); const PublicationState = require('../types/publication-state');
const Types = require('./type-builder'); const Types = require('./type-builder');
const { buildModels } = require('./type-definitions'); const buildShadowCrud = require('./shadow-crud');
const { mergeSchemas, createDefaultSchema, diffResolvers } = require('./utils'); const { createDefaultSchema, diffResolvers } = require('./utils');
const { toSDL } = require('./schema-definitions'); const { toSDL } = require('./schema-definitions');
const { buildQuery, buildMutation } = require('./resolvers-builder'); const { buildQuery, buildMutation } = require('./resolvers-builder');
@ -27,11 +27,15 @@ const generateSchema = () => {
const isFederated = _.get(strapi.plugins.graphql.config, 'federation', false); const isFederated = _.get(strapi.plugins.graphql.config, 'federation', false);
const shadowCRUDEnabled = strapi.plugins.graphql.config.shadowCRUD !== false; const shadowCRUDEnabled = strapi.plugins.graphql.config.shadowCRUD !== false;
// Generate type definition and query/mutation for models.
const shadowCRUD = shadowCRUDEnabled ? buildModelsShadowCRUD() : createDefaultSchema();
const _schema = strapi.plugins.graphql.config._schema.graphql; const _schema = strapi.plugins.graphql.config._schema.graphql;
const ctx = {
schema: _schema,
};
// Generate type definition and query/mutation for models.
const shadowCRUD = shadowCRUDEnabled ? buildShadowCrud(ctx) : createDefaultSchema();
// Extract custom definition, query or resolver. // Extract custom definition, query or resolver.
const { definition, query, mutation, resolver = {} } = _schema; const { definition, query, mutation, resolver = {} } = _schema;
@ -80,10 +84,12 @@ const generateSchema = () => {
firstname: String! firstname: String!
lastname: String! lastname: String!
} }
type Query { type Query {
${queryFields} ${queryFields}
${query} ${query}
} }
type Mutation { type Mutation {
${mutationFields} ${mutationFields}
${mutation} ${mutation}
@ -133,14 +139,6 @@ const writeGenerateSchema = schema => {
return strapi.fs.writeAppFile('exports/graphql/schema.graphql', printSchema); return strapi.fs.writeAppFile('exports/graphql/schema.graphql', printSchema);
}; };
const buildModelsShadowCRUD = () => {
const models = Object.values(strapi.contentTypes).filter(model => model.plugin !== 'admin');
const components = Object.values(strapi.components);
return mergeSchemas(createDefaultSchema(), ...buildModels([...models, ...components]));
};
const buildResolvers = resolvers => { const buildResolvers = resolvers => {
// Transform object to only contain function. // Transform object to only contain function.
return Object.keys(resolvers).reduce((acc, type) => { return Object.keys(resolvers).reduce((acc, type) => {

View File

@ -1,11 +1,5 @@
'use strict'; 'use strict';
/**
* GraphQL.js service
*
* @description: A set of functions similar to controller's actions to avoid code duplication.
*/
const _ = require('lodash'); const _ = require('lodash');
const { contentTypes } = require('strapi-utils'); const { contentTypes } = require('strapi-utils');
@ -18,16 +12,46 @@ const DynamicZoneScalar = require('../types/dynamiczoneScalar');
const { formatModelConnectionsGQL } = require('./build-aggregation'); const { formatModelConnectionsGQL } = require('./build-aggregation');
const types = require('./type-builder'); const types = require('./type-builder');
const { mergeSchemas, convertToParams, convertToQuery, amountLimiting } = require('./utils'); const {
actionExists,
mergeSchemas,
convertToParams,
convertToQuery,
amountLimiting,
createDefaultSchema,
} = require('./utils');
const { toSDL, getTypeDescription } = require('./schema-definitions'); const { toSDL, getTypeDescription } = require('./schema-definitions');
const { toSingular, toPlural } = require('./naming'); const { toSingular, toPlural } = require('./naming');
const { buildQuery, buildMutation } = require('./resolvers-builder'); const { buildQuery, buildMutation } = require('./resolvers-builder');
const { actionExists } = require('./utils');
const OPTIONS = Symbol(); const OPTIONS = Symbol();
const FIND_QUERY_ARGUMENTS = `(sort: String, limit: Int, start: Int, where: JSON, publicationState: PublicationState)`; const FIND_QUERY_ARGUMENTS = {
const FIND_ONE_QUERY_ARGUMENTS = `(id: ID!, publicationState: PublicationState)`; sort: 'String',
limit: 'Int',
start: 'Int',
where: 'JSON',
publicationState: 'PublicationState',
};
const FIND_ONE_QUERY_ARGUMENTS = {
id: 'ID!',
publicationState: 'PublicationState',
};
/**
* Builds a graphql schema from all the contentTypes & components loaded
* @param {{ schema: object }} ctx
* @returns {object}
*/
const buildShadowCrud = ctx => {
const models = Object.values(strapi.contentTypes).filter(model => model.plugin !== 'admin');
const components = Object.values(strapi.components);
const allSchemas = buildModels([...models, ...components], ctx);
return mergeSchemas(createDefaultSchema(), ...allSchemas);
};
const assignOptions = (element, parent) => { const assignOptions = (element, parent) => {
if (Array.isArray(element)) { if (Array.isArray(element)) {
@ -41,10 +65,18 @@ const isQueryEnabled = (schema, name) => {
return _.get(schema, `resolver.Query.${name}`) !== false; return _.get(schema, `resolver.Query.${name}`) !== false;
}; };
const getQueryInfo = (schema, name) => {
return _.get(schema, `resolver.Query.${name}`, {});
};
const isMutationEnabled = (schema, name) => { const isMutationEnabled = (schema, name) => {
return _.get(schema, `resolver.Mutation.${name}`) !== false; return _.get(schema, `resolver.Mutation.${name}`) !== false;
}; };
const getMutationInfo = (schema, name) => {
return _.get(schema, `resolver.Mutation.${name}`, {});
};
const isNotPrivate = _.curry((model, attributeName) => { const isNotPrivate = _.curry((model, attributeName) => {
return !contentTypes.isPrivateAttribute(model, attributeName); return !contentTypes.isPrivateAttribute(model, attributeName);
}); });
@ -294,19 +326,19 @@ const buildAssocResolvers = model => {
* *
* @return Object * @return Object
*/ */
const buildModels = models => { const buildModels = (models, ctx) => {
return models.map(model => { return models.map(model => {
const { kind, modelType } = model; const { kind, modelType } = model;
if (modelType === 'component') { if (modelType === 'component') {
return buildComponent(model); return buildComponent(model, ctx);
} }
switch (kind) { switch (kind) {
case 'singleType': case 'singleType':
return buildSingleType(model); return buildSingleType(model, ctx);
default: default:
return buildCollectionType(model); return buildCollectionType(model, ctx);
} }
}); });
}; };
@ -353,14 +385,12 @@ const buildComponent = component => {
return schema; return schema;
}; };
const buildSingleType = model => { const buildSingleType = (model, ctx) => {
const { uid, modelName } = model; const { uid, modelName } = model;
const singularName = toSingular(modelName); const singularName = toSingular(modelName);
const _schema = _.cloneDeep(_.get(strapi.plugins, 'graphql.config._schema.graphql', {})); const globalType = _.get(ctx.schema, `type.${model.globalId}`, {});
const globalType = _.get(_schema, `type.${model.globalId}`, {});
const localSchema = buildModelDefinition(model, globalType); const localSchema = buildModelDefinition(model, globalType);
@ -369,22 +399,32 @@ const buildSingleType = model => {
return localSchema; return localSchema;
} }
if (isQueryEnabled(_schema, singularName)) { if (isQueryEnabled(ctx.schema, singularName)) {
const resolver = buildQuery(singularName, { const resolverOpts = {
resolver: `${uid}.find`, resolver: `${uid}.find`,
..._.get(_schema, `resolver.Query.${singularName}`, {}), ...getQueryInfo(ctx.schema, singularName),
}); };
_.merge(localSchema, { const resolver = buildQuery(singularName, resolverOpts);
const query = {
query: { query: {
[`${singularName}(publicationState: PublicationState)`]: model.globalId, [singularName]: {
args: {
publicationState: 'PublicationState',
...(resolverOpts.args || {}),
},
type: model.globalId,
},
}, },
resolvers: { resolvers: {
Query: { Query: {
[singularName]: wrapPublicationStateResolver(resolver), [singularName]: wrapPublicationStateResolver(resolver),
}, },
}, },
}); };
_.merge(localSchema, query);
} }
// Add model Input definition. // Add model Input definition.
@ -392,7 +432,7 @@ const buildSingleType = model => {
// build every mutation // build every mutation
['update', 'delete'].forEach(action => { ['update', 'delete'].forEach(action => {
const mutationSchema = buildMutationTypeDef({ model, action }, { _schema }); const mutationSchema = buildMutationTypeDef({ model, action }, ctx);
mergeSchemas(localSchema, mutationSchema); mergeSchemas(localSchema, mutationSchema);
}); });
@ -400,15 +440,13 @@ const buildSingleType = model => {
return localSchema; return localSchema;
}; };
const buildCollectionType = model => { const buildCollectionType = (model, ctx) => {
const { plugin, modelName, uid } = model; const { plugin, modelName, uid } = model;
const singularName = toSingular(modelName); const singularName = toSingular(modelName);
const pluralName = toPlural(modelName); const pluralName = toPlural(modelName);
const _schema = _.cloneDeep(_.get(strapi.plugins, 'graphql.config._schema.graphql', {})); const globalType = _.get(ctx.schema, `type.${model.globalId}`, {});
const globalType = _.get(_schema, `type.${model.globalId}`, {});
const localSchema = buildModelDefinition(model, globalType); const localSchema = buildModelDefinition(model, globalType);
const { typeDefObj } = localSchema; const { typeDefObj } = localSchema;
@ -418,46 +456,65 @@ const buildCollectionType = model => {
return localSchema; return localSchema;
} }
if (isQueryEnabled(_schema, singularName)) { if (isQueryEnabled(ctx.schema, singularName)) {
const resolverOpts = { const resolverOpts = {
resolver: `${uid}.findOne`, resolver: `${uid}.findOne`,
..._.get(_schema, `resolver.Query.${singularName}`, {}), ...getQueryInfo(ctx.schema, singularName),
}; };
if (actionExists(resolverOpts)) { if (actionExists(resolverOpts)) {
const resolver = buildQuery(singularName, resolverOpts); const resolver = buildQuery(singularName, resolverOpts);
_.merge(localSchema, {
const query = {
query: { query: {
// TODO: support all the unique fields [singularName]: {
[`${singularName}${FIND_ONE_QUERY_ARGUMENTS}`]: model.globalId, args: {
...FIND_ONE_QUERY_ARGUMENTS,
...(resolverOpts.args || {}),
},
type: model.globalId,
},
}, },
resolvers: { resolvers: {
Query: { Query: {
[singularName]: wrapPublicationStateResolver(resolver), [singularName]: wrapPublicationStateResolver(resolver),
}, },
}, },
}); };
_.merge(localSchema, query);
} }
} }
if (isQueryEnabled(_schema, pluralName)) { if (isQueryEnabled(ctx.schema, pluralName)) {
const resolverOpts = { const resolverOpts = {
resolver: `${uid}.find`, resolver: `${uid}.find`,
..._.get(_schema, `resolver.Query.${pluralName}`, {}), ...getQueryInfo(ctx.schema, pluralName),
}; };
if (actionExists(resolverOpts)) { if (actionExists(resolverOpts)) {
const resolver = buildQuery(pluralName, resolverOpts); const resolver = buildQuery(pluralName, resolverOpts);
_.merge(localSchema, {
const query = {
query: { query: {
[`${pluralName}${FIND_QUERY_ARGUMENTS}`]: `[${model.globalId}]`, [pluralName]: {
args: {
...FIND_QUERY_ARGUMENTS,
...(resolverOpts.args || {}),
},
type: `[${model.globalId}]`,
},
}, },
resolvers: { resolvers: {
Query: { Query: {
[pluralName]: wrapPublicationStateResolver(resolver), [pluralName]: wrapPublicationStateResolver(resolver),
}, },
}, },
}); };
if (isQueryEnabled(_schema, `${pluralName}Connection`)) { _.merge(localSchema, query);
if (isQueryEnabled(ctx.schema, `${pluralName}Connection`)) {
// Generate the aggregation for the given model // Generate the aggregation for the given model
const aggregationSchema = formatModelConnectionsGQL({ const aggregationSchema = formatModelConnectionsGQL({
fields: typeDefObj, fields: typeDefObj,
@ -477,7 +534,7 @@ const buildCollectionType = model => {
// build every mutation // build every mutation
['create', 'update', 'delete'].forEach(action => { ['create', 'update', 'delete'].forEach(action => {
const mutationSchema = buildMutationTypeDef({ model, action }, { _schema }); const mutationSchema = buildMutationTypeDef({ model, action }, ctx);
mergeSchemas(localSchema, mutationSchema); mergeSchemas(localSchema, mutationSchema);
}); });
@ -487,14 +544,14 @@ const buildCollectionType = model => {
// TODO: // TODO:
// - Implement batch methods (need to update the content-manager as well). // - Implement batch methods (need to update the content-manager as well).
// - Implement nested transactional methods (create/update). // - Implement nested transactional methods (create/update).
const buildMutationTypeDef = ({ model, action }, { _schema }) => { const buildMutationTypeDef = ({ model, action }, ctx) => {
const capitalizedName = _.upperFirst(toSingular(model.modelName)); const capitalizedName = _.upperFirst(toSingular(model.modelName));
const mutationName = `${action}${capitalizedName}`; const mutationName = `${action}${capitalizedName}`;
const resolverOpts = { const resolverOpts = {
resolver: `${model.uid}.${action}`, resolver: `${model.uid}.${action}`,
transformOutput: result => ({ [toSingular(model.modelName)]: result }), transformOutput: result => ({ [toSingular(model.modelName)]: result }),
..._.get(_schema, `resolver.Mutation.${mutationName}`, {}), ...getMutationInfo(ctx.schema, mutationName),
}; };
if (!actionExists(resolverOpts)) { if (!actionExists(resolverOpts)) {
@ -509,7 +566,7 @@ const buildMutationTypeDef = ({ model, action }, { _schema }) => {
}); });
// ignore if disabled // ignore if disabled
if (!isMutationEnabled(_schema, mutationName)) { if (!isMutationEnabled(ctx.schema, mutationName)) {
return { return {
definition, definition,
}; };
@ -517,15 +574,24 @@ const buildMutationTypeDef = ({ model, action }, { _schema }) => {
const { kind } = model; const { kind } = model;
let mutationDef = `${mutationName}(input: ${mutationName}Input)`; const args = {};
if (kind === 'singleType' && action === 'delete') {
mutationDef = mutationName; if (kind !== 'singleType' || action !== 'delete') {
Object.assign(args, {
input: `${mutationName}Input`,
});
} }
return { return {
definition, definition,
mutation: { mutation: {
[mutationDef]: `${mutationName}Payload`, [mutationName]: {
args: {
...args,
...(resolverOpts.args || {}),
},
type: `${mutationName}Payload`,
},
}, },
resolvers: { resolvers: {
Mutation: { Mutation: {
@ -535,6 +601,4 @@ const buildMutationTypeDef = ({ model, action }, { _schema }) => {
}; };
}; };
module.exports = { module.exports = buildShadowCrud;
buildModels,
};

View File

@ -4,24 +4,39 @@ const _ = require('lodash');
const { QUERY_OPERATORS } = require('strapi-utils'); const { QUERY_OPERATORS } = require('strapi-utils');
/** /**
* Merges * @typedef {object} Schema
* @property {object} resolvers
* @property {object} mutation
* @property {object} query
* @property {string} definition
*/ */
const mergeSchemas = (root, ...subs) => {
subs.forEach(sub => { /**
* Merges strapi graphql schema together
* @param {Schema} object - destination object
* @param {Schema[]} sources - source objects to merge into the destination object
* @returns {Schema}
*/
const mergeSchemas = (object, ...sources) => {
sources.forEach(sub => {
if (_.isEmpty(sub)) return; if (_.isEmpty(sub)) return;
const { definition = '', query = {}, mutation = {}, resolvers = {} } = sub; const { definition = '', query = {}, mutation = {}, resolvers = {} } = sub;
root.definition += '\n' + definition; object.definition += '\n' + definition;
_.merge(root, { _.merge(object, {
query, query,
mutation, mutation,
resolvers, resolvers,
}); });
}); });
return root; return object;
}; };
/**
* Returns an empty schema
* @returns {Schema}
*/
const createDefaultSchema = () => ({ const createDefaultSchema = () => ({
definition: '', definition: '',
query: {}, query: {},

View File

@ -205,6 +205,20 @@ const addCreateLocalizationAction = contentType => {
_.set(strapi, coreApiControllerPath, handler); _.set(strapi, coreApiControllerPath, handler);
}; };
const mergeCustomizer = (dest, src) => {
if (typeof dest === 'string') {
return `${dest}\n${src}`;
}
};
/**
* Add a graphql schema to the plugin's global graphl schema to be processed
* @param {object} schema
*/
const addGraphqlSchema = schema => {
_.mergeWith(strapi.plugins.i18n.config.schema.graphql, schema, mergeCustomizer);
};
/** /**
* Add localization mutation & filters to use with the graphql plugin * Add localization mutation & filters to use with the graphql plugin
* @param {object} contentType * @param {object} contentType
@ -216,27 +230,61 @@ const addGraphqlLocalizationAction = contentType => {
return; return;
} }
const { toSingular, toPlural } = strapi.plugins.graphql.services.naming;
// We use a string instead of an enum as the locales can be changed in the admin
// NOTE: We could use a custom scalar so the validation becomes dynamic
const localeArgs = {
args: {
locale: 'String',
},
};
// add locale arguments in the existing queries
if (isSingleType(contentType)) {
const queryName = toSingular(modelName);
const mutationSuffix = _.upperFirst(queryName);
addGraphqlSchema({
resolver: {
Query: {
[queryName]: localeArgs,
},
Mutation: {
[`update${mutationSuffix}`]: localeArgs,
[`delete${mutationSuffix}`]: localeArgs,
},
},
});
} else {
const queryName = toPlural(modelName);
addGraphqlSchema({
resolver: {
Query: {
[queryName]: localeArgs,
[`${queryName}Connection`]: localeArgs,
},
},
});
}
// add new mutation to create a localization
const typeName = _.capitalize(modelName); const typeName = _.capitalize(modelName);
_.mergeWith( const mutationName = `create${typeName}Localization`;
strapi.plugins.i18n.config.schema.graphql, const mutationDef = `${mutationName}(input: update${typeName}Input!): ${typeName}!`;
{ const actionName = `${contentType.uid}.createLocalization`;
mutation: `
create${typeName}Localization(input: update${typeName}Input!): ${typeName}! addGraphqlSchema({
`, mutation: mutationDef,
resolver: { resolver: {
Mutation: { Mutation: {
[`create${typeName}Localization`]: { [mutationName]: {
resolver: `application::${modelName}.${modelName}.createLocalization`, resolver: actionName,
}, },
}, },
}, },
}, });
(dest, src) => {
if (typeof dest === 'string') {
return `${dest}\n${src}`;
}
}
);
}; };
module.exports = { module.exports = {