diff --git a/examples/getstarted/config/plugins.js b/examples/getstarted/config/plugins.js index 5aae0b4a8e..a7a1db2875 100644 --- a/examples/getstarted/config/plugins.js +++ b/examples/getstarted/config/plugins.js @@ -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, }, diff --git a/packages/core/utils/lib/pagination.js b/packages/core/utils/lib/pagination.js index 18f9dc5142..22d7d8c9c6 100644 --- a/packages/core/utils/lib/pagination.js +++ b/packages/core/utils/lib/pagination.js @@ -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); diff --git a/packages/plugins/graphql/server/bootstrap.js b/packages/plugins/graphql/server/bootstrap.js index 2e3e99b1b9..1c56bb7716 100644 --- a/packages/plugins/graphql/server/bootstrap.js +++ b/packages/plugins/graphql/server/bootstrap.js @@ -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); } diff --git a/packages/plugins/graphql/server/config/settings.json b/packages/plugins/graphql/server/config/settings.json deleted file mode 100644 index fbad94fd96..0000000000 --- a/packages/plugins/graphql/server/config/settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "endpoint": "/graphql", - "shadowCRUD": true, - "playgroundAlways": false, - "depthLimit": 7, - "amountLimit": 100, - "shareEnabled": false, - "federation": false, - "apolloServer": { - "tracing": false - } -} diff --git a/packages/plugins/graphql/server/load-config.js b/packages/plugins/graphql/server/load-config.js deleted file mode 100644 index 8b818f5504..0000000000 --- a/packages/plugins/graphql/server/load-config.js +++ /dev/null @@ -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); -}; diff --git a/packages/plugins/graphql/server/register.js b/packages/plugins/graphql/server/register.js deleted file mode 100644 index bf96d6e90d..0000000000 --- a/packages/plugins/graphql/server/register.js +++ /dev/null @@ -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} 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, -// }); -// }, {}); -// }; diff --git a/packages/plugins/graphql/server/services/builders/entity.js b/packages/plugins/graphql/server/services/builders/entity.js index da706fbc2a..dd8f0473a7 100644 --- a/packages/plugins/graphql/server/services/builders/entity.js +++ b/packages/plugins/graphql/server/services/builders/entity.js @@ -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} */ diff --git a/packages/plugins/graphql/server/services/builders/utils.js b/packages/plugins/graphql/server/services/builders/utils.js index cf2fa44a62..712630db11 100644 --- a/packages/plugins/graphql/server/services/builders/utils.js +++ b/packages/plugins/graphql/server/services/builders/utils.js @@ -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} */ 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} */ 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 }, + }, + }) ); } diff --git a/packages/plugins/graphql/server/services/constants.js b/packages/plugins/graphql/server/services/constants.js index 1a0308b2c1..6f0dcffc45 100644 --- a/packages/plugins/graphql/server/services/constants.js +++ b/packages/plugins/graphql/server/services/constants.js @@ -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'], diff --git a/packages/plugins/graphql/server/services/content-api/index.js b/packages/plugins/graphql/server/services/content-api/index.js index 7e0018955a..96b6908726 100644 --- a/packages/plugins/graphql/server/services/content-api/index.js +++ b/packages/plugins/graphql/server/services/content-api/index.js @@ -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 }); }; diff --git a/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js b/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js index 6a16f49c61..585b9e3f47 100644 --- a/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js +++ b/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js @@ -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) diff --git a/packages/plugins/graphql/server/services/utils/naming.js b/packages/plugins/graphql/server/services/utils/naming.js index a85d4bd3e6..48ae90ee87 100644 --- a/packages/plugins/graphql/server/services/utils/naming.js +++ b/packages/plugins/graphql/server/services/utils/naming.js @@ -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}`; }; diff --git a/packages/plugins/graphql/strapi-server.js b/packages/plugins/graphql/strapi-server.js index 4442277d2e..a52ffb5827 100644 --- a/packages/plugins/graphql/strapi-server.js +++ b/packages/plugins/graphql/strapi-server.js @@ -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: {}, }; }; diff --git a/packages/plugins/i18n/server/graphql.js b/packages/plugins/i18n/server/graphql.js index fdf0706459..87f5313c32 100644 --- a/packages/plugins/i18n/server/graphql.js +++ b/packages/plugins/i18n/server/graphql.js @@ -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); + }, + }); +};