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 }) => ({
graphql: {
amountLimit: 5,
amountLimit: 50,
depthLimit: 10,
apolloServer: {
tracing: true,

View File

@ -50,9 +50,7 @@ module.exports = strapi => {
});
_.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 schema = _.get(strapi.api[key], 'config.schema.graphql', {});
return attachMetadataToResolvers(schema, { api: key });
@ -68,10 +66,10 @@ module.exports = strapi => {
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
_.set(strapi, ['plugins', 'graphql', 'config', '_schema', 'graphql'], baseSchema);
_.set(strapi.plugins.graphql, 'config._schema.graphql', baseSchema);
},
initialize() {

View File

@ -494,8 +494,9 @@ const formatConnectionAggregator = function(fields, model, modelName) {
* }
*
*/
const formatModelConnectionsGQL = function({ fields, model, name, resolver }) {
const { globalId } = model;
const formatModelConnectionsGQL = function({ fields, model: contentType, name, resolver }) {
const { globalId } = contentType;
const model = strapi.getModel(contentType.uid);
const connectionGlobalId = `${globalId}Connection`;
@ -514,8 +515,6 @@ const formatModelConnectionsGQL = function({ fields, model, name, resolver }) {
}
modelConnectionTypes += groupByFormat.type;
const queryName = `${pluralName}Connection(sort: String, limit: Int, start: Int, where: JSON)`;
const connectionResolver = buildQueryResolver(`${pluralName}Connection.values`, resolver);
const connectionQueryName = `${pluralName}Connection`;
@ -524,7 +523,16 @@ const formatModelConnectionsGQL = function({ fields, model, name, resolver }) {
globalId: connectionGlobalId,
definition: modelConnectionTypes,
query: {
[queryName]: connectionGlobalId,
[`${pluralName}Connection`]: {
args: {
sort: 'String',
limit: 'Int',
start: 'Int',
where: 'JSON',
...(resolver.args || {}),
},
type: connectionGlobalId,
},
},
resolvers: {
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;
}
// pass extra args as ctx.query
if (options.input) {
ctx.query = convertToParams(_.omit(options, 'input'));
}
return ctx;
};

View File

@ -71,14 +71,38 @@ const fieldsToSDL = ({ fields, configurations, model }) => {
const operationToSDL = ({ fields, configurations }) => {
return Object.entries(fields)
.map(([key, value]) => {
const [attr] = key.split('(');
const attributeName = _.trim(attr);
if (typeof value === 'string') {
const [attr] = key.split('(');
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');
};
/**
* 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
* @param {string} definition field definition

View File

@ -12,8 +12,8 @@ const _ = require('lodash');
const graphql = require('graphql');
const PublicationState = require('../types/publication-state');
const Types = require('./type-builder');
const { buildModels } = require('./type-definitions');
const { mergeSchemas, createDefaultSchema, diffResolvers } = require('./utils');
const buildShadowCrud = require('./shadow-crud');
const { createDefaultSchema, diffResolvers } = require('./utils');
const { toSDL } = require('./schema-definitions');
const { buildQuery, buildMutation } = require('./resolvers-builder');
@ -27,11 +27,15 @@ const generateSchema = () => {
const isFederated = _.get(strapi.plugins.graphql.config, 'federation', 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 ctx = {
schema: _schema,
};
// Generate type definition and query/mutation for models.
const shadowCRUD = shadowCRUDEnabled ? buildShadowCrud(ctx) : createDefaultSchema();
// Extract custom definition, query or resolver.
const { definition, query, mutation, resolver = {} } = _schema;
@ -73,17 +77,19 @@ const generateSchema = () => {
${Types.addInput()}
${PublicationState.definition}
type AdminUser {
id: ID!
username: String
firstname: String!
lastname: String!
}
type Query {
${queryFields}
${query}
}
type Mutation {
${mutationFields}
${mutation}
@ -133,14 +139,6 @@ const writeGenerateSchema = schema => {
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 => {
// Transform object to only contain function.
return Object.keys(resolvers).reduce((acc, type) => {

View File

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

View File

@ -4,24 +4,39 @@ const _ = require('lodash');
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;
const { definition = '', query = {}, mutation = {}, resolvers = {} } = sub;
root.definition += '\n' + definition;
_.merge(root, {
object.definition += '\n' + definition;
_.merge(object, {
query,
mutation,
resolvers,
});
});
return root;
return object;
};
/**
* Returns an empty schema
* @returns {Schema}
*/
const createDefaultSchema = () => ({
definition: '',
query: {},

View File

@ -205,6 +205,20 @@ const addCreateLocalizationAction = contentType => {
_.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
* @param {object} contentType
@ -216,27 +230,61 @@ const addGraphqlLocalizationAction = contentType => {
return;
}
const typeName = _.capitalize(modelName);
_.mergeWith(
strapi.plugins.i18n.config.schema.graphql,
{
mutation: `
create${typeName}Localization(input: update${typeName}Input!): ${typeName}!
`,
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: {
[`create${typeName}Localization`]: {
resolver: `application::${modelName}.${modelName}.createLocalization`,
},
[`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 mutationName = `create${typeName}Localization`;
const mutationDef = `${mutationName}(input: update${typeName}Input!): ${typeName}!`;
const actionName = `${contentType.uid}.createLocalization`;
addGraphqlSchema({
mutation: mutationDef,
resolver: {
Mutation: {
[mutationName]: {
resolver: actionName,
},
},
},
(dest, src) => {
if (typeof dest === 'string') {
return `${dest}\n${src}`;
}
}
);
});
};
module.exports = {