V4/graphql configuration (#10896)

* Use a scalar to register the i18n locale arg

* Remove useless files & comments

* Use custom config for apollo server & the pagination (better handling of pagination)

* Fix missing strapi variable being transmitted to wrapResolvers/parseMiddlewares

* PR review comments
This commit is contained in:
Jean-Sébastien Herbaux 2021-09-07 11:23:49 +02:00 committed by GitHub
parent e2be869d3b
commit 2b715a6ee9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 154 additions and 223 deletions

View File

@ -1,13 +1,14 @@
'use strict';
const path = require('path');
module.exports = ({ env }) => ({
module.exports = () => ({
graphql: {
enabled: true,
config: {
amountLimit: 50,
depthLimit: 10,
endpoint: '/graphql',
defaultLimit: 25,
maxLimit: 100,
apolloServer: {
tracing: true,
},

View File

@ -15,21 +15,36 @@ const STRAPI_DEFAULTS = {
const paginationAttributes = ['start', 'limit', 'page', 'pageSize'];
const withMaxLimit = (limit, maxLimit = -1) => {
if (maxLimit === -1 || limit < maxLimit) {
return limit;
}
return maxLimit;
};
// Ensure minimum page & pageSize values (page >= 1, pageSize >= 0, start >= 0, limit >= 0)
const ensureMinValues = ({ start, limit }) => ({
start: Math.max(start, 0),
limit: Math.max(limit, 0),
limit: Math.max(limit, 1),
});
const withDefaultPagination = (args, defaults = {}) => {
const ensureMaxValues = (maxLimit = -1) => ({ start, limit }) => ({
start: Math.min(start, limit),
limit: withMaxLimit(limit, maxLimit),
});
const withDefaultPagination = (args, { defaults = {}, maxLimit = -1 } = {}) => {
const defaultValues = merge(STRAPI_DEFAULTS, defaults);
const usePagePagination = !isNil(args.page) || !isNil(args.pageSize);
const useOffsetPagination = !isNil(args.start) || !isNil(args.limit);
const ensureValidValues = pipe(ensureMinValues, ensureMaxValues(maxLimit));
// If there is no pagination attribute, don't modify the payload
if (!usePagePagination && !useOffsetPagination) {
return merge(args, defaultValues.offset);
return merge(args, ensureValidValues(defaultValues.offset));
}
// If there is page & offset pagination attributes, throw an error
@ -59,8 +74,8 @@ const withDefaultPagination = (args, defaults = {}) => {
const replacePaginationAttributes = pipe(
// Remove pagination attributes
omit(paginationAttributes),
// Merge the object with the new pagination + ensure minimum values (page >= 1, pageSize >= 0)
merge(ensureMinValues(pagination))
// Merge the object with the new pagination + ensure minimum & maximum values
merge(ensureValidValues(pagination))
);
return replacePaginationAttributes(args);

View File

@ -1,6 +1,6 @@
'use strict';
const { isEmpty, getOr } = require('lodash/fp');
const { isEmpty, mergeWith, isArray } = require('lodash/fp');
const { ApolloServer } = require('apollo-server-koa');
const {
ApolloServerPluginLandingPageLocalDefault,
@ -9,6 +9,12 @@ const {
const depthLimit = require('graphql-depth-limit');
const { graphqlUploadKoa } = require('graphql-upload');
const merge = mergeWith((a, b) => {
if (isArray(a) && isArray(b)) {
return a.concat(b);
}
});
module.exports = async strapi => {
// Generate the GraphQL schema for the content API
const schema = strapi
@ -22,10 +28,9 @@ module.exports = async strapi => {
return;
}
const config = getOr({}, 'config', strapi.plugin('graphql'));
const apolloServerConfig = getOr({}, 'apolloServer', config);
const { config } = strapi.plugin('graphql');
const serverParams = {
const defaultServerConfig = {
// Schema
schema,
@ -40,13 +45,8 @@ module.exports = async strapi => {
return ctx;
},
// Format & validation
formatError: err => {
const formatError = getOr(null, 'formatError', config);
return typeof formatError === 'function' ? formatError(err) : err;
},
validationRules: [depthLimit(config.depthLimit)],
// Validation
validationRules: [depthLimit(config('depthLimit'))],
// Misc
cors: false,
@ -59,11 +59,12 @@ module.exports = async strapi => {
? ApolloServerPluginLandingPageLocalDefault({ footer: false })
: ApolloServerPluginLandingPageProductionDefault({ footer: false }),
],
...apolloServerConfig,
};
const serverConfig = merge(defaultServerConfig, config('apolloServer', {}));
// Create a new Apollo server
const server = new ApolloServer(serverParams);
const server = new ApolloServer(serverConfig);
// Register the upload middleware
useUploadMiddleware(strapi, config);
@ -78,7 +79,7 @@ module.exports = async strapi => {
// Link the Apollo server & the Strapi app
server.applyMiddleware({
app: strapi.app,
path: config.endpoint,
path: config('endpoint', '/graphql'),
});
// Register destroy behavior
@ -92,13 +93,13 @@ module.exports = async strapi => {
/**
* Register the upload middleware powered by graphql-upload in Strapi
* @param {object} strapi
* @param {object} config
* @param {function} config
*/
const useUploadMiddleware = (strapi, config) => {
const uploadMiddleware = graphqlUploadKoa();
strapi.app.use((ctx, next) => {
if (ctx.path === config.endpoint) {
if (ctx.path === config('endpoint')) {
return uploadMiddleware(ctx, next);
}

View File

@ -1,12 +0,0 @@
{
"endpoint": "/graphql",
"shadowCRUD": true,
"playgroundAlways": false,
"depthLimit": 7,
"amountLimit": 100,
"shareEnabled": false,
"federation": false,
"apolloServer": {
"tracing": false
}
}

View File

@ -1,42 +0,0 @@
'use strict';
// eslint-disable-next-line node/no-extraneous-require
const loadUtils = require('@strapi/strapi/lib/load');
const _ = require('lodash');
const loadApisGraphqlConfig = appPath =>
loadUtils.loadFiles(appPath, 'api/**/config/*.graphql?(.js)');
const loadPluginsGraphqlConfig = async installedPlugins => {
const root = {};
for (let pluginName of installedPlugins) {
const pluginDir = loadUtils.findPackagePath(`@strapi/plugin-${pluginName}`);
const result = await loadUtils.loadFiles(pluginDir, 'config/*.graphql?(.js)');
_.set(root, ['plugins', pluginName], result);
}
return root;
};
const loadLocalPluginsGraphqlConfig = async appPath =>
loadUtils.loadFiles(appPath, 'plugins/**/config/*.graphql?(.js)');
const loadExtensions = async appPath =>
loadUtils.loadFiles(appPath, 'extensions/**/config/*.graphql?(.js)');
/**
* Loads the graphql config files
*/
module.exports = async ({ appPath, installedPlugins }) => {
const [apis, plugins, localPlugins, extensions] = await Promise.all([
loadApisGraphqlConfig(appPath),
loadPluginsGraphqlConfig(installedPlugins),
loadLocalPluginsGraphqlConfig(appPath),
loadExtensions(appPath),
]);
return _.merge({}, apis, plugins, extensions, localPlugins);
};

View File

@ -1,77 +0,0 @@
'use strict';
// const _ = require('lodash');
// const loadConfigs = require('./load-config');
//
// const attachMetadataToResolvers = (schema, { api, plugin }) => {
// const { resolver = {} } = schema;
// if (_.isEmpty(resolver)) return schema;
//
// Object.keys(resolver).forEach(type => {
// if (!_.isPlainObject(resolver[type])) return;
//
// Object.keys(resolver[type]).forEach(resolverName => {
// if (!_.isPlainObject(resolver[type][resolverName])) return;
//
// resolver[type][resolverName]['_metadatas'] = {
// api,
// plugin,
// };
// });
// });
//
// return schema;
// };
// todo[v4]: Rework how we load additional gql schema / customize current schema
module.exports = async (/*{ strapi }*/) => {
// const { appPath, installedPlugins } = strapi.config;
//
// // Load core utils.
//
// const { api, plugins, extensions } = await loadConfigs({
// appPath,
// installedPlugins,
// });
//
// _.merge(strapi, { api, plugins });
//
// // 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 });
// });
//
// const pluginsSchemas = Object.keys(strapi.plugins || {}).map(key => {
// const schema = _.get(strapi.plugins[key], 'config.schema.graphql', {});
// return attachMetadataToResolvers(schema, { plugin: key });
// });
//
// const extensionsSchemas = Object.keys(extensions || {}).map(key => {
// const schema = _.get(extensions[key], 'config.schema.graphql', {});
// return attachMetadataToResolvers(schema, { plugin: key });
// });
//
// const baseSchema = mergeSchemas([...pluginsSchemas, ...extensionsSchemas, ...apisSchemas]);
//
// // save the final schema in the plugin's config
// _.set(strapi.plugins.graphql, 'config._schema.graphql', baseSchema);
};
/**
* Merges a list of schemas
* @param {Array<Object>} schemas - The list of schemas to merge
*/
// const mergeSchemas = schemas => {
// return schemas.reduce((acc, el) => {
// const { definition, query, mutation, type, resolver } = el;
//
// return _.merge(acc, {
// definition: `${acc.definition || ''} ${definition || ''}`,
// query: `${acc.query || ''} ${query || ''}`,
// mutation: `${acc.mutation || ''} ${mutation || ''}`,
// type,
// resolver,
// });
// }, {});
// };

View File

@ -8,7 +8,7 @@ module.exports = ({ strapi }) => {
return {
/**
* Build a higher level type for a content type which contains both the attributes, the ID and the metadata
* Build a higher level type for a content type which contains the attributes, the ID and the metadata
* @param {object} contentType The content type which will be used to build its entity type
* @return {NexusObjectTypeDef}
*/

View File

@ -7,7 +7,7 @@ const {
} = require('@strapi/utils');
module.exports = ({ strapi }) => {
const getGraphQLService = strapi.plugin('graphql').service;
const { service: getService } = strapi.plugin('graphql');
return {
/**
@ -18,8 +18,8 @@ module.exports = ({ strapi }) => {
* @return {object}
*/
getContentTypeArgs(contentType, { multiple = true } = {}) {
const { naming } = getGraphQLService('utils');
const { args } = getGraphQLService('internals');
const { naming } = getService('utils');
const { args } = getService('internals');
const { kind, modelType } = contentType;
@ -71,7 +71,7 @@ module.exports = ({ strapi }) => {
* @return {Object<string, object>}
*/
getUniqueScalarAttributes: attributes => {
const { isStrapiScalar } = getGraphQLService('utils').attributes;
const { isStrapiScalar } = getService('utils').attributes;
const uniqueAttributes = entries(attributes).filter(
([, attribute]) => isStrapiScalar(attribute) && attribute.unique
@ -86,7 +86,7 @@ module.exports = ({ strapi }) => {
* @return {Object<string, string>}
*/
scalarAttributesToFiltersMap: mapValues(attribute => {
const { mappers, naming } = getGraphQLService('utils');
const { mappers, naming } = getService('utils');
const gqlScalar = mappers.strapiScalarToGraphQLScalar(attribute.type);
@ -97,7 +97,7 @@ module.exports = ({ strapi }) => {
* Apply basic transform to GQL args
*/
transformArgs(args, { contentType, usePagination = false } = {}) {
const { mappers } = getGraphQLService('utils');
const { mappers } = getService('utils');
const { pagination = {}, filters = {} } = args;
// Init
@ -105,9 +105,18 @@ module.exports = ({ strapi }) => {
// Pagination
if (usePagination) {
const defaultLimit = strapi.plugin('graphql').config('defaultLimit');
const maxLimit = strapi.plugin('graphql').config('maxLimit', -1);
Object.assign(
newArgs,
withDefaultPagination(pagination /*, config.get(graphql.pagination.defaults)*/)
withDefaultPagination(pagination, {
maxLimit,
defaults: {
offset: { limit: defaultLimit },
page: { pageSize: defaultLimit },
},
})
);
}

View File

@ -54,11 +54,11 @@ const KINDS = {
const GRAPHQL_SCALAR_OPERATORS = {
// ID
ID: ['eq', 'not'],
ID: ['eq', 'not', 'gt', 'lt'],
// Booleans
Boolean: ['eq', 'not'],
// Strings
String: ['eq', 'not', 'contains', 'startsWith', 'endsWith'],
String: ['eq', 'not', 'gt', 'lt', 'contains', 'startsWith', 'endsWith'],
// Numbers
Int: ['eq', 'not', 'gt', 'lt'],
Long: ['eq', 'not', 'gt', 'lt'],

View File

@ -76,7 +76,7 @@ module.exports = ({ strapi }) => {
// Add the extension's resolvers to the final schema
schema => addResolversToSchema(schema, extension.resolvers),
// Wrap resolvers if needed (auth, middlewares, policies...) as configured in the extension
schema => wrapResolvers({ schema, extension })
schema => wrapResolvers({ schema, strapi, extension })
)({ registry, extension });
};

View File

@ -9,10 +9,11 @@ const { createPoliciesMiddleware } = require('./policy');
* customized using the GraphQL extension service
* @param {object} options
* @param {GraphQLSchema} options.schema
* @param {object} options.strapi
* @param {object} options.extension
* @return {GraphQLSchema}
*/
const wrapResolvers = ({ schema, extension = {} }) => {
const wrapResolvers = ({ schema, strapi, extension = {} }) => {
// Get all the registered resolvers configuration
const { resolversConfig = {} } = extension;
@ -43,7 +44,7 @@ const wrapResolvers = ({ schema, extension = {} }) => {
const { resolve: baseResolver = get(fieldName) } = fieldDefinition;
const middlewares = parseMiddlewares(resolverConfig);
const middlewares = parseMiddlewares(resolverConfig, strapi);
// Generate the policy middleware
const policyMiddleware = createPoliciesMiddleware(resolverConfig, { strapi });
@ -84,9 +85,10 @@ const wrapResolvers = ({ schema, extension = {} }) => {
/**
* Get & parse middlewares definitions from the resolver's config
* @param {object} resolverConfig
* @param {object} strapi
* @return {function[]}
*/
const parseMiddlewares = resolverConfig => {
const parseMiddlewares = (resolverConfig, strapi) => {
const resolverMiddlewares = getOr([], 'middlewares', resolverConfig);
// TODO: [v4] to factorize with compose endpoints (routes)

View File

@ -2,7 +2,7 @@
const { camelCase, upperFirst, lowerFirst, pipe, get } = require('lodash/fp');
const { toSingular, toPlural } = require('../old/naming');
const { toSingular } = require('../old/naming');
module.exports = ({ strapi }) => {
/**
@ -23,15 +23,20 @@ module.exports = ({ strapi }) => {
/**
* Build the base type name for a given content type
* @param {object} contentType
* @param {object} options
* @param {'singular' | 'plural'} options.plurality
* @return {string}
*/
const getTypeName = contentType => {
const getTypeName = (contentType, { plurality = 'singular' } = {}) => {
const plugin = get('plugin', contentType);
const modelName = get('modelName', contentType);
const singularName = get('info.singularName', contentType);
const name =
plurality === 'singular'
? get('info.singularName', contentType)
: get('info.pluralName', contentType);
const transformedPlugin = upperFirst(camelCase(plugin));
const transformedModelName = upperFirst(singularName || toSingular(modelName));
const transformedModelName = upperFirst(camelCase(name || toSingular(modelName)));
return `${transformedPlugin}${transformedModelName}`;
};
@ -207,10 +212,10 @@ module.exports = ({ strapi }) => {
}
const getCustomTypeName = pipe(
getTypeName,
plurality === 'plural' ? toPlural : toSingular,
ct => getTypeName(ct, { plurality }),
firstLetterCase === 'upper' ? upperFirst : lowerFirst
);
return contentType => `${prefix}${getCustomTypeName(contentType)}${suffix}`;
};

View File

@ -1,20 +1,11 @@
'use strict';
const bootstrap = require('./server/bootstrap');
const register = require('./server/register');
const services = require('./server/services');
module.exports = (/* strapi, config */) => {
return {
bootstrap,
register,
services,
// destroy: () => {},
// config: {},
// routes: [],
// controllers: {},
// policies: {},
// middlewares: {},
// contentTypes: {},
};
};

View File

@ -1,47 +1,85 @@
'use strict';
const { propEq, identity } = require('lodash/fp');
const LOCALE_SCALAR_TYPENAME = 'Locale';
const LOCALE_ARG_PLUGIN_NAME = 'I18NLocaleArg';
module.exports = ({ strapi }) => ({
register() {
const useExtension = strapi
strapi
.plugin('graphql')
.service('extension')
.for('content-api').use;
.for('content-api')
.use(({ nexus, typeRegistry }) => {
const i18nLocaleArgPlugin = createI18nLocaleArgPlugin({ nexus, strapi, typeRegistry });
const i18nLocaleScalar = createLocaleScalar({ nexus, strapi });
const { isLocalizedContentType } = strapi.plugin('i18n').service('content-types');
useExtension(({ nexus, typeRegistry }) => {
/**
* Adds a "locale" arg to localized queries and mutations
* @param {object} config
*/
const addLocaleArg = config => {
const { parentType } = config;
// Only target queries or mutations
if (parentType !== 'Query' && parentType !== 'Mutation') {
return;
}
const contentType = typeRegistry.get(config.type).config.contentType;
// Ignore non-localized content types
if (!isLocalizedContentType(contentType)) {
return;
}
config.args.locale = nexus.stringArg();
};
const i18nPlugin = nexus.plugin({
name: 'i18nPlugin',
onAddOutputField(config) {
// Add the locale arg to the queries on localized CTs
addLocaleArg(config);
},
return {
plugins: [i18nLocaleArgPlugin],
types: [i18nLocaleScalar],
};
});
return { plugins: [i18nPlugin] };
});
},
});
const createLocaleScalar = ({ nexus, strapi }) => {
const locales = strapi
.plugin('i18n')
.service('iso-locales')
.getIsoLocales();
return nexus.scalarType({
name: LOCALE_SCALAR_TYPENAME,
description: 'A string used to identify an i18n locale',
serialize: identity,
parseValue: identity,
parseLiteral(ast) {
if (ast.kind !== 'StringValue') {
throw new TypeError('Locale cannot represent non string type');
}
const isValidLocale = locales.find(propEq('code', ast.value));
if (!isValidLocale) {
throw new TypeError('Unknown locale supplied');
}
return ast.value;
},
});
};
const createI18nLocaleArgPlugin = ({ nexus, strapi, typeRegistry }) => {
const { isLocalizedContentType } = strapi.plugin('i18n').service('content-types');
const addLocaleArg = config => {
const { parentType } = config;
// Only target queries or mutations
if (parentType !== 'Query' && parentType !== 'Mutation') {
return;
}
const contentType = typeRegistry.get(config.type).config.contentType;
// Ignore non-localized content types
if (!isLocalizedContentType(contentType)) {
return;
}
config.args.locale = nexus.arg({ type: LOCALE_SCALAR_TYPENAME });
};
return nexus.plugin({
name: LOCALE_ARG_PLUGIN_NAME,
onAddOutputField(config) {
// Add the locale arg to the queries on localized CTs
addLocaleArg(config);
},
});
};