diff --git a/.gitignore b/.gitignore index 4e7c31fbdf..b9472f751b 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,7 @@ dist ############################ packages/generators/app/files/public/ +schema.graphql ############################ # Example app diff --git a/examples/getstarted/config/plugins.js b/examples/getstarted/config/plugins.js index e7713d2b54..8fc7e3ea62 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/examples/getstarted/src/api/address/content-types/address/schema.json b/examples/getstarted/src/api/address/content-types/address/schema.json index 94a41e2ca1..110d0f4d39 100755 --- a/examples/getstarted/src/api/address/content-types/address/schema.json +++ b/examples/getstarted/src/api/address/content-types/address/schema.json @@ -79,8 +79,7 @@ } }, "slug": { - "type": "uid", - "targetField": "city" + "type": "uid" }, "notrepeat_req": { "type": "component", diff --git a/examples/getstarted/src/plugins/myplugin/server/graphql.js b/examples/getstarted/src/plugins/myplugin/server/graphql.js new file mode 100644 index 0000000000..48e8f2b118 --- /dev/null +++ b/examples/getstarted/src/plugins/myplugin/server/graphql.js @@ -0,0 +1,9 @@ +'use strict'; + +const crudActionsToDisable = ['create', 'update', 'delete']; + +module.exports = ({ strapi }) => { + const extension = strapi.plugin('graphql').service('extension'); + + extension.shadowCRUD('plugin::myplugin.test').disableActions(crudActionsToDisable); +}; diff --git a/examples/getstarted/src/plugins/myplugin/server/register.js b/examples/getstarted/src/plugins/myplugin/server/register.js new file mode 100644 index 0000000000..11142c8a09 --- /dev/null +++ b/examples/getstarted/src/plugins/myplugin/server/register.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = strapi => { + if (strapi.plugin('graphql')) { + require('./graphql')({ strapi }); + } +}; diff --git a/examples/getstarted/src/plugins/myplugin/server/schema.graphql.js b/examples/getstarted/src/plugins/myplugin/server/schema.graphql.js deleted file mode 100644 index e8d2d57f8a..0000000000 --- a/examples/getstarted/src/plugins/myplugin/server/schema.graphql.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - resolver: { - Mutation: { - createTest: false, - updateTest: false, - deleteTest: false, - }, - }, -}; diff --git a/examples/getstarted/src/plugins/myplugin/strapi-server.js b/examples/getstarted/src/plugins/myplugin/strapi-server.js index d54be01cba..87769881e0 100644 --- a/examples/getstarted/src/plugins/myplugin/strapi-server.js +++ b/examples/getstarted/src/plugins/myplugin/strapi-server.js @@ -3,9 +3,11 @@ const config = require('./server/config'); const contentTypes = require('./server/content-types'); const controllers = require('./server/controllers'); +const register = require('./server/register'); module.exports = () => { return { + register, config, controllers, contentTypes, diff --git a/jest.config.e2e.js b/jest.config.e2e.js index d94417f0cb..6c1e9de5b5 100644 --- a/jest.config.e2e.js +++ b/jest.config.e2e.js @@ -3,12 +3,7 @@ module.exports = { testMatch: ['**/?(*.)+(spec|test).e2e.js'], testEnvironment: 'node', setupFilesAfterEnv: ['/test/jest2e2.setup.js'], - testPathIgnorePatterns: [ - '/packages/plugins/graphql', - 'graphql.test.e2e.js', - 'graphqlUpload.test.e2e.js', - '/packages/core/database.old', - ], + testPathIgnorePatterns: ['/packages/core/database.old'], coveragePathIgnorePatterns: [ '/dist/', '/node_modules/', diff --git a/packages/core/content-type-builder/tests/components.test.e2e.js b/packages/core/content-type-builder/tests/components.test.e2e.js index 3dcd72b4fe..6541643bde 100644 --- a/packages/core/content-type-builder/tests/components.test.e2e.js +++ b/packages/core/content-type-builder/tests/components.test.e2e.js @@ -229,7 +229,11 @@ describe('Content Type Builder - Components', () => { category: 'default', icon: 'default', name: 'New Component', - attributes: {}, + attributes: { + name: { + type: 'string', + }, + }, pluginOptions: { pluginName: { option: false, diff --git a/packages/core/database/lib/entity-manager.js b/packages/core/database/lib/entity-manager.js index 5bc7a98176..d0d79dab5c 100644 --- a/packages/core/database/lib/entity-manager.js +++ b/packages/core/database/lib/entity-manager.js @@ -838,6 +838,10 @@ const createEntityManager = db => { }, }); + if (!entry) { + return null; + } + return entry[field]; }, diff --git a/packages/core/database/lib/query/helpers/populate.js b/packages/core/database/lib/query/helpers/populate.js index c5c594e68f..ddabeed2ae 100644 --- a/packages/core/database/lib/query/helpers/populate.js +++ b/packages/core/database/lib/query/helpers/populate.js @@ -94,8 +94,16 @@ const processPopulate = (populate, ctx) => { return finalPopulate; }; -// Omit limit & offset to avoid needing a query per result to avoid making too many queries -const pickPopulateParams = _.pick(['select', 'count', 'where', 'populate', 'orderBy']); +// TODO: Omit limit & offset to avoid needing a query per result to avoid making too many queries +const pickPopulateParams = _.pick([ + 'select', + 'count', + 'where', + 'populate', + 'orderBy', + 'limit', + 'offset', +]); // TODO: cleanup code // TODO: create aliases for pivot columns diff --git a/packages/core/strapi/lib/Strapi.js b/packages/core/strapi/lib/Strapi.js index b9faa51298..f23e6f251c 100644 --- a/packages/core/strapi/lib/Strapi.js +++ b/packages/core/strapi/lib/Strapi.js @@ -131,6 +131,10 @@ class Strapi { return this.container.get('apis').getAll(); } + get auth() { + return this.container.get('auth'); + } + async start() { try { if (!this.isLoaded) { @@ -451,7 +455,6 @@ class Strapi { /** * Binds queries with a specific model * @param {string} uid - * @returns {} */ query(uid) { return this.db.query(uid); diff --git a/packages/core/strapi/lib/core/domain/content-type/index.js b/packages/core/strapi/lib/core/domain/content-type/index.js index 7204069243..99a9d725e8 100644 --- a/packages/core/strapi/lib/core/domain/content-type/index.js +++ b/packages/core/strapi/lib/core/domain/content-type/index.js @@ -108,10 +108,9 @@ const createContentType = (uid, definition) => { private: isPrivate, }; - return Object.assign(schema, { - actions, - lifecycles, - }); + Object.assign(schema, { actions, lifecycles }); + + return schema; }; const getGlobalId = (model, modelName, prefix) => { diff --git a/packages/core/strapi/lib/core/domain/module/index.js b/packages/core/strapi/lib/core/domain/module/index.js index ff307f2f04..5431106099 100644 --- a/packages/core/strapi/lib/core/domain/module/index.js +++ b/packages/core/strapi/lib/core/domain/module/index.js @@ -62,8 +62,8 @@ const createModule = (namespace, rawModule, strapi) => { strapi.container.get('config').set(uidToPath(namespace), rawModule.config); }, routes: rawModule.routes, - config(path) { - return strapi.container.get('config').get(`${uidToPath(namespace)}.${path}`); + config(path, defaultValue) { + return strapi.container.get('config').get(`${uidToPath(namespace)}.${path}`, defaultValue); }, contentType(ctName) { return strapi.container.get('content-types').get(`${namespace}.${ctName}`); diff --git a/packages/core/strapi/lib/core/registries/services.js b/packages/core/strapi/lib/core/registries/services.js index 544125f93f..e4635c7351 100644 --- a/packages/core/strapi/lib/core/registries/services.js +++ b/packages/core/strapi/lib/core/registries/services.js @@ -6,18 +6,18 @@ const { addNamespace, hasNamespace } = require('../utils'); const servicesRegistry = strapi => { const services = {}; - const instanciatedServices = {}; + const instantiatedServices = {}; return { get(uid) { - if (instanciatedServices[uid]) { - return instanciatedServices[uid]; + if (instantiatedServices[uid]) { + return instantiatedServices[uid]; } const service = services[uid]; if (service) { - instanciatedServices[uid] = service({ strapi }); - return instanciatedServices[uid]; + instantiatedServices[uid] = service({ strapi }); + return instantiatedServices[uid]; } return undefined; @@ -28,7 +28,7 @@ const servicesRegistry = strapi => { return _.mapValues(filteredServices, (service, serviceUID) => this.get(serviceUID)); }, set(uid, value) { - instanciatedServices[uid] = value; + instantiatedServices[uid] = value; return this; }, add(namespace, newServices) { @@ -50,7 +50,7 @@ const servicesRegistry = strapi => { throw new Error(`Service ${serviceUID} doesn't exist`); } const newService = extendFn(currentService); - instanciatedServices[serviceUID] = newService; + instantiatedServices[serviceUID] = newService; }, }; }; diff --git a/packages/core/strapi/lib/services/entity-service/components.js b/packages/core/strapi/lib/services/entity-service/components.js index bdf0c1e73a..067443ae56 100644 --- a/packages/core/strapi/lib/services/entity-service/components.js +++ b/packages/core/strapi/lib/services/entity-service/components.js @@ -1,7 +1,7 @@ 'use strict'; const _ = require('lodash'); -const { has, prop, omit } = require('lodash/fp'); +const { has, prop, omit, toString } = require('lodash/fp'); const { contentTypes: contentTypesUtils } = require('@strapi/utils'); @@ -198,11 +198,13 @@ const deleteOldComponents = async ( const idsToKeep = _.castArray(componentValue) .filter(has('id')) - .map(prop('id')); + .map(prop('id')) + .map(toString); const allIds = _.castArray(previousValue) .filter(has('id')) - .map(prop('id')); + .map(prop('id')) + .map(toString); idsToKeep.forEach(id => { if (!allIds.includes(id)) { @@ -229,14 +231,14 @@ const deleteOldDZComponents = async (uid, entityToUpdate, attributeName, dynamic const idsToKeep = _.castArray(dynamiczoneValues) .filter(has('id')) .map(({ id, __component }) => ({ - id, + id: toString(id), __component, })); const allIds = _.castArray(previousValue) .filter(has('id')) .map(({ id, __component }) => ({ - id, + id: toString(id), __component, })); diff --git a/packages/core/strapi/lib/services/entity-service/index.js b/packages/core/strapi/lib/services/entity-service/index.js index 8793657351..9642e8af47 100644 --- a/packages/core/strapi/lib/services/entity-service/index.js +++ b/packages/core/strapi/lib/services/entity-service/index.js @@ -1,6 +1,8 @@ 'use strict'; const delegate = require('delegates'); +const { pipe } = require('lodash/fp'); + const { sanitizeEntity, webhook: webhookUtils, @@ -14,7 +16,12 @@ const { updateComponents, deleteComponents, } = require('./components'); -const { transformParamsToQuery, pickSelectionParams } = require('./params'); +const { + transformCommonParams, + transformPaginationParams, + transformParamsToQuery, + pickSelectionParams, +} = require('./params'); // TODO: those should be strapi events used by the webhooks not the other way arround const { ENTRY_CREATE, ENTRY_UPDATE, ENTRY_DELETE } = webhookUtils.webhookEvents; @@ -218,4 +225,17 @@ const createDefaultImplementation = ({ strapi, db, eventHub, entityValidator }) return db.query(uid).deleteMany(query); }, + + load(uid, entity, field, params) { + const { attributes } = strapi.getModel(uid); + + const attribute = attributes[field]; + + const loadParams = + attribute.type === 'relation' + ? transformParamsToQuery(attribute.target, params) + : pipe(transformCommonParams, transformPaginationParams)(params); + + return db.query(uid).load(entity, field, loadParams); + }, }); diff --git a/packages/core/strapi/lib/services/entity-service/params.js b/packages/core/strapi/lib/services/entity-service/params.js index 76d019a35a..a7ef523798 100644 --- a/packages/core/strapi/lib/services/entity-service/params.js +++ b/packages/core/strapi/lib/services/entity-service/params.js @@ -1,6 +1,6 @@ 'use strict'; -const { pick } = require('lodash/fp'); +const { pick, pipe, isNil } = require('lodash/fp'); const { convertSortQueryParams, @@ -15,47 +15,28 @@ const { contentTypes: contentTypesUtils } = require('@strapi/utils'); const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants; -// TODO: check invalid values / add defaults .... -const transformParamsToQuery = (uid, params = {}) => { - const model = strapi.getModel(uid); - +// TODO: to remove once the front is migrated +const convertOldQuery = params => { const query = {}; - const { - page, - start, - pageSize, - limit, - sort, - filters, - fields, - populate, - publicationState, - _q, - _where, - ...rest - } = params; + Object.keys(params).forEach(key => { + if (key.startsWith('_')) { + query[key.slice(1)] = params[key]; + } else { + query[key] = params[key]; + } + }); + + return query; +}; + +const transformCommonParams = (params = {}) => { + const { _q, sort, filters, _where, fields, populate, ...query } = params; if (_q) { query._q = _q; } - if (page) { - query.page = Number(page); - } - - if (pageSize) { - query.pageSize = Number(pageSize); - } - - if (start) { - query.offset = convertStartQueryParams(start); - } - - if (limit) { - query.limit = convertLimitQueryParams(limit); - } - if (sort) { query.orderBy = convertSortQueryParams(sort); } @@ -78,8 +59,50 @@ const transformParamsToQuery = (uid, params = {}) => { query.populate = convertPopulateQueryParams(populate); } - // TODO: move to convert-query-params ? - if (publicationState && contentTypesUtils.hasDraftAndPublish(model)) { + return { ...convertOldQuery(query), ...query }; +}; + +const transformPaginationParams = (params = {}) => { + const { page, pageSize, start, limit, ...query } = params; + + const isPagePagination = !isNil(page) || !isNil(pageSize); + const isOffsetPagination = !isNil(start) || !isNil(limit); + + if (isPagePagination && isOffsetPagination) { + throw new Error( + 'Invalid pagination attributes. You cannot use page and offset pagination in the same query' + ); + } + + if (page) { + query.page = Number(page); + } + + if (pageSize) { + query.pageSize = Number(pageSize); + } + + if (start) { + query.offset = convertStartQueryParams(start); + } + + if (limit) { + query.limit = convertLimitQueryParams(limit); + } + + return { ...convertOldQuery(query), ...query }; +}; + +const transformPublicationStateParams = uid => (params = {}) => { + const contentType = strapi.getModel(uid); + + if (!contentType) { + return params; + } + + const { publicationState, ...query } = params; + + if (publicationState && contentTypesUtils.hasDraftAndPublish(contentType)) { const { publicationState = 'live' } = params; const liveClause = { @@ -97,32 +120,26 @@ const transformParamsToQuery = (uid, params = {}) => { } } - const finalQuery = { - ...convertOldQuery(rest), - ...query, - }; - - return finalQuery; -}; - -// TODO: to remove once the front is migrated -const convertOldQuery = params => { - const obj = {}; - - Object.keys(params).forEach(key => { - if (key.startsWith('_')) { - obj[key.slice(1)] = params[key]; - } else { - obj[key] = params[key]; - } - }); - - return obj; + return { ...convertOldQuery(query), ...query }; }; const pickSelectionParams = pick(['fields', 'populate']); +const transformParamsToQuery = (uid, params) => { + return pipe( + // _q, _where, filters, etc... + transformCommonParams, + // page, pageSize, start, limit + transformPaginationParams, + // publicationState + transformPublicationStateParams(uid) + )(params); +}; + module.exports = { + transformCommonParams, + transformPublicationStateParams, + transformPaginationParams, transformParamsToQuery, pickSelectionParams, }; diff --git a/packages/core/strapi/tests/filtering.test.e2e.js b/packages/core/strapi/tests/filtering.test.e2e.js index b180e970dc..5566780b41 100644 --- a/packages/core/strapi/tests/filtering.test.e2e.js +++ b/packages/core/strapi/tests/filtering.test.e2e.js @@ -1278,7 +1278,9 @@ describe('Filtering API', () => { method: 'GET', url: '/products', qs: { - limit: 1, + pagination: { + limit: 1, + }, sort: 'rank:asc', }, }); @@ -1291,7 +1293,9 @@ describe('Filtering API', () => { method: 'GET', url: '/products', qs: { - limit: 1, + pagination: { + limit: 1, + }, sort: 'rank:desc', }, }); @@ -1306,7 +1310,9 @@ describe('Filtering API', () => { method: 'GET', url: '/products', qs: { - start: 1, + pagination: { + start: 1, + }, sort: 'rank:asc', }, }); @@ -1319,8 +1325,10 @@ describe('Filtering API', () => { method: 'GET', url: '/products', qs: { - limit: 1, - start: 1, + pagination: { + limit: 1, + start: 1, + }, sort: 'rank:asc', }, }); diff --git a/packages/core/upload/server/graphql.js b/packages/core/upload/server/graphql.js new file mode 100644 index 0000000000..ad1c8797c5 --- /dev/null +++ b/packages/core/upload/server/graphql.js @@ -0,0 +1,186 @@ +'use strict'; + +const _ = require('lodash'); +const { streamToBuffer } = require('./utils/file'); + +const UPLOAD_MUTATION_NAME = 'upload'; +const MULTIPLE_UPLOAD_MUTATION_NAME = 'multipleUpload'; +const UPDATE_FILE_INFO_MUTATION_NAME = 'updateFileInfo'; +const DELETE_FILE_MUTATION_NAME = 'removeFile'; + +const FILE_INFO_INPUT_TYPE_NAME = 'FileInfoInput'; + +module.exports = ({ strapi }) => { + const { service: getGraphQLService, config: graphQLConfig } = strapi.plugin('graphql'); + const { service: getUploadService } = strapi.plugin('upload'); + + const isShadowCRUDEnabled = graphQLConfig('shadowCRUD', true); + + if (!isShadowCRUDEnabled) { + return; + } + + const { getTypeName, getEntityResponseName } = getGraphQLService('utils').naming; + const { toEntityResponse } = getGraphQLService('format').returnTypes; + + const fileModel = strapi.getModel('plugin::upload.file'); + const fileTypeName = getTypeName(fileModel); + const fileEntityResponseType = getEntityResponseName(fileModel); + + const { optimize } = getUploadService('image-manipulation'); + + /** + * Optimize and format a file using the upload services + * + * @param {object} upload + * @param {object} extraInfo + * @param {object} metas + * @return {Promise} + */ + const formatFile = async (upload, extraInfo, metas) => { + const { filename, mimetype, createReadStream } = await upload; + + const readBuffer = await streamToBuffer(createReadStream()); + + const { buffer, info } = await optimize(readBuffer); + + const uploadService = getUploadService('upload'); + + const fileInfo = uploadService.formatFileInfo( + { + filename, + type: mimetype, + size: buffer.length, + }, + extraInfo || {}, + metas + ); + + return _.assign(fileInfo, info, { buffer }); + }; + + /** + * Register Upload's types, queries & mutations to the content API using the GraphQL extension API + */ + getGraphQLService('extension').use(({ nexus }) => { + const { inputObjectType, extendType, nonNull, list } = nexus; + + // Represents the input data payload for the file's information + const fileInfoInputType = inputObjectType({ + name: FILE_INFO_INPUT_TYPE_NAME, + + definition(t) { + t.string('name'); + t.string('alternativeText'); + t.string('caption'); + }, + }); + + const mutations = extendType({ + type: 'Mutation', + + definition(t) { + /** + * Upload a single file + */ + t.field(UPLOAD_MUTATION_NAME, { + type: nonNull(fileEntityResponseType), + + args: { + refId: 'ID', + ref: 'String', + field: 'String', + info: FILE_INFO_INPUT_TYPE_NAME, + file: nonNull('Upload'), + }, + + async resolve(parent, args) { + const { file: upload, info, ...fields } = args; + + const file = await formatFile(upload, info, fields); + const uploadedFile = await getUploadService('upload').uploadFileAndPersist(file); + + return toEntityResponse(uploadedFile, { args, resourceUID: fileTypeName }); + }, + }); + + /** + * Upload multiple files + */ + t.field(MULTIPLE_UPLOAD_MUTATION_NAME, { + type: nonNull(list(fileEntityResponseType)), + + args: { + refId: 'ID', + ref: 'String', + field: 'String', + files: nonNull(list('Upload')), + }, + + async resolve(parent, args) { + const { files: uploads, ...fields } = args; + + const files = await Promise.all(uploads.map(upload => formatFile(upload, {}, fields))); + + const uploadService = getUploadService('upload'); + + const uploadedFiles = await Promise.all( + files.map(file => uploadService.uploadFileAndPersist(file)) + ); + + return uploadedFiles.map(file => + toEntityResponse(file, { args, resourceUID: fileTypeName }) + ); + }, + }); + + /** + * Update some information for a given file + */ + t.field(UPDATE_FILE_INFO_MUTATION_NAME, { + type: nonNull(fileEntityResponseType), + + args: { + id: nonNull('ID'), + info: FILE_INFO_INPUT_TYPE_NAME, + }, + + async resolve(parent, args) { + const { id, info } = args; + + const updatedFile = await getUploadService('upload').updateFileInfo(id, info); + + return toEntityResponse(updatedFile, { args, resourceUID: fileTypeName }); + }, + }); + + /** + * Delete & remove a given file + */ + t.field(DELETE_FILE_MUTATION_NAME, { + type: fileEntityResponseType, + + args: { + id: nonNull('ID'), + }, + + async resolve(parent, args) { + const { id } = args; + + const file = await getUploadService('upload').findOne(id); + + if (!file) { + return null; + } + + const deletedFile = await getUploadService('upload').remove(file); + + return toEntityResponse(deletedFile, { args, resourceUID: fileTypeName }); + }, + }); + }, + }); + + return { types: [fileInfoInputType, mutations] }; + }); +}; diff --git a/packages/core/upload/server/register.js b/packages/core/upload/server/register.js new file mode 100644 index 0000000000..11142c8a09 --- /dev/null +++ b/packages/core/upload/server/register.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = strapi => { + if (strapi.plugin('graphql')) { + require('./graphql')({ strapi }); + } +}; diff --git a/packages/core/upload/server/schema.graphql.js b/packages/core/upload/server/schema.graphql.js deleted file mode 100644 index e5555c678f..0000000000 --- a/packages/core/upload/server/schema.graphql.js +++ /dev/null @@ -1,95 +0,0 @@ -'use strict'; - -const _ = require('lodash'); -const { streamToBuffer } = require('./utils/file'); -const { getService } = require('./utils'); - -module.exports = { - definition: ` - input FileInfoInput { - name: String - alternativeText: String - caption: String - } - `, - mutation: ` - upload(refId: ID, ref: String, field: String, source: String, info: FileInfoInput, file: Upload!): UploadFile! - multipleUpload(refId: ID, ref: String, field: String, source: String, files: [Upload]!): [UploadFile]! - updateFileInfo(id: ID!, info: FileInfoInput!): UploadFile! - `, - resolver: { - Query: { - file: false, - files: { - resolver: 'plugin::upload.upload.find', - }, - }, - Mutation: { - createFile: false, - updateFile: false, - upload: { - description: 'Upload one file', - resolverOf: 'plugin::upload.upload.upload', - async resolver(obj, { file: upload, info, ...fields }) { - const file = await formatFile(upload, info, fields); - - const uploadedFiles = await getService('upload').uploadFileAndPersist(file); - - // Return response. - return uploadedFiles.length === 1 ? uploadedFiles[0] : uploadedFiles; - }, - }, - multipleUpload: { - description: 'Upload one file', - resolverOf: 'plugin::upload.upload.upload', - async resolver(obj, { files: uploads, ...fields }) { - const files = await Promise.all(uploads.map(upload => formatFile(upload, {}, fields))); - - const uploadService = getService('upload'); - - return Promise.all(files.map(file => uploadService.uploadFileAndPersist(file))); - }, - }, - updateFileInfo: { - description: 'Update file information', - resolverOf: 'plugin::upload.upload.upload', - async resolver(obj, { id, info }) { - return getService('upload').updateFileInfo(id, info); - }, - }, - deleteFile: { - description: 'Delete one file', - resolverOf: 'plugin::upload.upload.destroy', - async resolver(obj, options, { context }) { - const file = await getService('upload').findOne(context.params.id); - if (file) { - const fileResult = await getService('upload').remove(file); - return { file: fileResult }; - } - }, - }, - }, - }, -}; - -const formatFile = async (upload, extraInfo, metas) => { - const { filename, mimetype, createReadStream } = await upload; - - const { optimize } = strapi.plugin('upload').service('image-manipulation'); - const readBuffer = await streamToBuffer(createReadStream()); - - const { buffer, info } = await optimize(readBuffer); - - const uploadService = getService('upload'); - const fileInfo = uploadService.formatFileInfo( - { - filename, - type: mimetype, - size: buffer.length, - }, - extraInfo || {}, - metas - ); - - return _.assign(fileInfo, info, { buffer }); -}; diff --git a/packages/core/upload/strapi-server.js b/packages/core/upload/strapi-server.js index 08d4fcfdfb..248425f2a2 100644 --- a/packages/core/upload/strapi-server.js +++ b/packages/core/upload/strapi-server.js @@ -1,6 +1,7 @@ 'use strict'; const bootstrap = require('./server/bootstrap'); +const register = require('./server/register'); const contentTypes = require('./server/content-types'); const services = require('./server/services'); const routes = require('./server/routes'); @@ -11,6 +12,7 @@ const middlewares = require('./server/middlewares'); module.exports = () => { return { bootstrap, + register, config, routes, controllers, diff --git a/packages/core/upload/tests/graphqlUpload.test.e2e.js b/packages/core/upload/tests/graphql-upload.test.e2e.js similarity index 70% rename from packages/core/upload/tests/graphqlUpload.test.e2e.js rename to packages/core/upload/tests/graphql-upload.test.e2e.js index 290aa33bec..6e87fe13f0 100644 --- a/packages/core/upload/tests/graphqlUpload.test.e2e.js +++ b/packages/core/upload/tests/graphql-upload.test.e2e.js @@ -25,12 +25,16 @@ describe('Upload plugin end to end tests', () => { const formData = { operations: JSON.stringify({ query: /* GraphQL */ ` - mutation uploadFiles($file: Upload!) { + mutation uploadFile($file: Upload!) { upload(file: $file) { - id - name - mime - url + data { + id + attributes { + name + mime + url + } + } } } `, @@ -50,13 +54,19 @@ describe('Upload plugin end to end tests', () => { expect(res.body).toMatchObject({ data: { upload: { - id: expect.anything(), - name: 'rec.jpg', + data: { + id: expect.anything(), + attributes: { + name: 'rec.jpg', + mime: 'image/jpeg', + url: expect.any(String), + }, + }, }, }, }); - data.file = res.body.data.upload; + data.file = res.body.data.upload.data; }); test('Upload multiple files', async () => { @@ -65,10 +75,14 @@ describe('Upload plugin end to end tests', () => { query: /* GraphQL */ ` mutation uploadFiles($files: [Upload]!) { multipleUpload(files: $files) { - id - name - mime - url + data { + id + attributes { + name + mime + url + } + } } } `, @@ -87,12 +101,19 @@ describe('Upload plugin end to end tests', () => { const res = await rq({ method: 'POST', url: '/graphql', formData }); expect(res.statusCode).toBe(200); + expect(res.body.data.multipleUpload).toHaveLength(2); expect(res.body).toEqual({ data: { multipleUpload: expect.arrayContaining([ expect.objectContaining({ - id: expect.anything(), - name: 'rec.jpg', + data: { + id: expect.anything(), + attributes: { + name: 'rec.jpg', + mime: 'image/jpeg', + url: expect.any(String), + }, + }, }), ]), }, @@ -107,10 +128,14 @@ describe('Upload plugin end to end tests', () => { query: /* GraphQL */ ` mutation updateFileInfo($id: ID!, $info: FileInfoInput!) { updateFileInfo(id: $id, info: $info) { - id - name - alternativeText - caption + data { + id + attributes { + name + alternativeText + caption + } + } } } `, @@ -129,10 +154,14 @@ describe('Upload plugin end to end tests', () => { expect(res.body).toMatchObject({ data: { updateFileInfo: { - id: data.file.id, - name: 'test name', - alternativeText: 'alternative text test', - caption: 'caption test', + data: { + id: data.file.id, + attributes: { + name: 'test name', + alternativeText: 'alternative text test', + caption: 'caption test', + }, + }, }, }, }); @@ -145,8 +174,8 @@ describe('Upload plugin end to end tests', () => { body: { query: /* GraphQL */ ` mutation removeFile($id: ID!) { - deleteFile(input: { where: { id: $id } }) { - file { + removeFile(id: $id) { + data { id } } @@ -161,8 +190,8 @@ describe('Upload plugin end to end tests', () => { expect(res.statusCode).toBe(200); expect(res.body).toMatchObject({ data: { - deleteFile: { - file: { + removeFile: { + data: { id: data.file.id, }, }, @@ -177,8 +206,8 @@ describe('Upload plugin end to end tests', () => { body: { query: /* GraphQL */ ` mutation removeFile($id: ID!) { - deleteFile(input: { where: { id: $id } }) { - file { + removeFile(id: $id) { + data { id } } @@ -193,7 +222,7 @@ describe('Upload plugin end to end tests', () => { expect(res.statusCode).toBe(200); expect(res.body).toMatchObject({ data: { - deleteFile: null, + removeFile: null, }, }); }); @@ -204,10 +233,14 @@ describe('Upload plugin end to end tests', () => { query: /* GraphQL */ ` mutation uploadFilesWithInfo($file: Upload!, $info: FileInfoInput) { upload(file: $file, info: $info) { - id - name - alternativeText - caption + data { + id + attributes { + name + alternativeText + caption + } + } } } `, @@ -231,10 +264,14 @@ describe('Upload plugin end to end tests', () => { expect(res.body).toMatchObject({ data: { upload: { - id: expect.anything(), - name: 'rec.jpg', - alternativeText: 'alternative text test', - caption: 'caption test', + data: { + id: expect.anything(), + attributes: { + name: 'rec.jpg', + alternativeText: 'alternative text test', + caption: 'caption test', + }, + }, }, }, }); diff --git a/packages/core/utils/lib/index.js b/packages/core/utils/lib/index.js index d5fd8a9b91..c6b8bde425 100644 --- a/packages/core/utils/lib/index.js +++ b/packages/core/utils/lib/index.js @@ -31,6 +31,7 @@ const relations = require('./relations'); const setCreatorFields = require('./set-creator-fields'); const hooks = require('./hooks'); const providerFactory = require('./provider-factory'); +const pagination = require('./pagination'); module.exports = { yup, @@ -63,4 +64,5 @@ module.exports = { setCreatorFields, hooks, providerFactory, + pagination, }; diff --git a/packages/core/utils/lib/pagination.js b/packages/core/utils/lib/pagination.js new file mode 100644 index 0000000000..093017e2ce --- /dev/null +++ b/packages/core/utils/lib/pagination.js @@ -0,0 +1,84 @@ +'use strict'; + +const { merge, pipe, omit, isNil } = require('lodash/fp'); + +const STRAPI_DEFAULTS = { + offset: { + start: 0, + limit: 10, + }, + page: { + page: 1, + pageSize: 10, + }, +}; + +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, 1), +}); + +const ensureMaxValues = (maxLimit = -1) => ({ start, limit }) => ({ + start, + 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, ensureValidValues(defaultValues.offset)); + } + + // If there is page & offset pagination attributes, throw an error + if (usePagePagination && useOffsetPagination) { + throw new Error('Cannot use both page & offset pagination in the same query'); + } + + const pagination = {}; + + // Start / Limit + if (useOffsetPagination) { + const { start, limit } = merge(defaultValues.offset, args); + + Object.assign(pagination, { start, limit }); + } + + // Page / PageSize + if (usePagePagination) { + const { page, pageSize } = merge(defaultValues.page, args); + + Object.assign(pagination, { + start: (page - 1) * pageSize, + limit: pageSize, + }); + } + + const replacePaginationAttributes = pipe( + // Remove pagination attributes + omit(paginationAttributes), + // Merge the object with the new pagination + ensure minimum & maximum values + merge(ensureValidValues(pagination)) + ); + + return replacePaginationAttributes(args); +}; + +module.exports = { withDefaultPagination }; diff --git a/packages/plugins/graphql/config/routes.json b/packages/plugins/graphql/config/routes.json deleted file mode 100644 index 9ade02d5d3..0000000000 --- a/packages/plugins/graphql/config/routes.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "routes": [] -} diff --git a/packages/plugins/graphql/config/schema.graphql b/packages/plugins/graphql/config/schema.graphql deleted file mode 100644 index f053ebf797..0000000000 --- a/packages/plugins/graphql/config/schema.graphql +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/packages/plugins/graphql/config/settings.json b/packages/plugins/graphql/config/settings.json deleted file mode 100644 index fbad94fd96..0000000000 --- a/packages/plugins/graphql/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/controllers/GraphQL.js b/packages/plugins/graphql/controllers/GraphQL.js deleted file mode 100644 index 7f5f47b024..0000000000 --- a/packages/plugins/graphql/controllers/GraphQL.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -/** - * GraphQL.js controller - * - * @description: A set of functions called "actions" of the `GraphQL` plugin. - */ - -module.exports = {}; diff --git a/packages/plugins/graphql/hooks/graphql/defaults.json b/packages/plugins/graphql/hooks/graphql/defaults.json deleted file mode 100644 index f515a839e9..0000000000 --- a/packages/plugins/graphql/hooks/graphql/defaults.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "graphql": { - "enabled": true - } -} diff --git a/packages/plugins/graphql/hooks/graphql/index.js b/packages/plugins/graphql/hooks/graphql/index.js deleted file mode 100644 index 8f56cc4e06..0000000000 --- a/packages/plugins/graphql/hooks/graphql/index.js +++ /dev/null @@ -1,174 +0,0 @@ -'use strict'; - -/** - * Module dependencies - */ - -// Public node modules. -const _ = require('lodash'); -const { ApolloServer } = require('apollo-server-koa'); -const depthLimit = require('graphql-depth-limit'); -const { graphqlUploadKoa } = require('graphql-upload'); -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; -}; - -module.exports = strapi => { - const { appPath, installedPlugins } = strapi.config; - - return { - async beforeInitialize() { - // Try to inject this hook just after the others hooks to skip the router processing. - if (!strapi.config.get('hook.load.after')) { - _.set(strapi.config.hook.load, 'after', []); - } - - strapi.config.hook.load.after.push('graphql'); - // 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); - }, - - initialize() { - const schema = strapi.plugins.graphql.services['schema-generator'].generateSchema(); - - if (_.isEmpty(schema)) { - strapi.log.warn('The GraphQL schema has not been generated because it is empty'); - - return; - } - - const config = _.get(strapi.plugins.graphql, 'config', {}); - - // TODO: Remove these deprecated options in favor of `apolloServer` in the next major version - const deprecatedApolloServerConfig = { - tracing: _.get(config, 'tracing', false), - introspection: _.get(config, 'introspection', true), - engine: _.get(config, 'engine', false), - }; - - if (['tracing', 'introspection', 'engine'].some(key => _.has(config, key))) { - strapi.log.warn( - 'The `tracing`, `introspection` and `engine` options are deprecated in favor of the `apolloServer` object and they will be removed in the next major version.' - ); - } - - const apolloServerConfig = _.get(config, 'apolloServer', {}); - - const serverParams = { - schema, - uploads: false, - context({ ctx }) { - // Initiliase loaders for this request. - // TODO: set loaders in the context not globally - - strapi.plugins.graphql.services['data-loaders'].initializeLoader(); - - return { - context: ctx, - }; - }, - formatError(err) { - const formatError = _.get(config, 'formatError', null); - - return typeof formatError === 'function' ? formatError(err) : err; - }, - validationRules: [depthLimit(config.depthLimit)], - playground: false, - cors: false, - bodyParserConfig: true, - // TODO: Remove these deprecated options in favor of `apolloServerConfig` in the next major version - ...deprecatedApolloServerConfig, - ...apolloServerConfig, - }; - - // Disable GraphQL Playground in production environment. - if (strapi.config.environment !== 'production' || config.playgroundAlways) { - serverParams.playground = { - endpoint: `${strapi.config.server.url}${config.endpoint}`, - shareEnabled: config.shareEnabled, - }; - } - - const server = new ApolloServer(serverParams); - - const uploadMiddleware = graphqlUploadKoa(); - strapi.app.use((ctx, next) => { - if (ctx.path === config.endpoint) { - return uploadMiddleware(ctx, next); - } - - return next(); - }); - server.applyMiddleware({ - app: strapi.app, - path: config.endpoint, - }); - - strapi.plugins.graphql.destroy = async () => { - await server.stop(); - }; - }, - }; -}; - -/** - * 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/hooks/graphql/load-config.js b/packages/plugins/graphql/hooks/graphql/load-config.js deleted file mode 100644 index 8b818f5504..0000000000 --- a/packages/plugins/graphql/hooks/graphql/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/package.json b/packages/plugins/graphql/package.json index e31c240eb7..8c4a1a29fb 100644 --- a/packages/plugins/graphql/package.json +++ b/packages/plugins/graphql/package.json @@ -13,23 +13,24 @@ "test": "echo \"no tests yet\"" }, "dependencies": { - "@apollo/federation": "^0.20.7", - "@graphql-tools/utils": "7.2.4", - "apollo-server-koa": "2.24.0", - "dataloader": "^1.4.0", - "glob": "^7.1.6", - "graphql": "15.5.0", + "@apollo/federation": "^0.28.0", + "@graphql-tools/schema": "8.1.2", + "@graphql-tools/utils": "^8.0.2", + "@strapi/utils": "3.6.8", + "apollo-server-core": "3.1.2", + "apollo-server-koa": "3.1.2", + "glob": "^7.1.7", + "graphql": "15.5.1", "graphql-depth-limit": "^1.1.0", "graphql-iso-date": "^3.6.1", "graphql-playground-middleware-koa": "^1.6.21", - "graphql-tools": "4.0.8", - "graphql-type-json": "0.3.2", + "graphql-type-json": "^0.3.2", "graphql-type-long": "^0.1.1", - "graphql-upload": "11.0.0", + "graphql-upload": "12.0.0", "koa-compose": "^4.1.0", "lodash": "4.17.21", - "pluralize": "^8.0.0", - "@strapi/utils": "3.6.8" + "nexus": "1.1.0", + "pluralize": "^8.0.0" }, "devDependencies": { "cross-env": "^7.0.3", diff --git a/packages/plugins/graphql/server/bootstrap.js b/packages/plugins/graphql/server/bootstrap.js new file mode 100644 index 0000000000..c0f92d952b --- /dev/null +++ b/packages/plugins/graphql/server/bootstrap.js @@ -0,0 +1,124 @@ +'use strict'; + +const { isEmpty, mergeWith, isArray } = require('lodash/fp'); +const { ApolloServer } = require('apollo-server-koa'); +const { + ApolloServerPluginLandingPageDisabled, + ApolloServerPluginLandingPageGraphQLPlayground, +} = require('apollo-server-core'); +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 + .plugin('graphql') + .service('content-api') + .buildSchema(); + + if (isEmpty(schema)) { + strapi.log.warn('The GraphQL schema has not been generated because it is empty'); + + return; + } + + const { config } = strapi.plugin('graphql'); + + const defaultServerConfig = { + // Schema + schema, + + // Initialize loaders for this request. + context: ({ ctx }) => ({ + state: ctx.state, + koaContext: ctx, + }), + + // Validation + validationRules: [depthLimit(config('depthLimit'))], + + // Misc + cors: false, + uploads: false, + bodyParserConfig: true, + + plugins: [ + process.env.NODE_ENV === 'production' + ? ApolloServerPluginLandingPageDisabled() + : ApolloServerPluginLandingPageGraphQLPlayground(), + ], + }; + + const serverConfig = merge(defaultServerConfig, config('apolloServer', {})); + + // Create a new Apollo server + const server = new ApolloServer(serverConfig); + + // Link the Apollo server & the Strapi app + const path = config('endpoint', '/graphql'); + + // Register the upload middleware + useUploadMiddleware(strapi, path); + + try { + // Since Apollo-Server v3, server.start() must be called before using server.applyMiddleware() + await server.start(); + } catch (e) { + strapi.log.error('Failed to start the Apollo server', e.message); + } + + strapi.server.routes([ + { + method: 'ALL', + path, + handler: [ + (ctx, next) => { + ctx.state.route = { + info: { + // Indicate it's a content API route + type: 'content-api', + }, + }; + + return strapi.auth.authenticate(ctx, next); + }, + + // Apollo Server + server.getMiddleware({ path }), + ], + config: { + auth: false, + }, + }, + ]); + + // Register destroy behavior + // We're doing it here instead of exposing a destroy method to the strapi-server.js + // file since we need to have access to the ApolloServer instance + strapi.plugin('graphql').destroy = async () => { + await server.stop(); + }; +}; + +/** + * Register the upload middleware powered by graphql-upload in Strapi + * @param {object} strapi + * @param {string} path + */ +const useUploadMiddleware = (strapi, path) => { + const uploadMiddleware = graphqlUploadKoa(); + + strapi.server.app.use((ctx, next) => { + if (ctx.path === path) { + return uploadMiddleware(ctx, next); + } + + return next(); + }); +}; diff --git a/packages/plugins/graphql/server/services/builders/dynamic-zones.js b/packages/plugins/graphql/server/services/builders/dynamic-zones.js new file mode 100644 index 0000000000..70512b56db --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/dynamic-zones.js @@ -0,0 +1,96 @@ +'use strict'; + +const { Kind, valueFromASTUntyped, GraphQLError } = require('graphql'); +const { omit } = require('lodash/fp'); +const { unionType, scalarType } = require('nexus'); + +module.exports = ({ strapi }) => { + const buildTypeDefinition = (name, components) => { + const { ERROR_TYPE_NAME } = strapi.plugin('graphql').service('constants'); + const isEmpty = components.length === 0; + + const componentsTypeNames = components.map(componentUID => { + const component = strapi.components[componentUID]; + + if (!component) { + throw new Error( + `Trying to create a dynamic zone type with an unknown component: "${componentUID}"` + ); + } + + return component.globalId; + }); + + return unionType({ + name, + + resolveType(obj) { + if (isEmpty) { + return ERROR_TYPE_NAME; + } + + return strapi.components[obj.__component].globalId; + }, + + definition(t) { + t.members(...componentsTypeNames, ERROR_TYPE_NAME); + }, + }); + }; + + const buildInputDefinition = (name, components) => { + const parseData = value => { + const component = Object.values(strapi.components).find( + component => component.globalId === value.__typename + ); + + if (!component) { + throw new GraphQLError( + `Component not found. expected one of: ${components + .map(uid => strapi.components[uid].globalId) + .join(', ')}` + ); + } + + return { + __component: component.uid, + ...omit(['__typename'], value), + }; + }; + + return scalarType({ + name, + + serialize: value => value, + + parseValue: value => parseData(value), + + parseLiteral(ast, variables) { + if (ast.kind !== Kind.OBJECT) { + return undefined; + } + + const value = valueFromASTUntyped(ast, variables); + return parseData(value); + }, + }); + }; + + return { + /** + * Build a Nexus dynamic zone type from a Strapi dz attribute + * @param {object} definition - The definition of the dynamic zone + * @param {string} name - the name of the dynamic zone + * @param {string} inputName - the name of the dynamic zone's input + * @return {[NexusUnionTypeDef, NexusScalarTypeDef]} + */ + buildDynamicZoneDefinition(definition, name, inputName) { + const { components } = definition; + + const typeDefinition = buildTypeDefinition(name, components); + const inputDefinition = buildInputDefinition(inputName, components); + + return [typeDefinition, inputDefinition]; + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/entity-meta.js b/packages/plugins/graphql/server/services/builders/entity-meta.js new file mode 100644 index 0000000000..4d7c3aff49 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/entity-meta.js @@ -0,0 +1,7 @@ +'use strict'; + +function buildEntityMetaDefinition(/*contentType*/) {} + +module.exports = () => ({ + buildEntityMetaDefinition, +}); diff --git a/packages/plugins/graphql/server/services/builders/entity.js b/packages/plugins/graphql/server/services/builders/entity.js new file mode 100644 index 0000000000..aadd553f98 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/entity.js @@ -0,0 +1,43 @@ +'use strict'; + +const { objectType } = require('nexus'); +const { prop, identity, isEmpty } = require('lodash/fp'); + +module.exports = ({ strapi }) => { + const { naming } = strapi.plugin('graphql').service('utils'); + + return { + /** + * 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} + */ + buildEntityDefinition(contentType) { + const { attributes } = contentType; + + const name = naming.getEntityName(contentType); + const typeName = naming.getTypeName(contentType); + + return objectType({ + name, + + definition(t) { + // Keep the ID attribute at the top level + t.id('id', { resolve: prop('id') }); + + if (!isEmpty(attributes)) { + // Keep the fetched object into a dedicated `attributes` field + // TODO: [v4] precise why we keep the ID + t.field('attributes', { + type: typeName, + resolve: identity, + }); + } + + // todo[v4]: add the meta field to the entity when there will be data in it (can't add an empty type for now) + // t.field('meta', { type: utils.getEntityMetaName(contentType) }); + }, + }); + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/enums.js b/packages/plugins/graphql/server/services/builders/enums.js new file mode 100644 index 0000000000..8879719b4b --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/enums.js @@ -0,0 +1,24 @@ +'use strict'; + +const { enumType } = require('nexus'); +const { set } = require('lodash/fp'); + +/** + * Build a Nexus enum type from a Strapi enum attribute + * @param {object} definition - The definition of the enum + * @param {string[]} definition.enum - The params of the enum + * @param {string} name - The name of the enum + * @return {NexusEnumTypeDef} + */ +const buildEnumTypeDefinition = (definition, name) => { + return enumType({ + name, + // In Strapi V3, the key of an enum is also its value + // todo[V4]: allow passing an object of key/value instead of an array + members: definition.enum.reduce((acc, value) => set(value, value, acc), {}), + }); +}; + +module.exports = () => ({ + buildEnumTypeDefinition, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/content-type.js b/packages/plugins/graphql/server/services/builders/filters/content-type.js new file mode 100644 index 0000000000..b683b2955f --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/content-type.js @@ -0,0 +1,84 @@ +'use strict'; + +const { inputObjectType } = require('nexus'); + +module.exports = ({ strapi }) => { + const rootLevelOperators = () => { + const { operators } = strapi.plugin('graphql').service('builders').filters; + + return [operators.and, operators.or, operators.not]; + }; + + const buildContentTypeFilters = contentType => { + const utils = strapi.plugin('graphql').service('utils'); + const extension = strapi.plugin('graphql').service('extension'); + + const { getFiltersInputTypeName } = utils.naming; + const { isStrapiScalar, isRelation } = utils.attributes; + + const { attributes } = contentType; + + const filtersTypeName = getFiltersInputTypeName(contentType); + + return inputObjectType({ + name: filtersTypeName, + + definition(t) { + const validAttributes = Object.entries(attributes).filter(([attributeName]) => + extension + .shadowCRUD(contentType.uid) + .field(attributeName) + .hasFiltersEnabeld() + ); + + // Add every defined attribute + for (const [attributeName, attribute] of validAttributes) { + // Handle scalars + if (isStrapiScalar(attribute)) { + addScalarAttribute(t, attributeName, attribute); + } + + // Handle relations + else if (isRelation(attribute)) { + addRelationalAttribute(t, attributeName, attribute); + } + } + + // Conditional clauses + for (const operator of rootLevelOperators()) { + operator.add(t, filtersTypeName); + } + }, + }); + }; + + const addScalarAttribute = (builder, attributeName, attribute) => { + const { naming, mappers } = strapi.plugin('graphql').service('utils'); + + const gqlType = mappers.strapiScalarToGraphQLScalar(attribute.type); + + builder.field(attributeName, { type: naming.getScalarFilterInputTypeName(gqlType) }); + }; + + const addRelationalAttribute = (builder, attributeName, attribute) => { + const utils = strapi.plugin('graphql').service('utils'); + const extension = strapi.plugin('graphql').service('extension'); + const { getFiltersInputTypeName } = utils.naming; + const { isMorphRelation } = utils.attributes; + + const model = strapi.getModel(attribute.target); + + // If there is no model corresponding to the attribute configuration + // or if the attribute is a polymorphic relation, then ignore it + if (!model || isMorphRelation(attribute)) return; + + // If the target model is disabled, then ignore it too + if (extension.shadowCRUD(model.uid).isDisabled()) return; + + builder.field(attributeName, { type: getFiltersInputTypeName(model) }); + }; + + return { + buildContentTypeFilters, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/filters/index.js b/packages/plugins/graphql/server/services/builders/filters/index.js new file mode 100644 index 0000000000..a70b629107 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/index.js @@ -0,0 +1,7 @@ +'use strict'; + +const contentType = require('./content-type'); + +module.exports = context => ({ + ...contentType(context), +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/and.js b/packages/plugins/graphql/server/services/builders/filters/operators/and.js new file mode 100644 index 0000000000..7725c09eda --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/and.js @@ -0,0 +1,15 @@ +'use strict'; + +const { list } = require('nexus'); + +const AND_FIELD_NAME = 'and'; + +module.exports = () => ({ + fieldName: AND_FIELD_NAME, + + strapiOperator: '$and', + + add(t, type) { + t.field(AND_FIELD_NAME, { type: list(type) }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/between.js b/packages/plugins/graphql/server/services/builders/filters/operators/between.js new file mode 100644 index 0000000000..2b8b817446 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/between.js @@ -0,0 +1,15 @@ +'use strict'; + +const { list } = require('nexus'); + +const BETWEEN_FIELD_NAME = 'between'; + +module.exports = () => ({ + fieldName: BETWEEN_FIELD_NAME, + + strapiOperator: '$between', + + add(t, type) { + t.field(BETWEEN_FIELD_NAME, { type: list(type) }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/contains.js b/packages/plugins/graphql/server/services/builders/filters/operators/contains.js new file mode 100644 index 0000000000..533f6945e0 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/contains.js @@ -0,0 +1,13 @@ +'use strict'; + +const CONTAINS_FIELD_NAME = 'contains'; + +module.exports = () => ({ + fieldName: CONTAINS_FIELD_NAME, + + strapiOperator: '$contains', + + add(t, type) { + t.field(CONTAINS_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/containsi.js b/packages/plugins/graphql/server/services/builders/filters/operators/containsi.js new file mode 100644 index 0000000000..872bd6e35c --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/containsi.js @@ -0,0 +1,13 @@ +'use strict'; + +const CONTAINSI_FIELD_NAME = 'containsi'; + +module.exports = () => ({ + fieldName: CONTAINSI_FIELD_NAME, + + strapiOperator: '$containsi', + + add(t, type) { + t.field(CONTAINSI_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/ends-with.js b/packages/plugins/graphql/server/services/builders/filters/operators/ends-with.js new file mode 100644 index 0000000000..6cc7e5591b --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/ends-with.js @@ -0,0 +1,13 @@ +'use strict'; + +const ENDS_WITH_FIELD_NAME = 'endsWith'; + +module.exports = () => ({ + fieldName: ENDS_WITH_FIELD_NAME, + + strapiOperator: '$endsWith', + + add(t, type) { + t.field(ENDS_WITH_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/eq.js b/packages/plugins/graphql/server/services/builders/filters/operators/eq.js new file mode 100644 index 0000000000..abd072cd25 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/eq.js @@ -0,0 +1,19 @@ +'use strict'; + +const EQ_FIELD_NAME = 'eq'; + +module.exports = ({ strapi }) => ({ + fieldName: EQ_FIELD_NAME, + + strapiOperator: '$eq', + + add(t, type) { + const { GRAPHQL_SCALARS } = strapi.plugin('graphql').service('constants'); + + if (!GRAPHQL_SCALARS.includes(type)) { + throw new Error(`Can't use "${EQ_FIELD_NAME}" operator. "${type}" is not a valid scalar`); + } + + t.field(EQ_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/gt.js b/packages/plugins/graphql/server/services/builders/filters/operators/gt.js new file mode 100644 index 0000000000..4c5af10a03 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/gt.js @@ -0,0 +1,13 @@ +'use strict'; + +const GT_FIELD_NAME = 'gt'; + +module.exports = () => ({ + fieldName: GT_FIELD_NAME, + + strapiOperator: '$gt', + + add(t, type) { + t.field(GT_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/gte.js b/packages/plugins/graphql/server/services/builders/filters/operators/gte.js new file mode 100644 index 0000000000..9c632c45a7 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/gte.js @@ -0,0 +1,13 @@ +'use strict'; + +const GTE_FIELD_NAME = 'gte'; + +module.exports = () => ({ + fieldName: GTE_FIELD_NAME, + + strapiOperator: '$gte', + + add(t, type) { + t.field(GTE_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/in.js b/packages/plugins/graphql/server/services/builders/filters/operators/in.js new file mode 100644 index 0000000000..8e1cd25a5c --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/in.js @@ -0,0 +1,15 @@ +'use strict'; + +const { list } = require('nexus'); + +const IN_FIELD_NAME = 'in'; + +module.exports = () => ({ + fieldName: IN_FIELD_NAME, + + strapiOperator: '$in', + + add(t, type) { + t.field(IN_FIELD_NAME, { type: list(type) }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/index.js b/packages/plugins/graphql/server/services/builders/filters/operators/index.js new file mode 100644 index 0000000000..2a4e2d4817 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/index.js @@ -0,0 +1,38 @@ +'use strict'; + +const { mapValues } = require('lodash/fp'); + +const operators = { + and: require('./and'), + or: require('./or'), + not: require('./not'), + + eq: require('./eq'), + ne: require('./ne'), + + startsWith: require('./starts-with'), + endsWith: require('./ends-with'), + + contains: require('./contains'), + notContains: require('./not-contains'), + + containsi: require('./containsi'), + notContainsi: require('./not-containsi'), + + gt: require('./gt'), + gte: require('./gte'), + + lt: require('./lt'), + lte: require('./lte'), + + null: require('./null'), + notNull: require('./not-null'), + + in: require('./in'), + notIn: require('./not-in'), + + between: require('./between'), +}; + +// Instantiate every operator with the Strapi instance +module.exports = context => mapValues(opCtor => opCtor(context), operators); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/lt.js b/packages/plugins/graphql/server/services/builders/filters/operators/lt.js new file mode 100644 index 0000000000..94eda03d1b --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/lt.js @@ -0,0 +1,13 @@ +'use strict'; + +const LT_FIELD_NAME = 'lt'; + +module.exports = () => ({ + fieldName: LT_FIELD_NAME, + + strapiOperator: '$lt', + + add(t, type) { + t.field(LT_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/lte.js b/packages/plugins/graphql/server/services/builders/filters/operators/lte.js new file mode 100644 index 0000000000..a4177be90e --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/lte.js @@ -0,0 +1,13 @@ +'use strict'; + +const LTE_FIELD_NAME = 'lte'; + +module.exports = () => ({ + fieldName: LTE_FIELD_NAME, + + strapiOperator: '$lte', + + add(t, type) { + t.field(LTE_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/ne.js b/packages/plugins/graphql/server/services/builders/filters/operators/ne.js new file mode 100644 index 0000000000..1ecf62052e --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/ne.js @@ -0,0 +1,13 @@ +'use strict'; + +const NE_FIELD_NAME = 'ne'; + +module.exports = () => ({ + fieldName: NE_FIELD_NAME, + + strapiOperator: '$ne', + + add(t, type) { + t.field(NE_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/not-contains.js b/packages/plugins/graphql/server/services/builders/filters/operators/not-contains.js new file mode 100644 index 0000000000..ab2d6ca919 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/not-contains.js @@ -0,0 +1,13 @@ +'use strict'; + +const NOT_CONTAINS_FIELD_NAME = 'notContains'; + +module.exports = () => ({ + fieldName: NOT_CONTAINS_FIELD_NAME, + + strapiOperator: '$notContains', + + add(t, type) { + t.field(NOT_CONTAINS_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/not-containsi.js b/packages/plugins/graphql/server/services/builders/filters/operators/not-containsi.js new file mode 100644 index 0000000000..36d4fa68cb --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/not-containsi.js @@ -0,0 +1,13 @@ +'use strict'; + +const NOT_CONTAINSI_FIELD_NAME = 'notContainsi'; + +module.exports = () => ({ + fieldName: NOT_CONTAINSI_FIELD_NAME, + + strapiOperator: '$notContainsi', + + add(t, type) { + t.field(NOT_CONTAINSI_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/not-in.js b/packages/plugins/graphql/server/services/builders/filters/operators/not-in.js new file mode 100644 index 0000000000..c5deed1717 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/not-in.js @@ -0,0 +1,15 @@ +'use strict'; + +const { list } = require('nexus'); + +const NOT_IN_FIELD_NAME = 'notIn'; + +module.exports = () => ({ + fieldName: NOT_IN_FIELD_NAME, + + strapiOperator: '$notIn', + + add(t, type) { + t.field(NOT_IN_FIELD_NAME, { type: list(type) }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/not-null.js b/packages/plugins/graphql/server/services/builders/filters/operators/not-null.js new file mode 100644 index 0000000000..befe3cefac --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/not-null.js @@ -0,0 +1,13 @@ +'use strict'; + +const NOT_NULL_FIELD_NAME = 'notNull'; + +module.exports = () => ({ + fieldName: NOT_NULL_FIELD_NAME, + + strapiOperator: '$notNull', + + add(t) { + t.boolean(NOT_NULL_FIELD_NAME); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/not.js b/packages/plugins/graphql/server/services/builders/filters/operators/not.js new file mode 100644 index 0000000000..2987952eba --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/not.js @@ -0,0 +1,19 @@ +'use strict'; + +const NOT_FIELD_NAME = 'not'; + +module.exports = ({ strapi }) => ({ + fieldName: NOT_FIELD_NAME, + + strapiOperator: '$not', + + add(t, type) { + const { naming, attributes } = strapi.plugin('graphql').service('utils'); + + if (attributes.isGraphQLScalar({ type })) { + t.field(NOT_FIELD_NAME, { type: naming.getScalarFilterInputTypeName(type) }); + } else { + t.field(NOT_FIELD_NAME, { type }); + } + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/null.js b/packages/plugins/graphql/server/services/builders/filters/operators/null.js new file mode 100644 index 0000000000..da161f81e7 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/null.js @@ -0,0 +1,13 @@ +'use strict'; + +const NULL_FIELD_NAME = 'null'; + +module.exports = () => ({ + fieldName: NULL_FIELD_NAME, + + strapiOperator: '$null', + + add(t) { + t.boolean(NULL_FIELD_NAME); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/or.js b/packages/plugins/graphql/server/services/builders/filters/operators/or.js new file mode 100644 index 0000000000..e789b8016c --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/or.js @@ -0,0 +1,15 @@ +'use strict'; + +const { list } = require('nexus'); + +const OR_FIELD_NAME = 'or'; + +module.exports = () => ({ + fieldName: OR_FIELD_NAME, + + strapiOperator: '$or', + + add(t, type) { + t.field(OR_FIELD_NAME, { type: list(type) }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/filters/operators/starts-with.js b/packages/plugins/graphql/server/services/builders/filters/operators/starts-with.js new file mode 100644 index 0000000000..17e3f60450 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/filters/operators/starts-with.js @@ -0,0 +1,13 @@ +'use strict'; + +const STARTS_WITH_FIELD_NAME = 'startsWith'; + +module.exports = () => ({ + fieldName: STARTS_WITH_FIELD_NAME, + + strapiOperator: '$startsWith', + + add(t, type) { + t.field(STARTS_WITH_FIELD_NAME, { type }); + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/generic-morph.js b/packages/plugins/graphql/server/services/builders/generic-morph.js new file mode 100644 index 0000000000..83c84d44ec --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/generic-morph.js @@ -0,0 +1,41 @@ +'use strict'; + +const { unionType } = require('nexus'); +const { prop } = require('lodash/fp'); + +module.exports = ({ strapi, registry }) => { + const { naming } = strapi.plugin('graphql').service('utils'); + const { KINDS, GENERIC_MORPH_TYPENAME } = strapi.plugin('graphql').service('constants'); + + return { + buildGenericMorphDefinition() { + return unionType({ + name: GENERIC_MORPH_TYPENAME, + + resolveType(obj) { + const contentType = strapi.getModel(obj.__type); + + if (!contentType) { + return null; + } + + if (contentType.modelType === 'component') { + return naming.getComponentName(contentType); + } + + return naming.getTypeName(contentType); + }, + + definition(t) { + const members = registry + // Resolve every content-type or component + .where(({ config }) => [KINDS.type, KINDS.component].includes(config.kind)) + // Only keep their name (the type's id) + .map(prop('name')); + + t.members(...members); + }, + }); + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/index.js b/packages/plugins/graphql/server/services/builders/index.js new file mode 100644 index 0000000000..c611241d10 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/index.js @@ -0,0 +1,92 @@ +'use strict'; + +const { merge, map, pipe, reduce } = require('lodash/fp'); + +// Builders Factories + +const enums = require('./enums'); +const dynamicZone = require('./dynamic-zones'); +const entity = require('./entity'); +const entityMeta = require('./entity-meta'); +const type = require('./type'); +const response = require('./response'); +const responseCollection = require('./response-collection'); +const relationResponseCollection = require('./relation-response-collection'); +const queries = require('./queries'); +const mutations = require('./mutations'); +const filters = require('./filters'); +const inputs = require('./input'); +const genericMorph = require('./generic-morph'); +const resolvers = require('./resolvers'); + +// Misc + +const operators = require('./filters/operators'); +const utils = require('./utils'); + +const buildersFactories = [ + enums, + dynamicZone, + entity, + entityMeta, + type, + response, + responseCollection, + relationResponseCollection, + queries, + mutations, + filters, + inputs, + genericMorph, + resolvers, +]; + +module.exports = ({ strapi }) => { + const buildersMap = new Map(); + + return { + /** + * Instantiate every builder with a strapi instance & a type registry + * @param {string} name + * @param {object} registry + */ + new(name, registry) { + const context = { strapi, registry }; + + const builders = pipe( + // Create a new instance of every builders + map(factory => factory(context)), + // Merge every builder into the same object + reduce(merge, {}) + ).call(null, buildersFactories); + + buildersMap.set(name, builders); + + return builders; + }, + + /** + * Delete a set of builders instances from + * the builders map for a given name + * @param {string} name + */ + delete(name) { + buildersMap.delete(name); + }, + + /** + * Retrieve a set of builders instances from + * the builders map for a given name + * @param {string} name + */ + get(name) { + return buildersMap.get(name); + }, + + filters: { + operators: operators({ strapi }), + }, + + utils: utils({ strapi }), + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/input.js b/packages/plugins/graphql/server/services/builders/input.js new file mode 100644 index 0000000000..b72cc09efa --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/input.js @@ -0,0 +1,118 @@ +'use strict'; + +const { inputObjectType, nonNull } = require('nexus'); + +module.exports = context => { + const { strapi } = context; + + const { naming, mappers, attributes } = strapi.plugin('graphql').service('utils'); + const extension = strapi.plugin('graphql').service('extension'); + + const { + getComponentInputName, + getContentTypeInputName, + getEnumName, + getDynamicZoneInputName, + } = naming; + + const { + isStrapiScalar, + isRelation, + isMorphRelation, + isMedia, + isEnumeration, + isComponent, + isDynamicZone, + } = attributes; + + return { + buildInputType(contentType) { + const { attributes, modelType } = contentType; + + const name = (modelType === 'component' + ? getComponentInputName + : getContentTypeInputName + ).call(null, contentType); + + return inputObjectType({ + name, + + definition(t) { + const isFieldEnabled = fieldName => { + return extension + .shadowCRUD(contentType.uid) + .field(fieldName) + .hasInputEnabled(); + }; + + const validAttributes = Object.entries(attributes).filter(([attributeName]) => + isFieldEnabled(attributeName) + ); + + // Add the ID for the component to enable inplace updates + if (modelType === 'component' && isFieldEnabled('id')) { + t.id('id'); + } + + validAttributes.forEach(([attributeName, attribute]) => { + // Scalars + if (isStrapiScalar(attribute)) { + const gqlScalar = mappers.strapiScalarToGraphQLScalar(attribute.type); + + t.field(attributeName, { type: gqlScalar }); + } + + // Media + else if (isMedia(attribute)) { + const isMultiple = attribute.multiple === true; + + if (extension.shadowCRUD('plugin::upload.file').isDisabled()) { + return; + } + + isMultiple ? t.list.id(attributeName) : t.id(attributeName); + } + + // Regular Relations (ignore polymorphic relations) + else if (isRelation(attribute) && !isMorphRelation(attribute)) { + if (extension.shadowCRUD(attribute.target).isDisabled()) { + return; + } + + const isToManyRelation = attribute.relation.endsWith('Many'); + + isToManyRelation ? t.list.id(attributeName) : t.id(attributeName); + } + + // Enums + else if (isEnumeration(attribute)) { + const enumTypeName = getEnumName(contentType, attributeName); + + t.field(attributeName, { type: enumTypeName }); + } + + // Components + else if (isComponent(attribute)) { + const isRepeatable = attribute.repeatable === true; + const component = strapi.components[attribute.component]; + const componentInputType = getComponentInputName(component); + + if (isRepeatable) { + t.list.field(attributeName, { type: componentInputType }); + } else { + t.field(attributeName, { type: componentInputType }); + } + } + + // Dynamic Zones + else if (isDynamicZone(attribute)) { + const dzInputName = getDynamicZoneInputName(contentType, attributeName); + + t.list.field(attributeName, { type: nonNull(dzInputName) }); + } + }); + }, + }); + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/mutations/collection-type.js b/packages/plugins/graphql/server/services/builders/mutations/collection-type.js new file mode 100644 index 0000000000..cd1e6c079d --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/mutations/collection-type.js @@ -0,0 +1,170 @@ +'use strict'; + +const { extendType, nonNull } = require('nexus'); + +module.exports = ({ strapi }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { naming } = getService('utils'); + const { transformArgs } = getService('builders').utils; + const { toEntityResponse } = getService('format').returnTypes; + + const { + getCreateMutationTypeName, + getUpdateMutationTypeName, + getDeleteMutationTypeName, + getEntityResponseName, + getContentTypeInputName, + } = naming; + + const addCreateMutation = (t, contentType) => { + const { uid } = contentType; + + const createMutationName = getCreateMutationTypeName(contentType); + const responseTypeName = getEntityResponseName(contentType); + + t.field(createMutationName, { + type: responseTypeName, + + args: { + // Create payload + data: nonNull(getContentTypeInputName(contentType)), + }, + + async resolve(parent, args) { + const transformedArgs = transformArgs(args, { contentType }); + + const { create } = getService('builders') + .get('content-api') + .buildMutationsResolvers({ contentType }); + + const value = await create(parent, transformedArgs); + + return toEntityResponse(value, { args: transformedArgs, resourceUID: uid }); + }, + }); + }; + + const addUpdateMutation = (t, contentType) => { + const { uid } = contentType; + + const updateMutationName = getUpdateMutationTypeName(contentType); + const responseTypeName = getEntityResponseName(contentType); + + // todo[v4]: Don't allow to filter using every unique attributes for now + // Only authorize filtering using unique scalar fields for updateOne queries + // const uniqueAttributes = getUniqueAttributesFiltersMap(attributes); + + t.field(updateMutationName, { + type: responseTypeName, + + args: { + // Query args + id: nonNull('ID'), + // todo[v4]: Don't allow to filter using every unique attributes for now + // ...uniqueAttributes, + + // Update payload + data: nonNull(getContentTypeInputName(contentType)), + }, + + async resolve(parent, args) { + const transformedArgs = transformArgs(args, { contentType }); + + const { update } = getService('builders') + .get('content-api') + .buildMutationsResolvers({ contentType }); + + const value = await update(parent, transformedArgs); + + return toEntityResponse(value, { args: transformedArgs, resourceUID: uid }); + }, + }); + }; + + const addDeleteMutation = (t, contentType) => { + const { uid } = contentType; + + const deleteMutationName = getDeleteMutationTypeName(contentType); + const responseTypeName = getEntityResponseName(contentType); + + // todo[v4]: Don't allow to filter using every unique attributes for now + // Only authorize filtering using unique scalar fields for updateOne queries + // const uniqueAttributes = getUniqueAttributesFiltersMap(attributes); + + t.field(deleteMutationName, { + type: responseTypeName, + + args: { + // Query args + id: nonNull('ID'), + // todo[v4]: Don't allow to filter using every unique attributes for now + // ...uniqueAttributes, + }, + + async resolve(parent, args) { + const transformedArgs = transformArgs(args, { contentType }); + + const { delete: deleteResolver } = getService('builders') + .get('content-api') + .buildMutationsResolvers({ contentType }); + + const value = await deleteResolver(parent, args); + + return toEntityResponse(value, { args: transformedArgs, resourceUID: uid }); + }, + }); + }; + + return { + buildCollectionTypeMutations(contentType) { + const createMutationName = `Mutation.${getCreateMutationTypeName(contentType)}`; + const updateMutationName = `Mutation.${getUpdateMutationTypeName(contentType)}`; + const deleteMutationName = `Mutation.${getDeleteMutationTypeName(contentType)}`; + + const extension = getService('extension'); + + const registerAuthConfig = (action, auth) => { + return extension.use({ resolversConfig: { [action]: { auth } } }); + }; + + const isActionEnabled = action => { + return extension.shadowCRUD(contentType.uid).isActionEnabled(action); + }; + + const isCreateEnabled = isActionEnabled('create'); + const isUpdateEnabled = isActionEnabled('update'); + const isDeleteEnabled = isActionEnabled('delete'); + + if (isCreateEnabled) { + registerAuthConfig(createMutationName, { scope: [`${contentType.uid}.create`] }); + } + + if (isUpdateEnabled) { + registerAuthConfig(updateMutationName, { scope: [`${contentType.uid}.update`] }); + } + + if (isDeleteEnabled) { + registerAuthConfig(deleteMutationName, { scope: [`${contentType.uid}.delete`] }); + } + + return extendType({ + type: 'Mutation', + + definition(t) { + if (isCreateEnabled) { + addCreateMutation(t, contentType); + } + + if (isUpdateEnabled) { + addUpdateMutation(t, contentType); + } + + if (isDeleteEnabled) { + addDeleteMutation(t, contentType); + } + }, + }); + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/mutations/index.js b/packages/plugins/graphql/server/services/builders/mutations/index.js new file mode 100644 index 0000000000..b2b6241da9 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/mutations/index.js @@ -0,0 +1,9 @@ +'use strict'; + +const createCollectionTypeMutationsBuilder = require('./collection-type'); +const createSingleTypeMutationsBuilder = require('./single-type'); + +module.exports = context => ({ + ...createCollectionTypeMutationsBuilder(context), + ...createSingleTypeMutationsBuilder(context), +}); diff --git a/packages/plugins/graphql/server/services/builders/mutations/single-type.js b/packages/plugins/graphql/server/services/builders/mutations/single-type.js new file mode 100644 index 0000000000..87365be18b --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/mutations/single-type.js @@ -0,0 +1,135 @@ +'use strict'; + +const { extendType, nonNull } = require('nexus'); +const { omit, isNil } = require('lodash/fp'); +const { getNonWritableAttributes } = require('@strapi/utils').contentTypes; + +const sanitizeInput = (contentType, data) => omit(getNonWritableAttributes(contentType), data); + +module.exports = ({ strapi }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { naming } = getService('utils'); + const { transformArgs } = getService('builders').utils; + const { toEntityResponse } = getService('format').returnTypes; + + const { + getUpdateMutationTypeName, + getEntityResponseName, + getContentTypeInputName, + getDeleteMutationTypeName, + } = naming; + + const addUpdateMutation = (t, contentType) => { + const { uid } = contentType; + + const updateMutationName = getUpdateMutationTypeName(contentType); + const responseTypeName = getEntityResponseName(contentType); + + t.field(updateMutationName, { + type: responseTypeName, + + args: { + // Update payload + data: nonNull(getContentTypeInputName(contentType)), + }, + + async resolve(parent, args) { + const transformedArgs = transformArgs(args, { contentType }); + + // Sanitize input data + Object.assign(transformedArgs, { data: sanitizeInput(contentType, transformedArgs.data) }); + + const { create, update } = getService('builders') + .get('content-api') + .buildMutationsResolvers({ contentType }); + + const findParams = omit(['data', 'files'], transformedArgs); + const entity = await strapi.entityService.findMany(uid, { params: findParams }); + + // Create or update + const value = isNil(entity) + ? create(parent, transformedArgs) + : update(uid, { id: entity.id, data: transformedArgs.data }); + + return toEntityResponse(value, { args: transformedArgs, resourceUID: uid }); + }, + }); + }; + + const addDeleteMutation = (t, contentType) => { + const { uid } = contentType; + + const deleteMutationName = getDeleteMutationTypeName(contentType); + const responseTypeName = getEntityResponseName(contentType); + + t.field(deleteMutationName, { + type: responseTypeName, + + args: {}, + + async resolve(parent, args) { + const transformedArgs = transformArgs(args, { contentType }); + + Object.assign(transformedArgs, { data: sanitizeInput(contentType, transformedArgs.data) }); + + const { delete: deleteResolver } = getService('builders') + .get('content-api') + .buildMutationsResolvers({ contentType }); + + const params = omit(['data', 'files'], transformedArgs); + const entity = await strapi.entityService.findMany(uid, { params }); + + if (!entity) { + throw new Error('Entity not found'); + } + + const value = await deleteResolver(parent, { id: entity.id, params }); + + return toEntityResponse(value, { args: transformedArgs, resourceUID: uid }); + }, + }); + }; + + return { + buildSingleTypeMutations(contentType) { + const updateMutationName = `Mutation.${getUpdateMutationTypeName(contentType)}`; + const deleteMutationName = `Mutation.${getDeleteMutationTypeName(contentType)}`; + + const extension = getService('extension'); + + const registerAuthConfig = (action, auth) => { + return extension.use({ resolversConfig: { [action]: { auth } } }); + }; + + const isActionEnabled = action => { + return extension.shadowCRUD(contentType.uid).isActionEnabled(action); + }; + + const isUpdateEnabled = isActionEnabled('update'); + const isDeleteEnabled = isActionEnabled('delete'); + + if (isUpdateEnabled) { + registerAuthConfig(updateMutationName, { scope: [`${contentType.uid}.update`] }); + } + + if (isDeleteEnabled) { + registerAuthConfig(deleteMutationName, { scope: [`${contentType.uid}.delete`] }); + } + + return extendType({ + type: 'Mutation', + + definition(t) { + if (isUpdateEnabled) { + addUpdateMutation(t, contentType); + } + + if (isDeleteEnabled) { + addDeleteMutation(t, contentType); + } + }, + }); + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/queries/collection-type.js b/packages/plugins/graphql/server/services/builders/queries/collection-type.js new file mode 100644 index 0000000000..acf648473a --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/queries/collection-type.js @@ -0,0 +1,120 @@ +'use strict'; + +const { extendType } = require('nexus'); + +module.exports = ({ strapi }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { naming } = getService('utils'); + const { transformArgs, getContentTypeArgs } = getService('builders').utils; + const { toEntityResponse, toEntityResponseCollection } = getService('format').returnTypes; + + const { + getFindOneQueryName, + getEntityResponseName, + getFindQueryName, + getEntityResponseCollectionName, + } = naming; + + const buildCollectionTypeQueries = contentType => { + const findOneQueryName = `Query.${getFindOneQueryName(contentType)}`; + const findQueryName = `Query.${getFindQueryName(contentType)}`; + + const extension = getService('extension'); + + const registerAuthConfig = (action, auth) => { + return extension.use({ resolversConfig: { [action]: { auth } } }); + }; + + const isActionEnabled = action => { + return extension.shadowCRUD(contentType.uid).isActionEnabled(action); + }; + + const isFindOneEnabled = isActionEnabled('findOne'); + const isFindEnabled = isActionEnabled('find'); + + if (isFindOneEnabled) { + registerAuthConfig(findOneQueryName, { scope: [`${contentType.uid}.findOne`] }); + } + + if (isFindEnabled) { + registerAuthConfig(findQueryName, { scope: [`${contentType.uid}.find`] }); + } + + return extendType({ + type: 'Query', + + definition(t) { + if (isFindOneEnabled) { + addFindOneQuery(t, contentType); + } + + if (isFindEnabled) { + addFindQuery(t, contentType); + } + }, + }); + }; + + /** + * Register a "find one" query field to the nexus type definition + * @param {OutputDefinitionBlock} t + * @param contentType + */ + const addFindOneQuery = (t, contentType) => { + const { uid } = contentType; + + const findOneQueryName = getFindOneQueryName(contentType); + const responseTypeName = getEntityResponseName(contentType); + + t.field(findOneQueryName, { + type: responseTypeName, + + args: getContentTypeArgs(contentType, { multiple: false }), + + async resolve(parent, args) { + const transformedArgs = transformArgs(args, { contentType }); + + const { findOne } = getService('builders') + .get('content-api') + .buildQueriesResolvers({ contentType }); + + const value = findOne(parent, transformedArgs); + + return toEntityResponse(value, { args: transformedArgs, resourceUID: uid }); + }, + }); + }; + + /** + * Register a "find" query field to the nexus type definition + * @param {OutputDefinitionBlock} t + * @param contentType + */ + const addFindQuery = (t, contentType) => { + const { uid } = contentType; + + const findQueryName = getFindQueryName(contentType); + const responseCollectionTypeName = getEntityResponseCollectionName(contentType); + + t.field(findQueryName, { + type: responseCollectionTypeName, + + args: getContentTypeArgs(contentType), + + async resolve(parent, args) { + const transformedArgs = transformArgs(args, { contentType, usePagination: true }); + + const { find } = getService('builders') + .get('content-api') + .buildQueriesResolvers({ contentType }); + + const nodes = await find(parent, transformedArgs); + + return toEntityResponseCollection(nodes, { args: transformedArgs, resourceUID: uid }); + }, + }); + }; + + return { buildCollectionTypeQueries }; +}; diff --git a/packages/plugins/graphql/server/services/builders/queries/index.js b/packages/plugins/graphql/server/services/builders/queries/index.js new file mode 100644 index 0000000000..f625ea090e --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/queries/index.js @@ -0,0 +1,9 @@ +'use strict'; + +const createCollectionTypeQueriesBuilder = require('./collection-type'); +const createSingleTypeQueriesBuilder = require('./single-type'); + +module.exports = context => ({ + ...createCollectionTypeQueriesBuilder(context), + ...createSingleTypeQueriesBuilder(context), +}); diff --git a/packages/plugins/graphql/server/services/builders/queries/single-type.js b/packages/plugins/graphql/server/services/builders/queries/single-type.js new file mode 100644 index 0000000000..b936d9bec1 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/queries/single-type.js @@ -0,0 +1,70 @@ +'use strict'; + +const { extendType } = require('nexus'); + +module.exports = ({ strapi }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { naming } = getService('utils'); + const { transformArgs, getContentTypeArgs } = getService('builders').utils; + const { toEntityResponse } = getService('format').returnTypes; + + const { getFindOneQueryName, getEntityResponseName } = naming; + + const buildSingleTypeQueries = contentType => { + const findQueryName = `Query.${getFindOneQueryName(contentType)}`; + + const extension = getService('extension'); + + const registerAuthConfig = (action, auth) => { + return extension.use({ resolversConfig: { [action]: { auth } } }); + }; + + const isActionEnabled = action => { + return extension.shadowCRUD(contentType.uid).isActionEnabled(action); + }; + + const isFindEnabled = isActionEnabled('find'); + + if (isFindEnabled) { + registerAuthConfig(findQueryName, { scope: [`${contentType.uid}.find`] }); + } + + return extendType({ + type: 'Query', + + definition(t) { + if (isFindEnabled) { + addFindQuery(t, contentType); + } + }, + }); + }; + + const addFindQuery = (t, contentType) => { + const { uid } = contentType; + + const findQueryName = getFindOneQueryName(contentType); + const responseTypeName = getEntityResponseName(contentType); + + t.field(findQueryName, { + type: responseTypeName, + + args: getContentTypeArgs(contentType), + + async resolve(parent, args) { + const transformedArgs = transformArgs(args, { contentType }); + + const queriesResolvers = getService('builders') + .get('content-api') + .buildQueriesResolvers({ contentType }); + + const value = queriesResolvers.find(parent, transformedArgs); + + return toEntityResponse(value, { args: transformedArgs, resourceUID: uid }); + }, + }); + }; + + return { buildSingleTypeQueries }; +}; diff --git a/packages/plugins/graphql/server/services/builders/relation-response-collection.js b/packages/plugins/graphql/server/services/builders/relation-response-collection.js new file mode 100644 index 0000000000..c812f7af78 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/relation-response-collection.js @@ -0,0 +1,32 @@ +'use strict'; + +const { objectType, nonNull } = require('nexus'); +const { defaultTo, prop, pipe } = require('lodash/fp'); + +module.exports = ({ strapi }) => { + const { naming } = strapi.plugin('graphql').service('utils'); + + return { + /** + * Build a type definition for a content API relation's collection response for a given content type + * @param {object} contentType The content type which will be used to build its content API response definition + * @return {NexusObjectTypeDef} + */ + buildRelationResponseCollectionDefinition(contentType) { + const name = naming.getRelationResponseCollectionName(contentType); + const entityName = naming.getEntityName(contentType); + + return objectType({ + name, + + definition(t) { + t.nonNull.list.field('data', { + type: nonNull(entityName), + + resolve: pipe(prop('nodes'), defaultTo([])), + }); + }, + }); + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/resolvers/association.js b/packages/plugins/graphql/server/services/builders/resolvers/association.js new file mode 100644 index 0000000000..cf86724e93 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/resolvers/association.js @@ -0,0 +1,64 @@ +'use strict'; + +module.exports = ({ strapi }) => { + const { service: getGraphQLService } = strapi.plugin('graphql'); + + const { isMorphRelation, isMedia } = getGraphQLService('utils').attributes; + const { transformArgs } = getGraphQLService('builders').utils; + const { toEntityResponse, toEntityResponseCollection } = getGraphQLService('format').returnTypes; + + return { + buildAssociationResolver({ contentTypeUID, attributeName }) { + const contentType = strapi.getModel(contentTypeUID); + const attribute = contentType.attributes[attributeName]; + + if (!attribute) { + throw new Error( + `Failed to build an association resolver for ${contentTypeUID}::${attributeName}` + ); + } + + const isMediaAttribute = isMedia(attribute); + const isMorphAttribute = isMorphRelation(attribute); + + const targetUID = isMediaAttribute ? 'plugins::upload.file' : attribute.target; + const isToMany = isMediaAttribute ? attribute.multiple : attribute.relation.endsWith('Many'); + + const targetContentType = strapi.getModel(targetUID); + + return async (parent, args = {}) => { + const transformedArgs = transformArgs(args, { + contentType: targetContentType, + usePagination: true, + }); + + const data = await strapi.entityService.load( + contentTypeUID, + parent, + attributeName, + transformedArgs + ); + + const info = { + args: transformedArgs, + resourceUID: targetUID, + }; + + // If this a polymorphic association, it returns the raw data + if (isMorphAttribute) { + return data; + } + + // If this is a to-many relation, it returns an object that + // matches what the entity-response-collection's resolvers expect + else if (isToMany) { + return toEntityResponseCollection(data, info); + } + + // Else, it returns an object that matches + // what the entity-response's resolvers expect + return toEntityResponse(data, info); + }; + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/resolvers/component.js b/packages/plugins/graphql/server/services/builders/resolvers/component.js new file mode 100644 index 0000000000..71ad8c970b --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/resolvers/component.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = ({ strapi }) => ({ + buildComponentResolver({ contentTypeUID, attributeName }) { + const { transformArgs } = strapi.plugin('graphql').service('builders').utils; + + return async (parent, args = {}) => { + const contentType = strapi.contentTypes[contentTypeUID]; + const transformedArgs = transformArgs(args, { contentType, usePagination: true }); + + return strapi.entityService.load(contentTypeUID, parent, attributeName, transformedArgs); + }; + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/resolvers/dynamic-zone.js b/packages/plugins/graphql/server/services/builders/resolvers/dynamic-zone.js new file mode 100644 index 0000000000..b8be0e14c0 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/resolvers/dynamic-zone.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = ({ strapi }) => ({ + buildDynamicZoneResolver({ contentTypeUID, attributeName }) { + return async parent => { + return strapi.entityService.load(contentTypeUID, parent, attributeName); + }; + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/resolvers/index.js b/packages/plugins/graphql/server/services/builders/resolvers/index.js new file mode 100644 index 0000000000..919bea485a --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/resolvers/index.js @@ -0,0 +1,18 @@ +'use strict'; + +const associationResolvers = require('./association'); +const queriesResolvers = require('./query'); +const mutationsResolvers = require('./mutation'); +const componentResolvers = require('./component'); +const dynamicZoneResolvers = require('./dynamic-zone'); + +module.exports = context => ({ + // Generics + ...associationResolvers(context), + + // Builders + ...mutationsResolvers(context), + ...queriesResolvers(context), + ...componentResolvers(context), + ...dynamicZoneResolvers(context), +}); diff --git a/packages/plugins/graphql/server/services/builders/resolvers/mutation.js b/packages/plugins/graphql/server/services/builders/resolvers/mutation.js new file mode 100644 index 0000000000..7f2b8fc8d0 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/resolvers/mutation.js @@ -0,0 +1,33 @@ +'use strict'; + +const { pick } = require('lodash/fp'); + +const pickCreateArgs = pick(['params', 'data', 'files']); + +module.exports = ({ strapi }) => ({ + buildMutationsResolvers({ contentType }) { + const { uid } = contentType; + + return { + async create(parent, args) { + // todo[v4]: Might be interesting to generate dynamic yup schema to validate payloads with more complex checks (on top of graphql validation) + const params = pickCreateArgs(args); + + // todo[v4]: Sanitize args to only keep params / data / files (or do it in the base resolver) + return strapi.entityService.create(uid, params); + }, + + async update(parent, args) { + const { id, data } = args; + + return strapi.entityService.update(uid, id, { data }); + }, + + async delete(parent, args) { + const { id, ...rest } = args; + + return strapi.entityService.delete(uid, id, rest); + }, + }; + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/resolvers/query.js b/packages/plugins/graphql/server/services/builders/resolvers/query.js new file mode 100644 index 0000000000..22aac496ca --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/resolvers/query.js @@ -0,0 +1,19 @@ +'use strict'; + +const { omit } = require('lodash/fp'); + +module.exports = ({ strapi }) => ({ + buildQueriesResolvers({ contentType }) { + const { uid } = contentType; + + return { + async find(parent, args) { + return strapi.entityService.findMany(uid, args); + }, + + async findOne(parent, args) { + return strapi.entityService.findOne(uid, args.id, omit('id', args)); + }, + }; + }, +}); diff --git a/packages/plugins/graphql/server/services/builders/response-collection.js b/packages/plugins/graphql/server/services/builders/response-collection.js new file mode 100644 index 0000000000..b9e5f5d9b2 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/response-collection.js @@ -0,0 +1,40 @@ +'use strict'; + +const { objectType, nonNull } = require('nexus'); +const { defaultTo, prop, pipe } = require('lodash/fp'); + +module.exports = ({ strapi }) => { + const { naming } = strapi.plugin('graphql').service('utils'); + const { RESPONSE_COLLECTION_META_TYPE_NAME } = strapi.plugin('graphql').service('constants'); + + return { + /** + * Build a type definition for a content API collection response for a given content type + * @param {object} contentType The content type which will be used to build its content API response definition + * @return {NexusObjectTypeDef} + */ + buildResponseCollectionDefinition(contentType) { + const name = naming.getEntityResponseCollectionName(contentType); + const entityName = naming.getEntityName(contentType); + + return objectType({ + name, + + definition(t) { + t.nonNull.list.field('data', { + type: nonNull(entityName), + + resolve: pipe(prop('nodes'), defaultTo([])), + }); + + t.nonNull.field('meta', { + type: RESPONSE_COLLECTION_META_TYPE_NAME, + + // Pass down the args stored in the source object + resolve: prop('info'), + }); + }, + }); + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/response.js b/packages/plugins/graphql/server/services/builders/response.js new file mode 100644 index 0000000000..45f85686b3 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/response.js @@ -0,0 +1,32 @@ +'use strict'; + +const { objectType } = require('nexus'); +const { prop } = require('lodash/fp'); + +module.exports = ({ strapi }) => { + const { naming } = strapi.plugin('graphql').service('utils'); + + return { + /** + * Build a type definition for a content API response for a given content type + * @param {object} contentType The content type which will be used to build its content API response definition + * @return {NexusObjectTypeDef} + */ + buildResponseDefinition(contentType) { + const name = naming.getEntityResponseName(contentType); + const entityName = naming.getEntityName(contentType); + + return objectType({ + name, + + definition(t) { + t.field('data', { + type: entityName, + + resolve: prop('value'), + }); + }, + }); + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/type.js b/packages/plugins/graphql/server/services/builders/type.js new file mode 100644 index 0000000000..9015799ab7 --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/type.js @@ -0,0 +1,370 @@ +'use strict'; + +const { isArray, isString, isUndefined, constant } = require('lodash/fp'); +const { objectType } = require('nexus'); + +const { contentTypes } = require('@strapi/utils'); + +/** + * @typedef TypeBuildersOptions + * + * @property {ObjectDefinitionBlock} builder + * @property {string} attributeName + * @property {object} attribute + * @property {object} contentType + * @property {object} context + * @property {object} context.strapi + * @property {object} context.registry + */ +module.exports = context => { + const { strapi } = context; + + const getGraphQLService = strapi.plugin('graphql').service; + + const extension = getGraphQLService('extension'); + + /** + * Add a scalar attribute to the type definition + * + * The attribute is added based on a simple association between a Strapi + * type and a GraphQL type (the map is defined in `strapiTypeToGraphQLScalar`) + * + * @param {TypeBuildersOptions} options + */ + const addScalarAttribute = ({ builder, attributeName, attribute }) => { + const { mappers } = getGraphQLService('utils'); + + const gqlType = mappers.strapiScalarToGraphQLScalar(attribute.type); + + builder.field(attributeName, { type: gqlType }); + }; + + /** + * Add a component attribute to the type definition + * + * The attribute is added by fetching the component's type + * name and using it as the attribute's type + * + * @param {TypeBuildersOptions} options + */ + const addComponentAttribute = ({ builder, attributeName, contentType, attribute }) => { + const { naming } = getGraphQLService('utils'); + const { getContentTypeArgs } = getGraphQLService('builders').utils; + const { buildComponentResolver } = getGraphQLService('builders').get('content-api'); + + const type = naming.getComponentNameFromAttribute(attribute); + + if (attribute.repeatable) { + builder = builder.list; + } + + const targetComponent = strapi.getModel(attribute.component); + + const resolve = buildComponentResolver({ + contentTypeUID: contentType.uid, + attributeName, + strapi, + }); + + const args = getContentTypeArgs(targetComponent); + + builder.field(attributeName, { type, resolve, args }); + }; + + /** + * Add a dynamic zone attribute to the type definition + * + * The attribute is added by fetching the dynamic zone's + * type name and using it as the attribute's type + * + * @param {TypeBuildersOptions} options + */ + const addDynamicZoneAttribute = ({ builder, attributeName, contentType }) => { + const { naming } = getGraphQLService('utils'); + const { ERROR_CODES } = getGraphQLService('constants'); + const { buildDynamicZoneResolver } = getGraphQLService('builders').get('content-api'); + + const { components } = contentType.attributes[attributeName]; + + const isEmpty = components.length === 0; + const type = naming.getDynamicZoneName(contentType, attributeName); + + const resolve = isEmpty + ? // If the dynamic zone don't have any component, then return an error payload + constant({ + code: ERROR_CODES.emptyDynamicZone, + message: `This dynamic zone don't have any component attached to it`, + }) + : // Else, return a classic dynamic-zone resolver + buildDynamicZoneResolver({ + contentTypeUID: contentType.uid, + attributeName, + }); + + builder.list.field(attributeName, { type, resolve }); + }; + + /** + * Add an enum attribute to the type definition + * + * The attribute is added by fetching the enum's type + * name and using it as the attribute's type + * + * @param {TypeBuildersOptions} options + */ + const addEnumAttribute = ({ builder, attributeName, contentType }) => { + const { naming } = getGraphQLService('utils'); + + const type = naming.getEnumName(contentType, attributeName); + + builder.field(attributeName, { type }); + }; + + /** + * Add a media attribute to the type definition + * @param {TypeBuildersOptions} options + */ + const addMediaAttribute = options => { + const { naming } = getGraphQLService('utils'); + const { getContentTypeArgs } = getGraphQLService('builders').utils; + const { buildAssociationResolver } = getGraphQLService('builders').get('content-api'); + const extension = getGraphQLService('extension'); + + let { builder } = options; + const { attributeName, attribute, contentType } = options; + const fileUID = 'plugin::upload.file'; + + if (extension.shadowCRUD(fileUID).isDisabled()) { + return; + } + + const fileContentType = strapi.contentTypes[fileUID]; + + const resolve = buildAssociationResolver({ + contentTypeUID: contentType.uid, + attributeName, + strapi, + }); + + const args = attribute.multiple ? getContentTypeArgs(fileContentType) : undefined; + const type = attribute.multiple + ? naming.getRelationResponseCollectionName(fileContentType) + : naming.getEntityResponseName(fileContentType); + + builder.field(attributeName, { type, resolve, args }); + }; + + /** + * Add a polymorphic relational attribute to the type definition + * @param {TypeBuildersOptions} options + */ + const addPolymorphicRelationalAttribute = options => { + const { GENERIC_MORPH_TYPENAME } = getGraphQLService('constants'); + const { naming } = getGraphQLService('utils'); + const { buildAssociationResolver } = getGraphQLService('builders').get('content-api'); + + let { builder } = options; + const { attributeName, attribute, contentType } = options; + + const { target } = attribute; + const isToManyRelation = attribute.relation.endsWith('Many'); + + if (isToManyRelation) { + builder = builder.list; + } + // todo[v4]: How to handle polymorphic relation w/ entity response collection types? + // -> Currently return raw polymorphic entities + + const resolve = buildAssociationResolver({ + contentTypeUID: contentType.uid, + attributeName, + strapi, + }); + + // If there is no specific target specified, then use the GenericMorph type + if (isUndefined(target)) { + builder.field(attributeName, { + type: GENERIC_MORPH_TYPENAME, + resolve, + }); + } + + // If the target is an array of string, resolve the associated morph type and use it + else if (isArray(target) && target.every(isString)) { + const type = naming.getMorphRelationTypeName(contentType, attributeName); + + builder.field(attributeName, { type, resolve }); + } + }; + + /** + * Add a regular relational attribute to the type definition + * @param {TypeBuildersOptions} options + */ + const addRegularRelationalAttribute = options => { + const { naming } = getGraphQLService('utils'); + const { getContentTypeArgs } = getGraphQLService('builders').utils; + const { buildAssociationResolver } = getGraphQLService('builders').get('content-api'); + const extension = getGraphQLService('extension'); + + let { builder } = options; + const { attributeName, attribute, contentType } = options; + + if (extension.shadowCRUD(attribute.target).isDisabled()) { + return; + } + + const isToManyRelation = attribute.relation.endsWith('Many'); + + const resolve = buildAssociationResolver({ + contentTypeUID: contentType.uid, + attributeName, + strapi, + }); + + const targetContentType = strapi.getModel(attribute.target); + + const type = isToManyRelation + ? naming.getRelationResponseCollectionName(targetContentType) + : naming.getEntityResponseName(targetContentType); + + const args = isToManyRelation ? getContentTypeArgs(targetContentType) : undefined; + + builder.field(attributeName, { type, resolve, args }); + }; + + const isNotPrivate = contentType => attributeName => { + return !contentTypes.isPrivateAttribute(contentType, attributeName); + }; + + const isNotDisabled = contentType => attributeName => { + return extension + .shadowCRUD(contentType.uid) + .field(attributeName) + .hasOutputEnabled(); + }; + + return { + /** + * Create a type definition for a given content type + * @param contentType - The content type used to created the definition + * @return {NexusObjectTypeDef} + */ + buildTypeDefinition(contentType) { + const utils = getGraphQLService('utils'); + + const { getComponentName, getTypeName } = utils.naming; + const { + isStrapiScalar, + isComponent, + isDynamicZone, + isEnumeration, + isMedia, + isMorphRelation, + isRelation, + } = utils.attributes; + + const { attributes, modelType, options = {} } = contentType; + + const attributesKey = Object.keys(attributes); + const hasTimestamps = isArray(options.timestamps); + + const name = (modelType === 'component' ? getComponentName : getTypeName).call( + null, + contentType + ); + + return objectType({ + name, + + definition(t) { + if (modelType === 'component' && isNotDisabled(contentType)('id')) { + t.nonNull.id('id'); + } + + // 1. Timestamps + // If the content type has timestamps enabled + // then we should add the corresponding attributes in the definition + if (hasTimestamps) { + const [createdAtKey, updatedAtKey] = contentType.options.timestamps; + + t.nonNull.dateTime(createdAtKey); + t.nonNull.dateTime(updatedAtKey); + } + + /** 2. Attributes + * + * Attributes can be of 7 different kind: + * - Scalar + * - Component + * - Dynamic Zone + * - Enum + * - Media + * - Polymorphic Relations + * - Regular Relations + * + * Here, we iterate over each non-private attribute + * and add it to the type definition based on its type + */ + attributesKey + // Ignore private attributes + .filter(isNotPrivate(contentType)) + // Ignore disabled fields (from extension service) + .filter(isNotDisabled(contentType)) + // Add each attribute to the type definition + .forEach(attributeName => { + const attribute = attributes[attributeName]; + + // We create a copy of the builder (t) to apply custom + // rules only on the current attribute (eg: nonNull, list, ...) + let builder = t; + + if (attribute.required) { + builder = builder.nonNull; + } + + /** + * @type {TypeBuildersOptions} + */ + const options = { builder, attributeName, attribute, contentType, context }; + + // Scalars + if (isStrapiScalar(attribute)) { + addScalarAttribute(options); + } + + // Components + else if (isComponent(attribute)) { + addComponentAttribute(options); + } + + // Dynamic Zones + else if (isDynamicZone(attribute)) { + addDynamicZoneAttribute(options); + } + + // Enums + else if (isEnumeration(attribute)) { + addEnumAttribute(options); + } + + // Media + else if (isMedia(attribute)) { + addMediaAttribute(options); + } + + // Polymorphic Relations + else if (isMorphRelation(attribute)) { + addPolymorphicRelationalAttribute(options); + } + + // Regular Relations + else if (isRelation(attribute) || isMedia(attribute)) { + addRegularRelationalAttribute(options); + } + }); + }, + }); + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/builders/utils.js b/packages/plugins/graphql/server/services/builders/utils.js new file mode 100644 index 0000000000..7f5c43930e --- /dev/null +++ b/packages/plugins/graphql/server/services/builders/utils.js @@ -0,0 +1,131 @@ +'use strict'; + +const { entries, mapValues, omit } = require('lodash/fp'); +const { + pagination: { withDefaultPagination }, + contentTypes: { hasDraftAndPublish }, +} = require('@strapi/utils'); + +module.exports = ({ strapi }) => { + const { service: getService } = strapi.plugin('graphql'); + + return { + /** + * Get every args for a given content type + * @param {object} contentType + * @param {object} options + * @param {boolean} options.multiple + * @return {object} + */ + getContentTypeArgs(contentType, { multiple = true } = {}) { + const { naming } = getService('utils'); + const { args } = getService('internals'); + + const { kind, modelType } = contentType; + + // Components + if (modelType === 'component') { + return { + filters: naming.getFiltersInputTypeName(contentType), + pagination: args.PaginationArg, + sort: args.SortArg, + }; + } + + // Collection Types + else if (kind === 'collectionType') { + if (!multiple) { + return { id: 'ID' }; + } + + const params = { + filters: naming.getFiltersInputTypeName(contentType), + pagination: args.PaginationArg, + sort: args.SortArg, + }; + + if (hasDraftAndPublish(contentType)) { + Object.assign(params, { publicationState: args.PublicationStateArg }); + } + + return params; + } + + // Single Types + else if (kind === 'singleType') { + const params = {}; + + if (hasDraftAndPublish(contentType)) { + Object.assign(params, { publicationState: args.PublicationStateArg }); + } + + return params; + } + }, + + /** + * Filter an object entries and keep only those whose value is a unique scalar attribute + * @param {object} attributes + * @return {Object} + */ + getUniqueScalarAttributes(attributes) { + const { isStrapiScalar } = getService('utils').attributes; + + const uniqueAttributes = entries(attributes).filter( + ([, attribute]) => isStrapiScalar(attribute) && attribute.unique + ); + + return Object.fromEntries(uniqueAttributes); + }, + + /** + * Map each value from an attribute to a FiltersInput type name + * @param {object} attributes - The attributes object to transform + * @return {Object} + */ + scalarAttributesToFiltersMap: mapValues(attribute => { + const { mappers, naming } = getService('utils'); + + const gqlScalar = mappers.strapiScalarToGraphQLScalar(attribute.type); + + return naming.getScalarFilterInputTypeName(gqlScalar); + }), + + /** + * Apply basic transform to GQL args + */ + transformArgs(args, { contentType, usePagination = false } = {}) { + const { mappers } = getService('utils'); + const { pagination = {}, filters = {} } = args; + + // Init + const newArgs = omit(['pagination', 'filters'], args); + + // Pagination + if (usePagination) { + const defaultLimit = strapi.plugin('graphql').config('defaultLimit'); + const maxLimit = strapi.plugin('graphql').config('maxLimit', -1); + + Object.assign( + newArgs, + withDefaultPagination(pagination, { + maxLimit, + defaults: { + offset: { limit: defaultLimit }, + page: { pageSize: defaultLimit }, + }, + }) + ); + } + + // Filters + if (args.filters) { + Object.assign(newArgs, { + filters: mappers.graphQLFiltersToStrapiQuery(filters, contentType), + }); + } + + return newArgs; + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/constants.js b/packages/plugins/graphql/server/services/constants.js new file mode 100644 index 0000000000..300d51be8e --- /dev/null +++ b/packages/plugins/graphql/server/services/constants.js @@ -0,0 +1,147 @@ +'use strict'; + +const PAGINATION_TYPE_NAME = 'Pagination'; +const PUBLICATION_STATE_TYPE_NAME = 'PublicationState'; +const ERROR_TYPE_NAME = 'Error'; + +const RESPONSE_COLLECTION_META_TYPE_NAME = 'ResponseCollectionMeta'; + +const GRAPHQL_SCALARS = [ + 'ID', + 'Boolean', + 'Int', + 'String', + 'Long', + 'Float', + 'JSON', + 'Date', + 'Time', + 'DateTime', +]; + +const STRAPI_SCALARS = [ + 'boolean', + 'integer', + 'string', + 'richtext', + 'biginteger', + 'float', + 'decimal', + 'json', + 'date', + 'time', + 'datetime', + 'timestamp', + 'uid', + 'email', + 'password', + 'text', +]; + +const SCALARS_ASSOCIATIONS = { + uid: 'String', + email: 'String', + password: 'String', + text: 'String', + boolean: 'Boolean', + integer: 'Int', + string: 'String', + richtext: 'String', + biginteger: 'Long', + float: 'Float', + decimal: 'Float', + json: 'JSON', + date: 'Date', + time: 'Time', + datetime: 'DateTime', + timestamp: 'DateTime', +}; + +const GENERIC_MORPH_TYPENAME = 'GenericMorph'; + +const KINDS = { + type: 'type', + component: 'component', + dynamicZone: 'dynamic-zone', + enum: 'enum', + entity: 'entity', + entityResponse: 'entity-response', + entityResponseCollection: 'entity-response-collection', + relationResponseCollection: 'relation-response-collection', + query: 'query', + mutation: 'mutation', + input: 'input', + filtersInput: 'filters-input', + scalar: 'scalar', + morph: 'polymorphic', + internal: 'internal', +}; + +const allOperators = [ + 'and', + 'or', + 'not', + + 'eq', + 'ne', + + 'startsWith', + 'endsWith', + + 'contains', + 'notContains', + + 'containsi', + 'notContainsi', + + 'gt', + 'gte', + + 'lt', + 'lte', + + 'null', + 'notNull', + + 'in', + 'notIn', + + 'between', +]; + +const GRAPHQL_SCALAR_OPERATORS = { + // ID + ID: allOperators, + // Booleans + Boolean: allOperators, + // Strings + String: allOperators, + // Numbers + Int: allOperators, + Long: allOperators, + Float: allOperators, + // Dates + Date: allOperators, + Time: allOperators, + DateTime: allOperators, + // Others + JSON: allOperators, +}; + +const ERROR_CODES = { + emptyDynamicZone: 'dynamiczone.empty', +}; + +module.exports = () => ({ + PAGINATION_TYPE_NAME, + RESPONSE_COLLECTION_META_TYPE_NAME, + PUBLICATION_STATE_TYPE_NAME, + GRAPHQL_SCALARS, + STRAPI_SCALARS, + GENERIC_MORPH_TYPENAME, + KINDS, + GRAPHQL_SCALAR_OPERATORS, + SCALARS_ASSOCIATIONS, + ERROR_CODES, + ERROR_TYPE_NAME, +}); diff --git a/packages/plugins/graphql/server/services/content-api/index.js b/packages/plugins/graphql/server/services/content-api/index.js new file mode 100644 index 0000000000..f40abb37ec --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/index.js @@ -0,0 +1,168 @@ +'use strict'; + +const { + mergeSchemas, + makeExecutableSchema, + addResolversToSchema, +} = require('@graphql-tools/schema'); +const { makeSchema } = require('nexus'); +const { pipe, prop, startsWith } = require('lodash/fp'); + +const { wrapResolvers } = require('./wrap-resolvers'); +const { + registerSingleType, + registerCollectionType, + registerComponent, + registerScalars, + registerInternals, + registerPolymorphicContentType, + contentType: { + registerEnumsDefinition, + registerInputsDefinition, + registerFiltersDefinition, + registerDynamicZonesDefinition, + }, +} = require('./register-functions'); + +module.exports = ({ strapi }) => { + const { service: getGraphQLService } = strapi.plugin('graphql'); + const { config } = strapi.plugin('graphql'); + + const { KINDS, GENERIC_MORPH_TYPENAME } = getGraphQLService('constants'); + + // Type Registry + let registry; + // Builders Instances + let builders; + + const buildSchema = () => { + const extensionService = getGraphQLService('extension'); + + const isShadowCRUDEnabled = !!config('shadowCRUD', true); + + // Create a new empty type registry + registry = getGraphQLService('type-registry').new(); + + // Reset the builders instances associated to the + // content-api, and link the new type registry + builders = getGraphQLService('builders').new('content-api', registry); + + registerScalars({ registry, strapi }); + registerInternals({ registry, strapi }); + + if (isShadowCRUDEnabled) { + shadowCRUD(); + } + + // Generate the extension configuration for the content API + const extension = extensionService.generate({ typeRegistry: registry }); + + return pipe( + // Build a collection of schema based on the + // type registry & the extension configuration + buildSchemas, + // Merge every created schema into a single one + schemas => mergeSchemas({ schemas }), + // 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, strapi, extension }) + )({ registry, extension }); + }; + + const buildSchemas = ({ registry, extension }) => { + const { types, plugins, typeDefs = [] } = extension; + + // Create a new Nexus schema (shadow CRUD) & add it to the schemas collection + const nexusSchema = makeSchema({ + types: [ + // Add the auto-generated Nexus types (shadow CRUD) + registry.definitions, + // Add every Nexus type registered using the extension service + types, + ], + + plugins: [ + // Add every plugin registered using the extension service + ...plugins, + ], + }); + + // Build schemas based on SDL type definitions (defined in the extension) + const sdlSchemas = typeDefs.map(sdl => makeExecutableSchema({ typeDefs: sdl })); + + return [nexusSchema, ...sdlSchemas]; + }; + + const shadowCRUD = () => { + const extensionService = getGraphQLService('extension'); + + // Get every content type & component defined in Strapi + const contentTypes = [ + ...Object.values(strapi.components), + ...Object.values(strapi.contentTypes), + ]; + + // Disable Shadow CRUD for admin content types + contentTypes + .map(prop('uid')) + .filter(startsWith('admin::')) + .forEach(uid => extensionService.shadowCRUD(uid).disable()); + + const contentTypesWithShadowCRUD = contentTypes.filter(ct => + extensionService.shadowCRUD(ct.uid).isEnabled() + ); + + // Generate and register definitions for every content type + registerAPITypes(contentTypesWithShadowCRUD); + + // Generate and register polymorphic types' definitions + registerMorphTypes(contentTypesWithShadowCRUD); + }; + + /** + * Register needed GraphQL types for every content type + * @param {object[]} contentTypes + */ + const registerAPITypes = contentTypes => { + for (const contentType of contentTypes) { + const { kind, modelType } = contentType; + + const registerOptions = { registry, strapi, builders }; + + // Generate various types associated to the content type + // (enums, dynamic-zones, filters, inputs...) + registerEnumsDefinition(contentType, registerOptions); + registerDynamicZonesDefinition(contentType, registerOptions); + registerFiltersDefinition(contentType, registerOptions); + registerInputsDefinition(contentType, registerOptions); + + // Generate & register component's definition + if (modelType === 'component') { + registerComponent(contentType, registerOptions); + } + + // Generate & register single type's definition + else if (kind === 'singleType') { + registerSingleType(contentType, registerOptions); + } + + // Generate & register collection type's definition + else if (kind === 'collectionType') { + registerCollectionType(contentType, registerOptions); + } + } + }; + + const registerMorphTypes = contentTypes => { + // Create & register a union type that includes every type or component registered + const genericMorphType = builders.buildGenericMorphDefinition(); + registry.register(GENERIC_MORPH_TYPENAME, genericMorphType, { kind: KINDS.morph }); + + for (const contentType of contentTypes) { + registerPolymorphicContentType(contentType, { registry, strapi }); + } + }; + + return { buildSchema }; +}; diff --git a/packages/plugins/graphql/server/services/content-api/policy.js b/packages/plugins/graphql/server/services/content-api/policy.js new file mode 100644 index 0000000000..c256f5878a --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/policy.js @@ -0,0 +1,59 @@ +'use strict'; + +const { getOr } = require('lodash/fp'); +const { policy: policyUtils } = require('@strapi/utils'); + +const createPoliciesMiddleware = (resolverConfig, { strapi }) => { + return async (resolve, ...rest) => { + const resolverPolicies = getOr([], 'policies', resolverConfig); + + // Transform every policy into a unique format + const policies = resolverPolicies.map(policy => policyUtils.get(policy)); + + // Create a graphql policy context + const context = createGraphQLPolicyContext(...rest); + + // Run policies & throw an error if one of them fails + for (const policy of policies) { + const result = await policy({ context, strapi }); + + if (!result) { + throw new Error('Policies failed'); + } + } + + return resolve(...rest); + }; +}; + +const createGraphQLPolicyContext = (parent, args, context, info) => { + return policyUtils.createPolicyContext('graphql', { + get parent() { + return parent; + }, + + get args() { + return args; + }, + + get context() { + return context; + }, + + get info() { + return info; + }, + + get state() { + return this.context.state; + }, + + get http() { + return this.context.koaContext; + }, + }); +}; + +module.exports = { + createPoliciesMiddleware, +}; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/collection-type.js b/packages/plugins/graphql/server/services/content-api/register-functions/collection-type.js new file mode 100644 index 0000000000..bc23e4260b --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/collection-type.js @@ -0,0 +1,72 @@ +'use strict'; + +const registerCollectionType = (contentType, { registry, strapi, builders }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { naming } = getService('utils'); + const { KINDS } = getService('constants'); + + const extension = getService('extension'); + + // Types name (as string) + const types = { + base: naming.getTypeName(contentType), + entity: naming.getEntityName(contentType), + response: naming.getEntityResponseName(contentType), + responseCollection: naming.getEntityResponseCollectionName(contentType), + relationResponseCollection: naming.getRelationResponseCollectionName(contentType), + queries: naming.getEntityQueriesTypeName(contentType), + mutations: naming.getEntityMutationsTypeName(contentType), + }; + + const getConfig = kind => ({ kind, contentType }); + + // Type definition + registry.register(types.base, builders.buildTypeDefinition(contentType), getConfig(KINDS.type)); + + // Higher level entity definition + registry.register( + types.entity, + builders.buildEntityDefinition(contentType), + getConfig(KINDS.entity) + ); + + // Responses definition + registry.register( + types.response, + builders.buildResponseDefinition(contentType), + getConfig(KINDS.entityResponse) + ); + + registry.register( + types.responseCollection, + builders.buildResponseCollectionDefinition(contentType), + getConfig(KINDS.entityResponseCollection) + ); + + registry.register( + types.relationResponseCollection, + builders.buildRelationResponseCollectionDefinition(contentType), + getConfig(KINDS.relationResponseCollection) + ); + + if (extension.shadowCRUD(contentType.uid).areQueriesEnabled()) { + // Query extensions + registry.register( + types.queries, + builders.buildCollectionTypeQueries(contentType), + getConfig(KINDS.query) + ); + } + + if (extension.shadowCRUD(contentType.uid).areMutationsEnabled()) { + // Mutation extensions + registry.register( + types.mutations, + builders.buildCollectionTypeMutations(contentType), + getConfig(KINDS.mutation) + ); + } +}; + +module.exports = { registerCollectionType }; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/component.js b/packages/plugins/graphql/server/services/content-api/register-functions/component.js new file mode 100644 index 0000000000..cb279e937a --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/component.js @@ -0,0 +1,15 @@ +'use strict'; + +const registerComponent = (contentType, { registry, strapi, builders }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { getComponentName } = getService('utils').naming; + const { KINDS } = getService('constants'); + + const name = getComponentName(contentType); + const definition = builders.buildTypeDefinition(contentType); + + registry.register(name, definition, { kind: KINDS.component, contentType }); +}; + +module.exports = { registerComponent }; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/content-type/dynamic-zones.js b/packages/plugins/graphql/server/services/content-api/register-functions/content-type/dynamic-zones.js new file mode 100644 index 0000000000..8b6493a29c --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/content-type/dynamic-zones.js @@ -0,0 +1,36 @@ +'use strict'; + +const registerDynamicZonesDefinition = (contentType, { registry, strapi, builders }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { + naming, + attributes: { isDynamicZone }, + } = getService('utils'); + const { KINDS } = getService('constants'); + + const { attributes } = contentType; + + const dynamicZoneAttributes = Object.keys(attributes).filter(attributeName => + isDynamicZone(attributes[attributeName]) + ); + + for (const attributeName of dynamicZoneAttributes) { + const attribute = attributes[attributeName]; + const dzName = naming.getDynamicZoneName(contentType, attributeName); + const dzInputName = naming.getDynamicZoneInputName(contentType, attributeName); + + const [type, input] = builders.buildDynamicZoneDefinition(attribute, dzName, dzInputName); + + const baseConfig = { + contentType, + attributeName, + attribute, + }; + + registry.register(dzName, type, { kind: KINDS.dynamicZone, ...baseConfig }); + registry.register(dzInputName, input, { kind: KINDS.input, ...baseConfig }); + } +}; + +module.exports = { registerDynamicZonesDefinition }; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/content-type/enums.js b/packages/plugins/graphql/server/services/content-api/register-functions/content-type/enums.js new file mode 100644 index 0000000000..c198894280 --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/content-type/enums.js @@ -0,0 +1,33 @@ +'use strict'; + +const registerEnumsDefinition = (contentType, { registry, strapi, builders }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { + naming, + attributes: { isEnumeration }, + } = getService('utils'); + const { KINDS } = getService('constants'); + + const { attributes } = contentType; + + const enumAttributes = Object.keys(attributes).filter(attributeName => + isEnumeration(attributes[attributeName]) + ); + + for (const attributeName of enumAttributes) { + const attribute = attributes[attributeName]; + + const enumName = naming.getEnumName(contentType, attributeName); + const enumDefinition = builders.buildEnumTypeDefinition(attribute, enumName); + + registry.register(enumName, enumDefinition, { + kind: KINDS.enum, + contentType, + attributeName, + attribute, + }); + } +}; + +module.exports = { registerEnumsDefinition }; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/content-type/filters.js b/packages/plugins/graphql/server/services/content-api/register-functions/content-type/filters.js new file mode 100644 index 0000000000..39d555aefe --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/content-type/filters.js @@ -0,0 +1,15 @@ +'use strict'; + +const registerFiltersDefinition = (contentType, { registry, strapi, builders }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { getFiltersInputTypeName } = getService('utils').naming; + const { KINDS } = getService('constants'); + + const type = getFiltersInputTypeName(contentType); + const definition = builders.buildContentTypeFilters(contentType); + + registry.register(type, definition, { kind: KINDS.filtersInput, contentType }); +}; + +module.exports = { registerFiltersDefinition }; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/content-type/index.js b/packages/plugins/graphql/server/services/content-api/register-functions/content-type/index.js new file mode 100644 index 0000000000..2306922329 --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/content-type/index.js @@ -0,0 +1,13 @@ +'use strict'; + +const { registerDynamicZonesDefinition } = require('./dynamic-zones'); +const { registerEnumsDefinition } = require('./enums'); +const { registerInputsDefinition } = require('./inputs'); +const { registerFiltersDefinition } = require('./filters'); + +module.exports = { + registerDynamicZonesDefinition, + registerFiltersDefinition, + registerInputsDefinition, + registerEnumsDefinition, +}; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/content-type/inputs.js b/packages/plugins/graphql/server/services/content-api/register-functions/content-type/inputs.js new file mode 100644 index 0000000000..2f1e19349f --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/content-type/inputs.js @@ -0,0 +1,21 @@ +'use strict'; + +const registerInputsDefinition = (contentType, { registry, strapi, builders }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { getComponentInputName, getContentTypeInputName } = getService('utils').naming; + const { KINDS } = getService('constants'); + + const { modelType } = contentType; + + const type = (modelType === 'component' ? getComponentInputName : getContentTypeInputName).call( + null, + contentType + ); + + const definition = builders.buildInputType(contentType); + + registry.register(type, definition, { kind: KINDS.input, contentType }); +}; + +module.exports = { registerInputsDefinition }; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/index.js b/packages/plugins/graphql/server/services/content-api/register-functions/index.js new file mode 100644 index 0000000000..5e58a43020 --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/index.js @@ -0,0 +1,22 @@ +'use strict'; + +const { registerCollectionType } = require('./collection-type'); +const { registerSingleType } = require('./single-type'); +const { registerComponent } = require('./component'); +const { registerPolymorphicContentType } = require('./polymorphic'); + +const { registerScalars } = require('./scalars'); +const { registerInternals } = require('./internals'); + +const contentType = require('./content-type'); + +module.exports = { + registerCollectionType, + registerSingleType, + registerComponent, + registerPolymorphicContentType, + registerInternals, + registerScalars, + + contentType, +}; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/internals.js b/packages/plugins/graphql/server/services/content-api/register-functions/internals.js new file mode 100644 index 0000000000..8baf02dee7 --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/internals.js @@ -0,0 +1,13 @@ +'use strict'; + +const registerInternals = ({ registry, strapi }) => { + const { buildInternalTypes } = strapi.plugin('graphql').service('internals'); + + const internalTypes = buildInternalTypes({ strapi }); + + for (const [kind, definitions] of Object.entries(internalTypes)) { + registry.registerMany(Object.entries(definitions), { kind }); + } +}; + +module.exports = { registerInternals }; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/polymorphic.js b/packages/plugins/graphql/server/services/content-api/register-functions/polymorphic.js new file mode 100644 index 0000000000..c38a3cb254 --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/polymorphic.js @@ -0,0 +1,69 @@ +'use strict'; + +const { unionType } = require('nexus'); + +const registerPolymorphicContentType = (contentType, { registry, strapi }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { + naming, + attributes: { isMorphRelation }, + } = getService('utils'); + const { KINDS } = getService('constants'); + + const { attributes = {} } = contentType; + + // Isolate its polymorphic attributes + const morphAttributes = Object.entries(attributes).filter(([, attribute]) => + isMorphRelation(attribute) + ); + + // For each one of those polymorphic attribute + for (const [attributeName, attribute] of morphAttributes) { + const name = naming.getMorphRelationTypeName(contentType, attributeName); + const { target } = attribute; + + // Ignore those whose target is not an array + if (!Array.isArray(target)) { + continue; + } + + // Transform target UIDs into types names + const members = target + // Get content types definitions + .map(uid => strapi.getModel(uid)) + // Resolve types names + .map(contentType => naming.getTypeName(contentType)); + + // Register the new polymorphic union type + registry.register( + name, + + unionType({ + name, + + resolveType(obj) { + const contentType = strapi.getModel(obj.__type); + + if (!contentType) { + return null; + } + + if (contentType.modelType === 'component') { + return naming.getComponentName(contentType); + } + + return naming.getTypeName(contentType); + }, + + definition(t) { + t.members(...members); + }, + }), + + { kind: KINDS.morph, contentType, attributeName } + ); + } +}; + +module.exports = { registerPolymorphicContentType }; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/scalars.js b/packages/plugins/graphql/server/services/content-api/register-functions/scalars.js new file mode 100644 index 0000000000..6ac147b426 --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/scalars.js @@ -0,0 +1,14 @@ +'use strict'; + +const registerScalars = ({ registry, strapi }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { scalars } = getService('internals'); + const { KINDS } = getService('constants'); + + Object.entries(scalars).forEach(([name, definition]) => { + registry.register(name, definition, { kind: KINDS.scalar }); + }); +}; + +module.exports = { registerScalars }; diff --git a/packages/plugins/graphql/server/services/content-api/register-functions/single-type.js b/packages/plugins/graphql/server/services/content-api/register-functions/single-type.js new file mode 100644 index 0000000000..545b853bb1 --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/register-functions/single-type.js @@ -0,0 +1,72 @@ +'use strict'; + +const registerSingleType = (contentType, { registry, strapi, builders }) => { + const { service: getService } = strapi.plugin('graphql'); + + const { naming } = getService('utils'); + const { KINDS } = getService('constants'); + + const extension = getService('extension'); + + const types = { + base: naming.getTypeName(contentType), + entity: naming.getEntityName(contentType), + response: naming.getEntityResponseName(contentType), + responseCollection: naming.getEntityResponseCollectionName(contentType), + relationResponseCollection: naming.getRelationResponseCollectionName(contentType), + queries: naming.getEntityQueriesTypeName(contentType), + mutations: naming.getEntityMutationsTypeName(contentType), + }; + + const getConfig = kind => ({ kind, contentType }); + + // Single type's definition + registry.register(types.base, builders.buildTypeDefinition(contentType), getConfig(KINDS.type)); + + // Higher level entity definition + registry.register( + types.entity, + builders.buildEntityDefinition(contentType), + getConfig(KINDS.entity) + ); + + // Responses definition + registry.register( + types.response, + builders.buildResponseDefinition(contentType), + getConfig(KINDS.entityResponse) + ); + + // Response collection definition + registry.register( + types.responseCollection, + builders.buildResponseCollectionDefinition(contentType), + getConfig(KINDS.entityResponseCollection) + ); + + registry.register( + types.relationResponseCollection, + builders.buildRelationResponseCollectionDefinition(contentType), + getConfig(KINDS.relationResponseCollection) + ); + + if (extension.shadowCRUD(contentType.uid).areQueriesEnabled()) { + // Queries + registry.register( + types.queries, + builders.buildSingleTypeQueries(contentType), + getConfig(KINDS.query) + ); + } + + if (extension.shadowCRUD(contentType.uid).areMutationsEnabled()) { + // Mutations + registry.register( + types.mutations, + builders.buildSingleTypeMutations(contentType), + getConfig(KINDS.mutation) + ); + } +}; + +module.exports = { registerSingleType }; diff --git a/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js b/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js new file mode 100644 index 0000000000..e1052f6638 --- /dev/null +++ b/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js @@ -0,0 +1,146 @@ +'use strict'; + +const { get, getOr, isFunction, first, isNil } = require('lodash/fp'); + +const { GraphQLObjectType } = require('graphql'); +const { createPoliciesMiddleware } = require('./policy'); + +const introspectionQueries = [ + '__Schema', + '__Type', + '__Field', + '__InputValue', + '__EnumValue', + '__Directive', +]; + +/** + * Wrap the schema's resolvers if they've been + * 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, strapi, extension = {} }) => { + // Get all the registered resolvers configuration + const { resolversConfig = {} } = extension; + + // Fields filters + const isValidFieldName = ([field]) => !field.startsWith('__'); + + const typeMap = schema.getTypeMap(); + + // Iterate over every field from every type within the + // schema's type map and wrap its resolve attribute if needed + Object.entries(typeMap).forEach(([type, definition]) => { + const isGraphQLObjectType = definition instanceof GraphQLObjectType; + const isIgnoredType = introspectionQueries.includes(type); + + if (!isGraphQLObjectType || isIgnoredType) { + return; + } + + const fields = definition.getFields(); + const fieldsToProcess = Object.entries(fields).filter(isValidFieldName); + + for (const [fieldName, fieldDefinition] of fieldsToProcess) { + const defaultResolver = get(fieldName); + + const path = `${type}.${fieldName}`; + const resolverConfig = getOr({}, path, resolversConfig); + + const { resolve: baseResolver = defaultResolver } = fieldDefinition; + + // Parse & initialize the middlewares + const middlewares = parseMiddlewares(resolverConfig, strapi); + + // Generate the policy middleware + const policyMiddleware = createPoliciesMiddleware(resolverConfig, { strapi }); + + // Add the policyMiddleware at the end of the middlewares collection + middlewares.push(policyMiddleware); + + // Bind every middleware to the next one + const boundMiddlewares = middlewares.map((middleware, index, collection) => { + return (...args) => + middleware( + // Make sure the last middleware in the list calls the baseResolver + index >= collection.length - 1 ? baseResolver : boundMiddlewares[index + 1], + ...args + ); + }); + + /** + * GraphQL authorization flow + * @param {object} context + * @return {Promise} + */ + const authorize = async ({ context }) => { + const authConfig = get('auth', resolverConfig); + const authContext = get('state.auth', context); + + const isMutationOrQuery = ['Mutation', 'Query'].includes(type); + const hasConfig = !isNil(authConfig); + + const isAuthDisabled = authConfig === false; + + if ((isMutationOrQuery || hasConfig) && !isAuthDisabled) { + try { + await strapi.auth.verify(authContext, authConfig); + } catch (error) { + // TODO: [v4] Throw GraphQL Error instead + throw new Error('Forbidden access'); + } + } + }; + + /** + * Base resolver wrapper that handles authorization, middlewares & policies + * @param {object} parent + * @param {object} args + * @param {object} context + * @param {object} info + * @return {Promise} + */ + fieldDefinition.resolve = async (parent, args, context, info) => { + await authorize({ context }); + + // Execute middlewares (including the policy middleware which will always be included) + return first(boundMiddlewares).call(null, parent, args, context, info); + }; + } + }); + + return schema; +}; + +/** + * Get & parse middlewares definitions from the resolver's config + * @param {object} resolverConfig + * @param {object} strapi + * @return {function[]} + */ +const parseMiddlewares = (resolverConfig, strapi) => { + const resolverMiddlewares = getOr([], 'middlewares', resolverConfig); + + // TODO: [v4] to factorize with compose endpoints (routes) + return resolverMiddlewares.map(middleware => { + if (isFunction(middleware)) { + return middleware; + } + + if (typeof middleware === 'string') { + return strapi.middleware(middleware); + } + + if (typeof middleware === 'object') { + const { name, options = {} } = middleware; + + return strapi.middleware(name)(options); + } + }); +}; + +module.exports = { wrapResolvers }; diff --git a/packages/plugins/graphql/server/services/extension/extension.js b/packages/plugins/graphql/server/services/extension/extension.js new file mode 100644 index 0000000000..0dcec14289 --- /dev/null +++ b/packages/plugins/graphql/server/services/extension/extension.js @@ -0,0 +1,95 @@ +'use strict'; + +const nexus = require('nexus'); +const { merge } = require('lodash/fp'); + +const createShadowCRUDManager = require('./shadow-crud-manager'); + +/** + * @typedef StrapiGraphQLExtensionConfiguration + * @property {NexusGen[]} types - A collection of Nexus types + * @property {string} typeDefs - Type definitions (SDL format) + * @property {object} resolvers - A resolver map + * @property {object} resolversConfig - An object that bind a configuration to a resolver based on an absolute path (the key) + * @property {NexusPlugin[]} plugins - A collection of Nexus plugins + */ + +/** + * @typedef {function({ strapi: object, nexus: object, typeRegistry: object }): StrapiGraphQLExtensionConfiguration} StrapiGraphQLExtensionConfigurationFactory + */ + +const getDefaultState = () => ({ + types: [], + typeDefs: [], + resolvers: {}, + resolversConfig: {}, + plugins: [], +}); + +const createExtension = ({ strapi } = {}) => { + const configs = []; + + return { + shadowCRUD: createShadowCRUDManager({ strapi }), + + /** + * Register a new extension configuration + * @param {StrapiGraphQLExtensionConfiguration | StrapiGraphQLExtensionConfigurationFactory} configuration + * @return {this} + */ + use(configuration) { + configs.push(configuration); + + return this; + }, + + /** + * Convert the registered configuration into a single extension object & return it + * @param {object} options + * @param {object} options.typeRegistry + * @return {object} + */ + generate({ typeRegistry }) { + const resolveConfig = config => { + return typeof config === 'function' ? config({ strapi, nexus, typeRegistry }) : config; + }; + + // Evaluate & merge every registered configuration object, then return the result + return configs.reduce((acc, configuration) => { + const { types, typeDefs, resolvers, resolversConfig, plugins } = resolveConfig( + configuration + ); + + // Register type definitions + if (typeof typeDefs === 'string') { + acc.typeDefs.push(typeDefs); + } + + // Register nexus types + if (Array.isArray(types)) { + acc.types.push(...types); + } + + // Register nexus plugins + if (Array.isArray(plugins)) { + acc.plugins.push(...plugins); + } + + // Register resolvers + if (typeof resolvers === 'object') { + acc.resolvers = merge(acc.resolvers, resolvers); + } + + // Register resolvers configuration + if (typeof resolversConfig === 'object') { + // TODO: smarter merge for auth, middlewares & policies + acc.resolversConfig = merge(resolversConfig, acc.resolversConfig); + } + + return acc; + }, getDefaultState()); + }, + }; +}; + +module.exports = createExtension; diff --git a/packages/plugins/graphql/server/services/extension/index.js b/packages/plugins/graphql/server/services/extension/index.js new file mode 100644 index 0000000000..bda97042a2 --- /dev/null +++ b/packages/plugins/graphql/server/services/extension/index.js @@ -0,0 +1,5 @@ +'use strict'; + +const createExtension = require('./extension'); + +module.exports = createExtension; diff --git a/packages/plugins/graphql/server/services/extension/shadow-crud-manager.js b/packages/plugins/graphql/server/services/extension/shadow-crud-manager.js new file mode 100644 index 0000000000..9f3118680c --- /dev/null +++ b/packages/plugins/graphql/server/services/extension/shadow-crud-manager.js @@ -0,0 +1,159 @@ +'use strict'; + +const getDefaultContentTypeConfig = () => ({ + enabled: true, + + mutations: true, + queries: true, + + disabledActions: [], + fields: new Map(), +}); + +const getDefaultFieldConfig = () => ({ + enabled: true, + + input: true, + output: true, + + filters: true, +}); + +const ALL_ACTIONS = '*'; + +module.exports = () => { + const configs = new Map(); + + return uid => { + if (!configs.has(uid)) { + configs.set(uid, getDefaultContentTypeConfig()); + } + + return { + isEnabled() { + return configs.get(uid).enabled; + }, + + isDisabled() { + return !this.isEnabled(); + }, + + areQueriesEnabled() { + return configs.get(uid).queries; + }, + + areQueriesDisabled() { + return !this.areQueriesEnabled(); + }, + + areMutationsEnabled() { + return configs.get(uid).mutations; + }, + + areMutationsDisabled() { + return !this.areMutationsEnabled(); + }, + + isActionEnabled(action) { + const matchingActions = [action, ALL_ACTIONS]; + + return configs.get(uid).disabledActions.every(action => !matchingActions.includes(action)); + }, + + isActionDisabled(action) { + return !this.isActionEnabled(action); + }, + + disable() { + configs.get(uid).enabled = false; + + return this; + }, + + disableQueries() { + configs.get(uid).queries = false; + + return this; + }, + + disableMutations() { + configs.get(uid).mutations = false; + + return this; + }, + + disableAction(action) { + const config = configs.get(uid); + + if (!config.disabledActions.includes(action)) { + config.disabledActions.push(action); + } + + return this; + }, + + disableActions(actions = []) { + actions.forEach(action => this.disableAction(action)); + + return this; + }, + + field(fieldName) { + const { fields } = configs.get(uid); + + if (!fields.has(fieldName)) { + fields.set(fieldName, getDefaultFieldConfig()); + } + + return { + isEnabled() { + return fields.get(fieldName).enabled; + }, + + hasInputEnabled() { + return fields.get(fieldName).input; + }, + + hasOutputEnabled() { + return fields.get(fieldName).output; + }, + + hasFiltersEnabeld() { + return fields.get(fieldName).filters; + }, + + disable() { + fields.set(fieldName, { + enabled: false, + + output: false, + input: false, + + filters: false, + }); + + return this; + }, + + disableOutput() { + fields.get(fieldName).output = false; + + return this; + }, + + disableInput() { + fields.get(fieldName).input = false; + + return this; + }, + + disableFilters() { + fields.get(fieldName).filters = false; + + return this; + }, + }; + }, + }; + }; +}; diff --git a/packages/plugins/graphql/server/services/format/index.js b/packages/plugins/graphql/server/services/format/index.js new file mode 100644 index 0000000000..31395c7e3e --- /dev/null +++ b/packages/plugins/graphql/server/services/format/index.js @@ -0,0 +1,7 @@ +'use strict'; + +const returnTypes = require('./return-types'); + +module.exports = context => ({ + returnTypes: returnTypes(context), +}); diff --git a/packages/plugins/graphql/server/services/format/return-types.js b/packages/plugins/graphql/server/services/format/return-types.js new file mode 100644 index 0000000000..6afed999ce --- /dev/null +++ b/packages/plugins/graphql/server/services/format/return-types.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = () => ({ + /** + * @param {object} value + * @param {object} info + * @param {object} info.args + * @param {string} info.resourceUID + */ + toEntityResponse(value, info = {}) { + const { args = {}, resourceUID } = info; + + return { value, info: { args, resourceUID } }; + }, + + /** + * @param {object[]} nodes + * @param {object} info + * @param {object} info.args + * @param {string} info.resourceUID + */ + toEntityResponseCollection(nodes, info = {}) { + const { args = {}, resourceUID } = info; + + return { nodes, info: { args, resourceUID } }; + }, +}); diff --git a/packages/plugins/graphql/server/services/index.js b/packages/plugins/graphql/server/services/index.js new file mode 100644 index 0000000000..f64ea95c9b --- /dev/null +++ b/packages/plugins/graphql/server/services/index.js @@ -0,0 +1,21 @@ +'use strict'; + +const contentAPI = require('./content-api'); +const typeRegistry = require('./type-registry'); +const utils = require('./utils'); +const constants = require('./constants'); +const internals = require('./internals'); +const builders = require('./builders'); +const extension = require('./extension'); +const format = require('./format'); + +module.exports = { + builders, + 'content-api': contentAPI, + constants, + extension, + format, + internals, + 'type-registry': typeRegistry, + utils, +}; diff --git a/packages/plugins/graphql/server/services/internals/args/index.js b/packages/plugins/graphql/server/services/internals/args/index.js new file mode 100644 index 0000000000..62b44bee71 --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/args/index.js @@ -0,0 +1,11 @@ +'use strict'; + +const SortArg = require('./sort'); +const publicationState = require('./publication-state'); +const PaginationArg = require('./pagination'); + +module.exports = context => ({ + SortArg, + PaginationArg, + PublicationStateArg: publicationState(context), +}); diff --git a/packages/plugins/graphql/server/services/internals/args/pagination.js b/packages/plugins/graphql/server/services/internals/args/pagination.js new file mode 100644 index 0000000000..68293c522c --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/args/pagination.js @@ -0,0 +1,19 @@ +'use strict'; + +const { arg, inputObjectType } = require('nexus'); + +const PaginationInputType = inputObjectType({ + name: 'PaginationArg', + + definition(t) { + t.int('page'); + t.int('pageSize'); + t.int('start'); + t.int('limit'); + }, +}); + +module.exports = arg({ + type: PaginationInputType, + default: {}, +}); diff --git a/packages/plugins/graphql/server/services/internals/args/publication-state.js b/packages/plugins/graphql/server/services/internals/args/publication-state.js new file mode 100644 index 0000000000..5c7257b715 --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/args/publication-state.js @@ -0,0 +1,12 @@ +'use strict'; + +const { arg } = require('nexus'); + +module.exports = ({ strapi }) => { + const { PUBLICATION_STATE_TYPE_NAME } = strapi.plugin('graphql').service('constants'); + + return arg({ + type: PUBLICATION_STATE_TYPE_NAME, + default: 'live', + }); +}; diff --git a/packages/plugins/graphql/server/services/internals/args/sort.js b/packages/plugins/graphql/server/services/internals/args/sort.js new file mode 100644 index 0000000000..ca06a15bff --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/args/sort.js @@ -0,0 +1,10 @@ +'use strict'; + +const { arg, list } = require('nexus'); + +const SortArg = arg({ + type: list('String'), + default: [], +}); + +module.exports = SortArg; diff --git a/packages/plugins/graphql/server/services/internals/helpers/get-enabled-scalars.js b/packages/plugins/graphql/server/services/internals/helpers/get-enabled-scalars.js new file mode 100644 index 0000000000..31782e8af0 --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/helpers/get-enabled-scalars.js @@ -0,0 +1,15 @@ +'use strict'; + +const { first } = require('lodash/fp'); + +module.exports = ({ strapi }) => () => { + const { GRAPHQL_SCALAR_OPERATORS } = strapi.plugin('graphql').service('constants'); + + return ( + Object.entries(GRAPHQL_SCALAR_OPERATORS) + // To be valid, a GraphQL scalar must have at least one operator enabled + .filter(([, value]) => value.length > 0) + // Only keep the key (the scalar name) + .map(first) + ); +}; diff --git a/packages/plugins/graphql/server/services/internals/helpers/index.js b/packages/plugins/graphql/server/services/internals/helpers/index.js new file mode 100644 index 0000000000..61b815d69f --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/helpers/index.js @@ -0,0 +1,7 @@ +'use strict'; + +const getEnabledScalars = require('./get-enabled-scalars'); + +module.exports = context => ({ + getEnabledScalars: getEnabledScalars(context), +}); diff --git a/packages/plugins/graphql/server/services/internals/index.js b/packages/plugins/graphql/server/services/internals/index.js new file mode 100644 index 0000000000..697ac23e08 --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/index.js @@ -0,0 +1,13 @@ +'use strict'; + +const args = require('./args'); +const scalars = require('./scalars'); +const types = require('./types'); +const helpers = require('./helpers'); + +module.exports = context => ({ + args: args(context), + scalars: scalars(context), + buildInternalTypes: types(context), + helpers: helpers(context), +}); diff --git a/packages/plugins/graphql/server/services/internals/scalars/index.js b/packages/plugins/graphql/server/services/internals/scalars/index.js new file mode 100644 index 0000000000..5502c2b0d1 --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/scalars/index.js @@ -0,0 +1,18 @@ +'use strict'; + +const GraphQLJSON = require('graphql-type-json'); +const GraphQLLong = require('graphql-type-long'); +const { GraphQLDateTime, GraphQLDate } = require('graphql-iso-date/dist'); +const { GraphQLUpload } = require('graphql-upload'); +const { asNexusMethod } = require('nexus'); + +const TimeScalar = require('./time'); + +module.exports = () => ({ + JSON: asNexusMethod(GraphQLJSON, 'json'), + DateTime: asNexusMethod(GraphQLDateTime, 'dateTime'), + Time: asNexusMethod(TimeScalar, 'time'), + Date: asNexusMethod(GraphQLDate, 'date'), + Long: asNexusMethod(GraphQLLong, 'long'), + Upload: asNexusMethod(GraphQLUpload, 'upload'), +}); diff --git a/packages/plugins/graphql/server/services/internals/scalars/time.js b/packages/plugins/graphql/server/services/internals/scalars/time.js new file mode 100644 index 0000000000..0b5a3ca0e3 --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/scalars/time.js @@ -0,0 +1,35 @@ +'use strict'; + +const { GraphQLScalarType } = require('graphql'); +const { Kind } = require('graphql'); +const { parseType } = require('@strapi/utils'); + +/** + * A GraphQL scalar used to store Time (HH:mm:ss.SSS) values + * @type {GraphQLScalarType} + */ +const TimeScalar = new GraphQLScalarType({ + name: 'Time', + + description: 'A time string with format HH:mm:ss.SSS', + + serialize(value) { + return parseType({ type: 'time', value }); + }, + + parseValue(value) { + return parseType({ type: 'time', value }); + }, + + parseLiteral(ast) { + if (ast.kind !== Kind.STRING) { + throw new TypeError('Time cannot represent non string type'); + } + + const value = ast.value; + + return parseType({ type: 'time', value }); + }, +}); + +module.exports = TimeScalar; diff --git a/packages/plugins/graphql/server/services/internals/types/error.js b/packages/plugins/graphql/server/services/internals/types/error.js new file mode 100644 index 0000000000..29cb41e2ed --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/types/error.js @@ -0,0 +1,33 @@ +'use strict'; + +const { objectType } = require('nexus'); +const { get } = require('lodash/fp'); + +/** + * Build an Error object type + * @return {Object} + */ +module.exports = ({ strapi }) => { + const { ERROR_CODES, ERROR_TYPE_NAME } = strapi.plugin('graphql').service('constants'); + + return objectType({ + name: ERROR_TYPE_NAME, + + definition(t) { + t.nonNull.string('code', { + resolve(parent) { + const code = get('code', parent); + + const isValidPlaceholderCode = Object.values(ERROR_CODES).includes(code); + if (!isValidPlaceholderCode) { + throw new TypeError(`"${code}" is not a valid code value`); + } + + return code; + }, + }); + + t.string('message'); + }, + }); +}; diff --git a/packages/plugins/graphql/server/services/internals/types/filters.js b/packages/plugins/graphql/server/services/internals/types/filters.js new file mode 100644 index 0000000000..efadc7b315 --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/types/filters.js @@ -0,0 +1,39 @@ +'use strict'; + +const { inputObjectType } = require('nexus'); + +/** + * Build a map of filters type for every GraphQL scalars + * @return {Object} + */ +const buildScalarFilters = ({ strapi }) => { + const { naming, mappers } = strapi.plugin('graphql').service('utils'); + const { helpers } = strapi.plugin('graphql').service('internals'); + + return helpers.getEnabledScalars().reduce((acc, type) => { + const operators = mappers.graphqlScalarToOperators(type); + const typeName = naming.getScalarFilterInputTypeName(type); + + if (!operators || operators.length === 0) { + return acc; + } + + return { + ...acc, + + [typeName]: inputObjectType({ + name: typeName, + + definition(t) { + for (const operator of operators) { + operator.add(t, type); + } + }, + }), + }; + }, {}); +}; + +module.exports = context => ({ + scalars: buildScalarFilters(context), +}); diff --git a/packages/plugins/graphql/server/services/internals/types/index.js b/packages/plugins/graphql/server/services/internals/types/index.js new file mode 100644 index 0000000000..f4253898e7 --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/types/index.js @@ -0,0 +1,29 @@ +'use strict'; + +const pagination = require('./pagination'); +const buildResponseCollectionMeta = require('./response-collection-meta'); +const publicationState = require('./publication-state'); +const filters = require('./filters'); +const error = require('./error'); + +module.exports = context => () => { + const { strapi } = context; + + const { KINDS } = strapi.plugin('graphql').service('constants'); + + return { + [KINDS.internal]: { + error: error(context), + pagination: pagination(context), + responseCollectionMeta: buildResponseCollectionMeta(context), + }, + + [KINDS.enum]: { + publicationState: publicationState(context), + }, + + [KINDS.filtersInput]: { + ...filters(context), + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/internals/types/pagination.js b/packages/plugins/graphql/server/services/internals/types/pagination.js new file mode 100644 index 0000000000..410c16f203 --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/types/pagination.js @@ -0,0 +1,24 @@ +'use strict'; + +const { objectType } = require('nexus'); + +module.exports = ({ strapi }) => { + const { PAGINATION_TYPE_NAME } = strapi.plugin('graphql').service('constants'); + + return { + /** + * Type definition for a Pagination object + * @type {NexusObjectTypeDef} + */ + Pagination: objectType({ + name: PAGINATION_TYPE_NAME, + + definition(t) { + t.nonNull.int('total'); + t.nonNull.int('page'); + t.nonNull.int('pageSize'); + t.nonNull.int('pageCount'); + }, + }), + }; +}; diff --git a/packages/plugins/graphql/server/services/internals/types/publication-state.js b/packages/plugins/graphql/server/services/internals/types/publication-state.js new file mode 100644 index 0000000000..de767b63a1 --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/types/publication-state.js @@ -0,0 +1,24 @@ +'use strict'; + +const { enumType } = require('nexus'); + +module.exports = ({ strapi }) => { + const { PUBLICATION_STATE_TYPE_NAME } = strapi.plugin('graphql').service('constants'); + + return { + /** + * An enum type definition representing a publication state + * @type {NexusEnumTypeDef} + */ + PublicationState: enumType({ + name: PUBLICATION_STATE_TYPE_NAME, + + members: { + // Published only + LIVE: 'live', + // Published & draft + PREVIEW: 'preview', + }, + }), + }; +}; diff --git a/packages/plugins/graphql/server/services/internals/types/response-collection-meta.js b/packages/plugins/graphql/server/services/internals/types/response-collection-meta.js new file mode 100644 index 0000000000..828008aa2e --- /dev/null +++ b/packages/plugins/graphql/server/services/internals/types/response-collection-meta.js @@ -0,0 +1,38 @@ +'use strict'; + +const { objectType } = require('nexus'); + +module.exports = ({ strapi }) => { + const { RESPONSE_COLLECTION_META_TYPE_NAME, PAGINATION_TYPE_NAME } = strapi + .plugin('graphql') + .service('constants'); + + return { + /** + * A shared type definition used in EntitiesResponseCollection + * to have information about the collection as a whole + * @type {NexusObjectTypeDef} + */ + ResponseCollectionMeta: objectType({ + name: RESPONSE_COLLECTION_META_TYPE_NAME, + + definition(t) { + t.nonNull.field('pagination', { + type: PAGINATION_TYPE_NAME, + + async resolve(parent) { + const { args, resourceUID } = parent; + const { start, limit } = args; + + const total = await strapi.entityService.count(resourceUID, args); + const pageSize = limit; + const pageCount = limit === 0 ? 0 : Math.ceil(total / limit); + const page = limit === 0 ? 1 : Math.floor(start / limit) + 1; + + return { total, page, pageSize, pageCount }; + }, + }); + }, + }), + }; +}; diff --git a/packages/plugins/graphql/server/services/type-registry.js b/packages/plugins/graphql/server/services/type-registry.js new file mode 100644 index 0000000000..f4acf81278 --- /dev/null +++ b/packages/plugins/graphql/server/services/type-registry.js @@ -0,0 +1,103 @@ +'use strict'; + +const { isFunction } = require('lodash/fp'); + +/** + * @typedef RegisteredTypeDef + * + * @property {string} name + * @property {NexusAcceptedTypeDef} definition + * @property {object} config + */ + +/** + * Create a new type registry + */ +const createTypeRegistry = () => { + const registry = new Map(); + + return { + /** + * Register a new type definition + * @param {string} name The name of the type + * @param {NexusAcceptedTypeDef} definition The Nexus definition for the type + * @param {object} [config] An optional config object with any metadata inside + */ + register(name, definition, config = {}) { + if (registry.has(name)) { + throw new Error(`"${name}" has already been registered`); + } + + registry.set(name, { name, definition, config }); + + return this; + }, + + /** + * Register many types definitions at once + * @param {[string, NexusAcceptedTypeDef][]} definitionsEntries + * @param {object | function} [config] + */ + registerMany(definitionsEntries, config = {}) { + for (const [name, definition] of definitionsEntries) { + this.register(name, definition, isFunction(config) ? config(name, definition) : config); + } + + return this; + }, + + /** + * Check if the given type name has already been added to the registry + * @param {string} name + * @return {boolean} + */ + has(name) { + return registry.has(name); + }, + + /** + * Get the type definition for `name` + * @param {string} name - The name of the type + */ + get(name) { + return registry.get(name); + }, + + /** + * Transform and return the registry as an object + * @return {Object} + */ + toObject() { + return Object.fromEntries(registry.entries()); + }, + + /** + * Return the name of every registered type + * @return {string[]} + */ + get types() { + return Array.from(registry.keys()); + }, + + /** + * Return all the registered definitions as an array + * @return {RegisteredTypeDef[]} + */ + get definitions() { + return Array.from(registry.values()); + }, + + /** + * Filter and return the types definitions that matches the given predicate + * @param {function(RegisteredTypeDef): boolean} predicate + * @return {RegisteredTypeDef[]} + */ + where(predicate) { + return this.definitions.filter(predicate); + }, + }; +}; + +module.exports = () => ({ + new: createTypeRegistry, +}); diff --git a/packages/plugins/graphql/server/services/utils/attributes.js b/packages/plugins/graphql/server/services/utils/attributes.js new file mode 100644 index 0000000000..efef6daee8 --- /dev/null +++ b/packages/plugins/graphql/server/services/utils/attributes.js @@ -0,0 +1,84 @@ +'use strict'; + +const { propEq } = require('lodash/fp'); + +module.exports = ({ strapi }) => { + /** + * Check if the given attribute is a Strapi scalar + * @param {object} attribute + * @return {boolean} + */ + const isStrapiScalar = attribute => { + return strapi + .plugin('graphql') + .service('constants') + .STRAPI_SCALARS.includes(attribute.type); + }; + + /** + * Check if the given attribute is a GraphQL scalar + * @param {object} attribute + * @return {boolean} + */ + const isGraphQLScalar = attribute => { + return strapi + .plugin('graphql') + .service('constants') + .GRAPHQL_SCALARS.includes(attribute.type); + }; + + /** + * Check if the given attribute is a polymorphic relation + * @param {object} attribute + * @return {boolean} + */ + const isMorphRelation = attribute => { + return isRelation(attribute) && attribute.relation.includes('morph'); + }; + + /** + * Check if the given attribute is a media + * @param {object} attribute + * @return {boolean} + */ + const isMedia = propEq('type', 'media'); + + /** + * Check if the given attribute is a relation + * @param {object} attribute + * @return {boolean} + */ + const isRelation = propEq('type', 'relation'); + + /** + * Check if the given attribute is an enum + * @param {object} attribute + * @return {boolean} + */ + const isEnumeration = propEq('type', 'enumeration'); + + /** + * Check if the given attribute is a component + * @param {object} attribute + * @return {boolean} + */ + const isComponent = propEq('type', 'component'); + + /** + * Check if the given attribute is a dynamic zone + * @param {object} attribute + * @return {boolean} + */ + const isDynamicZone = propEq('type', 'dynamiczone'); + + return { + isStrapiScalar, + isGraphQLScalar, + isMorphRelation, + isMedia, + isRelation, + isEnumeration, + isComponent, + isDynamicZone, + }; +}; diff --git a/packages/plugins/graphql/server/services/utils/index.js b/packages/plugins/graphql/server/services/utils/index.js new file mode 100644 index 0000000000..db4bbeda9b --- /dev/null +++ b/packages/plugins/graphql/server/services/utils/index.js @@ -0,0 +1,11 @@ +'use strict'; + +const mappers = require('./mappers'); +const attributes = require('./attributes'); +const naming = require('./naming'); + +module.exports = context => ({ + naming: naming(context), + attributes: attributes(context), + mappers: mappers(context), +}); diff --git a/packages/plugins/graphql/server/services/utils/mappers/entity-to-response-entity.js b/packages/plugins/graphql/server/services/utils/mappers/entity-to-response-entity.js new file mode 100644 index 0000000000..58bc1d7d42 --- /dev/null +++ b/packages/plugins/graphql/server/services/utils/mappers/entity-to-response-entity.js @@ -0,0 +1,12 @@ +'use strict'; + +const { map } = require('lodash/fp'); + +const entityToResponseEntity = entity => ({ id: entity.id, attributes: entity }); + +const entitiesToResponseEntities = map(entityToResponseEntity); + +module.exports = () => ({ + entityToResponseEntity, + entitiesToResponseEntities, +}); diff --git a/packages/plugins/graphql/server/services/utils/mappers/graphql-filters-to-strapi-query.js b/packages/plugins/graphql/server/services/utils/mappers/graphql-filters-to-strapi-query.js new file mode 100644 index 0000000000..d3aa379b3b --- /dev/null +++ b/packages/plugins/graphql/server/services/utils/mappers/graphql-filters-to-strapi-query.js @@ -0,0 +1,107 @@ +'use strict'; + +const { has, propEq, isNil } = require('lodash/fp'); + +// todo[v4]: Find a way to get that dynamically +const virtualScalarAttributes = ['id']; + +module.exports = ({ strapi }) => { + const { service: getService } = strapi.plugin('graphql'); + + const recursivelyReplaceScalarOperators = data => { + const { operators } = getService('builders').filters; + + if (Array.isArray(data)) { + return data.map(recursivelyReplaceScalarOperators); + } + + if (typeof data !== 'object') { + return data; + } + + const result = {}; + + for (const [key, value] of Object.entries(data)) { + const isOperator = !!operators[key]; + + const newKey = isOperator ? operators[key].strapiOperator : key; + + result[newKey] = recursivelyReplaceScalarOperators(value); + } + + return result; + }; + + return { + /** + * Transform one or many GraphQL filters object into a valid Strapi query + * @param {object | object[]} filters + * @param {object} contentType + * @return {object | object[]} + */ + graphQLFiltersToStrapiQuery(filters, contentType = {}) { + const { isStrapiScalar, isMedia, isRelation } = getService('utils').attributes; + const { operators } = getService('builders').filters; + + const ROOT_LEVEL_OPERATORS = [operators.and, operators.or, operators.not]; + + // Handle unwanted scenario where there is no filters defined + if (isNil(filters)) { + return {}; + } + + // If filters is a collection, then apply the transformation to every item of the list + if (Array.isArray(filters)) { + return filters.map(filtersItem => + this.graphQLFiltersToStrapiQuery(filtersItem, contentType) + ); + } + + const resultMap = {}; + const { attributes } = contentType; + + const isAttribute = attributeName => { + return virtualScalarAttributes.includes(attributeName) || has(attributeName, attributes); + }; + + for (const [key, value] of Object.entries(filters)) { + // If the key is an attribute, update the value + if (isAttribute(key)) { + const attribute = attributes[key]; + + // If it's a scalar attribute + if (virtualScalarAttributes.includes(key) || isStrapiScalar(attribute)) { + // Replace (recursively) every GraphQL scalar operator with the associated Strapi operator + resultMap[key] = recursivelyReplaceScalarOperators(value); + } + + // If it's a deep filter on a relation + else if (isRelation(attribute) || isMedia(attribute)) { + // Fetch the model from the relation + const relModel = strapi.getModel(attribute.target); + + // Recursively apply the mapping to the value using the fetched model, + // and update the value within `resultMap` + resultMap[key] = this.graphQLFiltersToStrapiQuery(value, relModel); + } + } + + // Handle the case where the key is not an attribute (operator, ...) + else { + const rootLevelOperator = ROOT_LEVEL_OPERATORS.find(propEq('fieldName', key)); + + // If it's a root level operator (AND, NOT, OR, ...) + if (rootLevelOperator) { + const { strapiOperator } = rootLevelOperator; + + // Transform the current value recursively and add it to the resultMap + // object using the strapiOperator equivalent of the GraphQL key + resultMap[strapiOperator] = this.graphQLFiltersToStrapiQuery(value, contentType); + } + } + } + + return resultMap; + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/utils/mappers/graphql-scalar-to-operators.js b/packages/plugins/graphql/server/services/utils/mappers/graphql-scalar-to-operators.js new file mode 100644 index 0000000000..68a01b67a7 --- /dev/null +++ b/packages/plugins/graphql/server/services/utils/mappers/graphql-scalar-to-operators.js @@ -0,0 +1,17 @@ +'use strict'; + +const { get, map, mapValues } = require('lodash/fp'); + +module.exports = ({ strapi }) => ({ + graphqlScalarToOperators(graphqlScalar) { + const { GRAPHQL_SCALAR_OPERATORS } = strapi.plugin('graphql').service('constants'); + const { operators } = strapi.plugin('graphql').service('builders').filters; + + const associations = mapValues( + map(operatorName => operators[operatorName]), + GRAPHQL_SCALAR_OPERATORS + ); + + return get(graphqlScalar, associations); + }, +}); diff --git a/packages/plugins/graphql/server/services/utils/mappers/index.js b/packages/plugins/graphql/server/services/utils/mappers/index.js new file mode 100644 index 0000000000..9013277876 --- /dev/null +++ b/packages/plugins/graphql/server/services/utils/mappers/index.js @@ -0,0 +1,13 @@ +'use strict'; + +const strapiScalarToGraphQLScalar = require('./strapi-scalar-to-graphql-scalar'); +const graphQLFiltersToStrapiQuery = require('./graphql-filters-to-strapi-query'); +const graphqlScalarToOperators = require('./graphql-scalar-to-operators'); +const entityToResponseEntity = require('./entity-to-response-entity'); + +module.exports = context => ({ + ...strapiScalarToGraphQLScalar(context), + ...graphQLFiltersToStrapiQuery(context), + ...graphqlScalarToOperators(context), + ...entityToResponseEntity(context), +}); diff --git a/packages/plugins/graphql/server/services/utils/mappers/strapi-scalar-to-graphql-scalar.js b/packages/plugins/graphql/server/services/utils/mappers/strapi-scalar-to-graphql-scalar.js new file mode 100644 index 0000000000..8bbfa4fe71 --- /dev/null +++ b/packages/plugins/graphql/server/services/utils/mappers/strapi-scalar-to-graphql-scalar.js @@ -0,0 +1,24 @@ +'use strict'; + +const { get, difference } = require('lodash/fp'); + +module.exports = ({ strapi }) => { + const { STRAPI_SCALARS, SCALARS_ASSOCIATIONS } = strapi.plugin('graphql').service('constants'); + + const missingStrapiScalars = difference(STRAPI_SCALARS, Object.keys(SCALARS_ASSOCIATIONS)); + + if (missingStrapiScalars.length > 0) { + throw new Error('Some Strapi scalars are not handled in the GraphQL scalars mapper'); + } + + return { + /** + * Used to transform a Strapi scalar type into its GraphQL equivalent + * @param {string} strapiScalar + * @return {NexusGenScalars} + */ + strapiScalarToGraphQLScalar(strapiScalar) { + return get(strapiScalar, SCALARS_ASSOCIATIONS); + }, + }; +}; diff --git a/packages/plugins/graphql/server/services/utils/naming.js b/packages/plugins/graphql/server/services/utils/naming.js new file mode 100644 index 0000000000..9410b3849d --- /dev/null +++ b/packages/plugins/graphql/server/services/utils/naming.js @@ -0,0 +1,278 @@ +'use strict'; + +const { camelCase, upperFirst, lowerFirst, pipe, get } = require('lodash/fp'); +const { singular } = require('pluralize'); + +module.exports = ({ strapi }) => { + /** + * Build a type name for a enum based on a content type & an attribute name + * @param {object} contentType + * @param {string} attributeName + * @return {string} + */ + const getEnumName = (contentType, attributeName) => { + const { attributes, modelName } = contentType; + const { enumName } = attributes[attributeName]; + + const defaultEnumName = `ENUM_${modelName.toUpperCase()}_${attributeName.toUpperCase()}`; + + return enumName || defaultEnumName; + }; + + /** + * 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, { plurality = 'singular' } = {}) => { + const plugin = get('plugin', contentType); + const modelName = get('modelName', contentType); + const name = + plurality === 'singular' + ? get('info.singularName', contentType) + : get('info.pluralName', contentType); + + const transformedPlugin = upperFirst(camelCase(plugin)); + const transformedModelName = upperFirst(camelCase(name || singular(modelName))); + + return `${transformedPlugin}${transformedModelName}`; + }; + + /** + * Build the entity's type name for a given content type + * @param {object} contentType + * @return {string} + */ + const getEntityName = contentType => { + return `${getTypeName(contentType)}Entity`; + }; + + /** + * Build the entity meta type name for a given content type + * @param {object} contentType + * @return {string} + */ + const getEntityMetaName = contentType => { + return `${getEntityName(contentType)}Meta`; + }; + + /** + * Build the entity response's type name for a given content type + * @param {object} contentType + * @return {string} + */ + const getEntityResponseName = contentType => { + return `${getEntityName(contentType)}Response`; + }; + + /** + * Build the entity response collection's type name for a given content type + * @param {object} contentType + * @return {string} + */ + const getEntityResponseCollectionName = contentType => { + return `${getEntityName(contentType)}ResponseCollection`; + }; + + /** + * Build the relation response collection's type name for a given content type + * @param {object} contentType + * @return {string} + */ + const getRelationResponseCollectionName = contentType => { + return `${getTypeName(contentType)}RelationResponseCollection`; + }; + + /** + * Build a component type name based on its definition + * @param {object} contentType + * @return {string} + */ + const getComponentName = contentType => { + return contentType.globalId; + }; + + /** + * Build a component type name based on a content type's attribute + * @param {object} attribute + * @return {string} + */ + const getComponentNameFromAttribute = attribute => { + return strapi.components[attribute.component].globalId; + }; + + /** + * Build a dynamic zone type name based on a content type and an attribute name + * @param {object} contentType + * @param {string} attributeName + * @return {string} + */ + const getDynamicZoneName = (contentType, attributeName) => { + const typeName = getTypeName(contentType); + const dzName = upperFirst(camelCase(attributeName)); + const suffix = 'DynamicZone'; + + return `${typeName}${dzName}${suffix}`; + }; + + /** + * Build a dynamic zone input type name based on a content type and an attribute name + * @param {object} contentType + * @param {string} attributeName + * @return {string} + */ + const getDynamicZoneInputName = (contentType, attributeName) => { + const dzName = getDynamicZoneName(contentType, attributeName); + + return `${dzName}Input`; + }; + + /** + * Build a component input type name based on a content type and an attribute name + * @param {object} contentType + * @return {string} + */ + const getComponentInputName = contentType => { + const componentName = getComponentName(contentType); + + return `${componentName}Input`; + }; + + /** + * Build a content type input name based on a content type and an attribute name + * @param {object} contentType + * @return {string} + */ + const getContentTypeInputName = contentType => { + const typeName = getTypeName(contentType); + + return `${typeName}Input`; + }; + + /** + * Build the queries type name for a given content type + * @param {object} contentType + * @return {string} + */ + const getEntityQueriesTypeName = contentType => { + return `${getEntityName(contentType)}Queries`; + }; + + /** + * Build the mutations type name for a given content type + * @param {object} contentType + * @return {string} + */ + const getEntityMutationsTypeName = contentType => { + return `${getEntityName(contentType)}Mutations`; + }; + + /** + * Build the filters type name for a given content type + * @param {object} contentType + * @return {string} + */ + const getFiltersInputTypeName = contentType => { + return `${getTypeName(contentType)}FiltersInput`; + }; + + /** + * Build a filters type name for a given GraphQL scalar type + * @param {NexusGenScalars} scalarType + * @return {string} + */ + const getScalarFilterInputTypeName = scalarType => { + return `${scalarType}FilterInput`; + }; + + /** + * Build a type name for a given content type & polymorphic attribute + * @param {object} contentType + * @param {string} attributeName + * @return {string} + */ + const getMorphRelationTypeName = (contentType, attributeName) => { + const typeName = getTypeName(contentType); + const formattedAttr = upperFirst(camelCase(attributeName)); + + return `${typeName}${formattedAttr}Morph`; + }; + + /** + * Build a custom type name generator with different customization options + * @param {object} options + * @param {string} [options.prefix] + * @param {string} [options.suffix] + * @param {'upper' | 'lower'} [options.firstLetterCase] + * @param {'plural' | 'singular'} [options.plurality] + * @return {function(*=): string} + */ + const buildCustomTypeNameGenerator = (options = {}) => { + // todo[v4]: use singularName & pluralName is available + const { prefix = '', suffix = '', plurality = 'singular', firstLetterCase = 'upper' } = options; + + if (!['plural', 'singular'].includes(plurality)) { + throw new Error( + `"plurality" param must be either "plural" or "singular", but got: "${plurality}"` + ); + } + + const getCustomTypeName = pipe( + ct => getTypeName(ct, { plurality }), + firstLetterCase === 'upper' ? upperFirst : lowerFirst + ); + + return contentType => `${prefix}${getCustomTypeName(contentType)}${suffix}`; + }; + + const getFindQueryName = buildCustomTypeNameGenerator({ + plurality: 'plural', + firstLetterCase: 'lower', + }); + + const getFindOneQueryName = buildCustomTypeNameGenerator({ firstLetterCase: 'lower' }); + + const getCreateMutationTypeName = buildCustomTypeNameGenerator({ + prefix: 'create', + firstLetterCase: 'upper', + }); + + const getUpdateMutationTypeName = buildCustomTypeNameGenerator({ + prefix: 'update', + firstLetterCase: 'upper', + }); + + const getDeleteMutationTypeName = buildCustomTypeNameGenerator({ + prefix: 'delete', + firstLetterCase: 'upper', + }); + + return { + getEnumName, + getTypeName, + getEntityName, + getEntityMetaName, + getEntityResponseName, + getEntityResponseCollectionName, + getRelationResponseCollectionName, + getComponentName, + getComponentNameFromAttribute, + getDynamicZoneName, + getDynamicZoneInputName, + getComponentInputName, + getContentTypeInputName, + getEntityQueriesTypeName, + getEntityMutationsTypeName, + getFiltersInputTypeName, + getScalarFilterInputTypeName, + getMorphRelationTypeName, + buildCustomTypeNameGenerator, + getFindQueryName, + getFindOneQueryName, + getCreateMutationTypeName, + getUpdateMutationTypeName, + getDeleteMutationTypeName, + }; +}; diff --git a/packages/plugins/graphql/services/__tests__/data-loaders.test.js b/packages/plugins/graphql/services/__tests__/data-loaders.test.js deleted file mode 100644 index 63e7ac54e8..0000000000 --- a/packages/plugins/graphql/services/__tests__/data-loaders.test.js +++ /dev/null @@ -1,67 +0,0 @@ -'use strict'; - -const dataLoaders = require('../data-loaders'); - -describe('dataloader', () => { - describe('serializeKey', () => { - test('Serializes objects to json', () => { - expect(dataLoaders.serializeKey(1928)).toBe(1928); - expect(dataLoaders.serializeKey('test')).toBe('test'); - expect(dataLoaders.serializeKey([1, 2, 3])).toBe('[1,2,3]'); - expect(dataLoaders.serializeKey({ foo: 'bar' })).toBe('{"foo":"bar"}'); - expect(dataLoaders.serializeKey({ foo: 'bar', nested: { bar: 'foo' } })).toBe( - '{"foo":"bar","nested":{"bar":"foo"}}' - ); - }); - }); - - describe('makeQuery', () => { - test('makeQuery single calls findOne', async () => { - const uid = 'uid'; - const findOne = jest.fn(() => ({ id: 1 })); - const filters = { _limit: 5 }; - - global.strapi = { - query() { - return { findOne }; - }, - }; - - await dataLoaders.makeQuery(uid, { single: true, filters }); - - expect(findOne).toHaveBeenCalledWith(filters, []); - }); - - test('makeQuery calls find', async () => { - const uid = 'uid'; - const find = jest.fn(() => [{ id: 1 }]); - const filters = { limit: 5, sort: 'field' }; - - global.strapi = { - query() { - return { find }; - }, - }; - - await dataLoaders.makeQuery(uid, { filters }); - - expect(find).toHaveBeenCalledWith(filters, []); - }); - - test('makeQuery disables populate to optimize fetching a bit', async () => { - const uid = 'uid'; - const find = jest.fn(() => [{ id: 1 }]); - const filters = { _limit: 5 }; - - global.strapi = { - query() { - return { find }; - }, - }; - - await dataLoaders.makeQuery(uid, { filters }); - - expect(find).toHaveBeenCalledWith(filters, []); - }); - }); -}); diff --git a/packages/plugins/graphql/services/__tests__/naming.test.js b/packages/plugins/graphql/services/__tests__/naming.test.js deleted file mode 100644 index d03bab7dae..0000000000 --- a/packages/plugins/graphql/services/__tests__/naming.test.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; - -const { toPlural, toSingular, toInputName } = require('../naming'); - -describe('Name util', () => { - it('Pluralizes with camelcase', () => { - expect(toPlural('post')).toBe('posts'); - expect(toPlural('posts')).toBe('posts'); - expect(toPlural('Posts')).toBe('posts'); - expect(toPlural('home-page')).toBe('homePages'); - }); - - it('Casts to singular with camelcase', () => { - expect(toSingular('post')).toBe('post'); - expect(toSingular('posts')).toBe('post'); - expect(toSingular('Posts')).toBe('post'); - expect(toSingular('home-pages')).toBe('homePage'); - }); - - it('Generates valid input type names', () => { - expect(toInputName('post')).toBe('PostInput'); - expect(toInputName('posts')).toBe('PostInput'); - expect(toInputName('home-page')).toBe('HomePageInput'); - }); -}); diff --git a/packages/plugins/graphql/services/__tests__/resolvers-builder.test.js b/packages/plugins/graphql/services/__tests__/resolvers-builder.test.js deleted file mode 100644 index ee18f0c7b5..0000000000 --- a/packages/plugins/graphql/services/__tests__/resolvers-builder.test.js +++ /dev/null @@ -1,122 +0,0 @@ -'use strict'; - -const { buildMutation, buildQuery } = require('../resolvers-builder'); - -global.strapi = { - plugins: { - graphql: { - config: {}, - }, - }, - api: { - 'my-api': { - controllers: { - 'my-controller': {}, - }, - }, - }, -}; - -const graphqlContext = { - context: { - req: {}, - res: {}, - app: { - createContext(request, response) { - return { request, response }; - }, - }, - }, -}; - -describe('Resolvers builder', () => { - describe('buildMutation', () => { - test("Returns ctx.body if it's not falsy and the resolver is a string", async () => { - expect.assertions(1); - - strapi.api['my-api'].controllers['my-controller'].myAction = async ctx => { - ctx.body = 1; - }; - - const resolver = buildMutation('mutation', { - resolver: 'api::my-api.my-controller.myAction', - }); - - const result = await resolver(null, {}, graphqlContext); - expect(result).toBe(1); - }); - - test("Returns ctx.body if it's not undefined and the resolver is a string", async () => { - expect.assertions(1); - - strapi.api['my-api'].controllers['my-controller'].myAction = async ctx => { - ctx.body = 0; - }; - - const resolver = buildMutation('mutation', { - resolver: 'api::my-api.my-controller.myAction', - }); - - const result = await resolver(null, {}, graphqlContext); - expect(result).toBe(0); - }); - - test('Returns the action result if ctx.body is undefined and the resolver is a string', async () => { - expect.assertions(1); - - strapi.api['my-api'].controllers['my-controller'].myAction = async () => 'result'; - - const resolver = buildMutation('mutation', { - resolver: 'api::my-api.my-controller.myAction', - }); - - const result = await resolver(null, {}, graphqlContext); - expect(result).toBe('result'); - }); - }); - - describe('buildQuery', () => { - test("Returns ctx.body if it's not falsy and the resolver is a string", async () => { - expect.assertions(1); - - strapi.api['my-api'].controllers['my-controller'].myAction = async ctx => { - ctx.body = 1; - }; - - const resolver = buildQuery('mutation', { - resolver: 'api::my-api.my-controller.myAction', - }); - - const result = await resolver(null, {}, graphqlContext); - expect(result).toBe(1); - }); - - test("Returns ctx.body if it's not undefined and the resolver is a string", async () => { - expect.assertions(1); - - strapi.api['my-api'].controllers['my-controller'].myAction = async ctx => { - ctx.body = 0; - }; - - const resolver = buildQuery('mutation', { - resolver: 'api::my-api.my-controller.myAction', - }); - - const result = await resolver(null, {}, graphqlContext); - expect(result).toBe(0); - }); - - test('Returns the action result if ctx.body is undefined and the resolver is a string', async () => { - expect.assertions(1); - - strapi.api['my-api'].controllers['my-controller'].myAction = async () => 'result'; - - const resolver = buildQuery('mutation', { - resolver: 'api::my-api.my-controller.myAction', - }); - - const result = await resolver(null, {}, graphqlContext); - expect(result).toBe('result'); - }); - }); -}); diff --git a/packages/plugins/graphql/services/__tests__/utils.test.js b/packages/plugins/graphql/services/__tests__/utils.test.js deleted file mode 100644 index bceacdf52d..0000000000 --- a/packages/plugins/graphql/services/__tests__/utils.test.js +++ /dev/null @@ -1,66 +0,0 @@ -'use strict'; - -const { mergeSchemas } = require('../utils'); - -const createRootSchema = () => ({ - definition: '', - resolvers: {}, - query: {}, - mutation: {}, -}); - -describe('Utils', () => { - describe('mergeSchemas', () => { - test('Ignore empty schema', () => { - const rootSchema = createRootSchema(); - mergeSchemas(rootSchema, {}); - - expect(rootSchema).toEqual(createRootSchema()); - }); - - test('Concatenates definitions', () => { - const rootSchema = createRootSchema(); - mergeSchemas(rootSchema, { - definition: 'type Query {}', - }); - - expect(rootSchema).toMatchObject({ - definition: '\ntype Query {}', - }); - }); - - test('merges resolvers', () => { - const resolvers = { - Post: { - id() {}, - }, - }; - - const rootSchema = createRootSchema(); - mergeSchemas(rootSchema, { - resolvers, - }); - - expect(rootSchema.resolvers).toEqual(resolvers); - }); - - test('merges query and mutation', () => { - const query = { - posts() {}, - }; - - const mutation = { - createMutation() {}, - }; - - const rootSchema = createRootSchema(); - mergeSchemas(rootSchema, { - query, - mutation, - }); - - expect(rootSchema.query).toEqual(query); - expect(rootSchema.mutation).toEqual(mutation); - }); - }); -}); diff --git a/packages/plugins/graphql/services/build-aggregation.js b/packages/plugins/graphql/services/build-aggregation.js deleted file mode 100644 index 3920140ec1..0000000000 --- a/packages/plugins/graphql/services/build-aggregation.js +++ /dev/null @@ -1,565 +0,0 @@ -/** - * Aggregator.js service - * - * @description: A set of functions similar to controller's actions to avoid code duplication. - */ - -'use strict'; - -const _ = require('lodash'); -const pluralize = require('pluralize'); -const { convertRestQueryParams, buildQuery } = require('@strapi/utils'); - -const { buildQuery: buildQueryResolver } = require('./resolvers-builder'); -const { convertToParams, convertToQuery, nonRequired } = require('./utils'); -const { toSDL } = require('./schema-definitions'); - -/** - * Returns all fields of type primitive - * - * @returns {Boolean} - */ -const isPrimitiveType = type => { - const nonRequiredType = nonRequired(type); - return ( - nonRequiredType === 'Int' || - nonRequiredType === 'Float' || - nonRequiredType === 'String' || - nonRequiredType === 'Boolean' || - nonRequiredType === 'DateTime' || - nonRequiredType === 'JSON' - ); -}; - -/** - * Checks if the field is of type enum - * - * @returns {Boolean} - */ -const isEnumType = type => { - return type === 'enumeration'; -}; - -/** - * Returns all fields that are not of type array - * - * @returns {Boolean} - * - * @example - * - * isNotOfTypeArray([String]) - * // => false - * isNotOfTypeArray(String!) - * // => true - */ -const isNotOfTypeArray = type => { - return !/(\[\w+!?\])/.test(type); -}; - -/** - * Returns all fields of type Integer or float - */ -const isNumberType = type => { - const nonRequiredType = nonRequired(type); - return nonRequiredType === 'Int' || nonRequiredType === 'Float'; -}; - -/** - * Returns a list of fields that have type included in fieldTypes. - */ -const 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} - */ -const fieldResolver = (field, key) => { - return object => { - const resolver = - field.resolve || - function resolver(obj) { - // eslint-disable-line no-unused-vars - return obj[key]; - }; - return resolver(object); - }; -}; - -/** - * Create fields resolvers - * - * @return {Object} - */ -const createFieldsResolver = function(fields, resolverFn, typeCheck) { - const resolver = 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 resolverFn( - obj, - options, - context, - fieldResolver(field, fieldKey), - fieldKey, - obj, - field - ); - }); - } - return acc; - }, {}); - - return resolver; -}; - -/** - * 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 - * - */ -const extractType = function(_type, attributeType) { - return isPrimitiveType(_type) - ? _type.replace('!', '') - : isEnumType(attributeType) - ? 'String' - : 'ID'; -}; - -/** - * 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() { .... } - * } - */ -const createAggregationFieldsResolver = function(model, fields, operation, typeCheck) { - return createFieldsResolver( - fields, - async (obj, options, context, fieldResolver, fieldKey) => { - const filters = convertRestQueryParams({ - ...convertToParams(_.omit(obj, 'where')), - ...convertToQuery(obj.where), - }); - - if (model.orm === 'mongoose') { - return buildQuery({ model, filters, aggregate: true }) - .group({ - _id: null, - [fieldKey]: { [`$${operation}`]: `$${fieldKey}` }, - }) - .exec() - .then(result => _.get(result, [0, fieldKey])); - } - - if (model.orm === 'bookshelf') { - return model - .query(qb => { - // apply filters - buildQuery({ model, filters })(qb); - - // `sum, avg, min, max` pass nicely to knex :-> - qb[operation](`${fieldKey} as ${operation}_${fieldKey}`); - }) - .fetch() - .then(result => result.get(`${operation}_${fieldKey}`)); - } - }, - typeCheck - ); -}; - -/** - * Correctly format the data returned by the group by - */ -const preProcessGroupByData = function({ result, fieldKey, filters }) { - const _result = _.toArray(result).filter(value => Boolean(value._id)); - return _.map(_result, value => { - return { - key: value._id.toString(), - connection() { - // filter by the grouped by value in next connection - - return { - ...filters, - where: { - ...(filters.where || {}), - [fieldKey]: value._id.toString(), - }, - }; - }, - }; - }); -}; - -/** - * 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() { .... } - * } - */ -const createGroupByFieldsResolver = function(model, fields) { - const resolver = async (filters, options, context, fieldResolver, fieldKey) => { - const params = convertRestQueryParams({ - ...convertToParams(_.omit(filters, 'where')), - ...convertToQuery(filters.where), - }); - - if (model.orm === 'mongoose') { - const result = await buildQuery({ - model, - filters: params, - aggregate: true, - }).group({ - _id: `$${fieldKey === 'id' ? model.primaryKey : fieldKey}`, - }); - - return preProcessGroupByData({ - result, - fieldKey, - filters, - }); - } - - if (model.orm === 'bookshelf') { - return model - .query(qb => { - buildQuery({ model, filters: params })(qb); - qb.groupBy(fieldKey); - qb.select(fieldKey); - }) - .fetchAll() - .then(result => { - let values = result.models - .map(m => m.get(fieldKey)) // extract aggregate field - .filter(v => !!v) // remove null - .map(v => '' + v); // convert to string - return values.map(v => ({ - key: v, - connection() { - return { - ..._.omit(filters, ['limit']), // we shouldn't carry limit to sub-field - where: { - ...(filters.where || {}), - [fieldKey]: v, - }, - }; - }, - })); - }); - } - }; - - return createFieldsResolver(fields, resolver, () => true); -}; -/** - * Generate the connection type of each non-array field of the model - * - * @return {String} - */ -const generateConnectionFieldsTypes = function(fields, model) { - const { globalId, attributes } = model; - const primitiveFields = getFieldsByTypes(fields, isNotOfTypeArray, (type, name) => - 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)} {${toSDL(connectionFields[fieldKey])}}` - ) - .join('\n\n'); -}; - -const formatConnectionGroupBy = function(fields, model) { - const { globalId } = model; - const groupByGlobalId = `${globalId}GroupBy`; - - // Extract all primitive fields and change their types - const groupByFields = getFieldsByTypes( - fields, - isNotOfTypeArray, - (fieldType, fieldName) => `[${globalId}Connection${_.upperFirst(fieldName)}]` - ); - - // Get the generated field types - let groupByTypes = `type ${groupByGlobalId} {${toSDL(groupByFields)}}\n\n`; - groupByTypes += generateConnectionFieldsTypes(fields, model); - - return { - globalId: groupByGlobalId, - type: groupByTypes, - resolver: { - [groupByGlobalId]: createGroupByFieldsResolver(model, groupByFields), - }, - }; -}; - -const formatConnectionAggregator = function(fields, model, modelName) { - const { globalId } = model; - - // Extract all fields of type Integer and Float and change their type to Float - const numericFields = getFieldsByTypes(fields, isNumberType, () => 'Float'); - - // Don't create an aggregator field if the model has not number fields - const aggregatorGlobalId = `${globalId}Aggregator`; - const initialFields = { - count: 'Int', - totalCount: '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 = toSDL(numericFields); - let aggregatorTypes = `type ${aggregatorGlobalId} {${toSDL(initialFields)}}\n\n`; - - let resolvers = { - [aggregatorGlobalId]: { - count(obj) { - const opts = convertToQuery(obj.where); - - if (opts._q) { - // allow search param - return strapi.query(modelName, model.plugin).countSearch(opts); - } - return strapi.query(modelName, model.plugin).count(opts); - }, - totalCount() { - return strapi.query(modelName, model.plugin).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 => { - // 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`]: createAggregationFieldsResolver( - model, - fields, - 'sum', - isNumberType - ), - [`${aggregatorGlobalId}Avg`]: createAggregationFieldsResolver( - model, - fields, - 'avg', - isNumberType - ), - [`${aggregatorGlobalId}Min`]: createAggregationFieldsResolver( - model, - fields, - 'min', - isNumberType - ), - [`${aggregatorGlobalId}Max`]: createAggregationFieldsResolver( - model, - fields, - 'max', - isNumberType - ), - }; - } - - return { - globalId: aggregatorGlobalId, - type: aggregatorTypes, - resolver: resolvers, - }; -}; - -/** - * 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 - * } - * - */ -const formatModelConnectionsGQL = function({ fields, model: contentType, name, resolver }) { - const { globalId } = contentType; - const model = strapi.getModel(contentType.uid); - - const connectionGlobalId = `${globalId}Connection`; - - const aggregatorFormat = formatConnectionAggregator(fields, model, name); - const groupByFormat = formatConnectionGroupBy(fields, model); - const connectionFields = { - values: `[${globalId}]`, - groupBy: `${globalId}GroupBy`, - aggregate: `${globalId}Aggregator`, - }; - const pluralName = pluralize.plural(_.camelCase(name)); - - let modelConnectionTypes = `type ${connectionGlobalId} {${toSDL(connectionFields)}}\n\n`; - if (aggregatorFormat) { - modelConnectionTypes += aggregatorFormat.type; - } - modelConnectionTypes += groupByFormat.type; - - const connectionResolver = buildQueryResolver(`${pluralName}Connection.values`, resolver); - - const connectionQueryName = `${pluralName}Connection`; - - return { - globalId: connectionGlobalId, - definition: modelConnectionTypes, - query: { - [`${pluralName}Connection`]: { - args: { - sort: 'String', - limit: 'Int', - start: 'Int', - where: 'JSON', - ...(resolver.args || {}), - }, - type: connectionGlobalId, - }, - }, - resolvers: { - Query: { - [connectionQueryName]: buildQueryResolver(connectionQueryName, { - resolverOf: resolver.resolverOf || resolver.resolver, - resolver(obj, options) { - return options; - }, - }), - }, - [connectionGlobalId]: { - values(obj, options, gqlCtx) { - return connectionResolver(obj, obj, gqlCtx); - }, - groupBy(obj) { - return obj; - }, - aggregate(obj) { - return obj; - }, - }, - ...aggregatorFormat.resolver, - ...groupByFormat.resolver, - }, - }; -}; - -module.exports = { - formatModelConnectionsGQL, -}; diff --git a/packages/plugins/graphql/services/data-loaders.js b/packages/plugins/graphql/services/data-loaders.js deleted file mode 100644 index c970f4613f..0000000000 --- a/packages/plugins/graphql/services/data-loaders.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -/** - * Loaders.js service - * - * @description: A set of functions similar to controller's actions to avoid code duplication. - */ - -const _ = require('lodash'); -const DataLoader = require('dataloader'); - -module.exports = { - loaders: {}, - - initializeLoader() { - this.resetLoaders(); - - // Create loaders for each relational field (exclude core models & plugins). - Object.values(strapi.contentTypes).forEach(model => this.createLoader(model.uid)); - }, - - resetLoaders() { - this.loaders = {}; - }, - - createLoader(modelUID) { - if (this.loaders[modelUID]) { - return this.loaders[modelUID]; - } - - const loadFn = queries => this.batchQuery(modelUID, queries); - const loadOptions = { - cacheKeyFn: key => this.serializeKey(key), - }; - - this.loaders[modelUID] = new DataLoader(loadFn, loadOptions); - }, - - serializeKey(key) { - return _.isObjectLike(key) ? JSON.stringify(key) : key; - }, - - async batchQuery(modelUID, queries) { - // Extract queries from keys and merge similar queries. - return Promise.all(queries.map(query => this.makeQuery(modelUID, query))); - }, - - async makeQuery(modelUID, query = {}) { - if (query.single === true) { - return strapi.query(modelUID).findOne(query.filters, []); - } - - return strapi.query(modelUID).find(query.filters, []); - }, -}; diff --git a/packages/plugins/graphql/services/naming.js b/packages/plugins/graphql/services/naming.js deleted file mode 100644 index 4ad7197652..0000000000 --- a/packages/plugins/graphql/services/naming.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -const _ = require('lodash'); -const pluralize = require('pluralize'); - -const toPlural = str => pluralize(_.camelCase(str)); -const toSingular = str => _.camelCase(pluralize.singular(str)); - -const toInputName = str => `${_.upperFirst(toSingular(str))}Input`; - -module.exports = { - toSingular, - toPlural, - toInputName, -}; diff --git a/packages/plugins/graphql/services/resolvers-builder.js b/packages/plugins/graphql/services/resolvers-builder.js deleted file mode 100644 index 7fae9733d1..0000000000 --- a/packages/plugins/graphql/services/resolvers-builder.js +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Build queries and mutation resolvers - */ - -'use strict'; - -const _ = require('lodash'); -const compose = require('koa-compose'); - -const { policy: policyUtils } = require('@strapi/utils'); -const { - convertToParams, - convertToQuery, - amountLimiting, - getAction, - getActionDetails, - isResolvablePath, -} = require('./utils'); - -const buildMutation = (mutationName, config) => { - const { resolver, resolverOf, transformOutput = _.identity, isShadowCrud = false } = config; - - if (_.isFunction(resolver) && !isResolvablePath(resolverOf)) { - throw new Error( - `Cannot create mutation "${mutationName}". Missing "resolverOf" option with custom resolver.` - ); - } - - const policiesMiddleware = compose(getPolicies(config)); - - // custom resolvers - if (_.isFunction(resolver)) { - return async (root, options = {}, graphqlContext, info) => { - const ctx = buildMutationContext({ options, graphqlContext, isShadowCrud }); - - await policiesMiddleware(ctx); - graphqlContext.context = ctx; - - return resolver(root, options, graphqlContext, info); - }; - } - - const action = getAction(resolver); - - return async (root, options = {}, graphqlContext) => { - const ctx = buildMutationContext({ options, graphqlContext, isShadowCrud }); - - await policiesMiddleware(ctx); - - const values = await action(ctx); - const result = ctx.body !== undefined ? ctx.body : values; - - if (_.isError(result)) { - throw result; - } - - return transformOutput(result); - }; -}; - -const buildMutationContext = ({ options, graphqlContext, isShadowCrud }) => { - const { context } = graphqlContext; - - const ctx = cloneKoaContext(context); - - if (options.input && options.input.where) { - ctx.params = convertToParams(options.input.where || {}); - } else { - ctx.params = {}; - } - - if (options.input && options.input.data) { - ctx.request.body = options.input.data || {}; - } else { - ctx.request.body = options; - } - - if (isShadowCrud) { - ctx.query = convertToParams(_.omit(options, 'input')); - } - - return ctx; -}; - -const buildQuery = (queryName, config) => { - const { resolver } = config; - - try { - validateResolverOption(config); - } catch (error) { - throw new Error(`Cannot create query "${queryName}": ${error.message}`); - } - - const policiesMiddleware = compose(getPolicies(config)); - - // custom resolvers - if (_.isFunction(resolver)) { - return async (root, options = {}, graphqlContext, info) => { - const { ctx, opts } = buildQueryContext({ options, graphqlContext }); - - await policiesMiddleware(ctx); - graphqlContext.context = ctx; - - return resolver(root, opts, graphqlContext, info); - }; - } - - const action = getAction(resolver); - - return async (root, options = {}, graphqlContext) => { - const { ctx } = buildQueryContext({ options, graphqlContext }); - - // duplicate context - await policiesMiddleware(ctx); - - const values = await action(ctx); - const result = ctx.body !== undefined ? ctx.body : values; - - if (_.isError(result)) { - throw result; - } - - return result; - }; -}; - -const validateResolverOption = config => { - const { resolver, resolverOf, policies } = config; - - if (_.isFunction(resolver) && !isResolvablePath(resolverOf)) { - throw new Error(`Missing "resolverOf" option with custom resolver.`); - } - - if (!_.isUndefined(policies) && (!Array.isArray(policies) || !_.every(policies, _.isString))) { - throw new Error('Policies option must be an array of string.'); - } - - return true; -}; - -const cloneKoaContext = ctx => { - return Object.assign(ctx.app.createContext(_.clone(ctx.req), _.clone(ctx.res)), { - state: { - ...ctx.state, - }, - }); -}; - -const buildQueryContext = ({ options, graphqlContext }) => { - const { context } = graphqlContext; - const _options = _.cloneDeep(options); - - const ctx = cloneKoaContext(context); - - const opts = amountLimiting(_options); - - ctx.query = { - ...convertToParams(_.omit(opts, 'where')), - ...convertToQuery(opts.where), - }; - - ctx.params = convertToParams(opts); - - return { ctx, opts }; -}; - -/** - * Checks if a resolverPath (resolver or resovlerOf) might be resolved - */ -const getPolicies = config => { - const { resolver, policies = [], resolverOf } = config; - - const { api, plugin } = config['_metadatas'] || {}; - - const policyFns = []; - - const { controller, action, plugin: pathPlugin } = isResolvablePath(resolverOf) - ? getActionDetails(resolverOf) - : getActionDetails(resolver); - - const globalPolicy = policyUtils.globalPolicy({ - controller, - action, - plugin: pathPlugin, - }); - - policyFns.push(globalPolicy); - - if (strapi.plugins['users-permissions']) { - policies.unshift('plugin::users-permissions.permissions'); - } - - policies.forEach(policy => { - const policyFn = policyUtils.get(policy, plugin, api); - policyFns.push(policyFn); - }); - - return policyFns; -}; - -module.exports = { - buildQuery, - buildMutation, -}; diff --git a/packages/plugins/graphql/services/schema-definitions.js b/packages/plugins/graphql/services/schema-definitions.js deleted file mode 100644 index 40c79d42df..0000000000 --- a/packages/plugins/graphql/services/schema-definitions.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Schema definition language tools - */ - -'use strict'; - -const _ = require('lodash'); - -/** - * Retrieves a type description from its configuration or its related model - * @return String - */ -const getTypeDescription = (type, model = {}) => { - const str = _.get(type, '_description') || _.get(model, 'info.description'); - - if (str) { - return `"""\n${str}\n"""\n`; - } - - return ''; -}; - -/** - * Receive an Object and return a string which is following the GraphQL specs. - * @param {Object} fields - * @param {Object} description - * @param {Object} model the underlying strapi model of those fields - * @param {string} type the type of object we are converting to SQL (query, mutation, or fields) - */ -const toSDL = (fields, configurations = {}, model = {}, type = 'field') => { - if (['query', 'mutation'].includes(type)) { - return operationToSDL({ fields, configurations }); - } - - return fieldsToSDL({ fields, model, configurations }); -}; - -/** - * Generated a SDL for a type - * @param {Object} options - * @param {Object} options.fields fields to convert to SDL - * @param {Object} options.configurations fields configurations (descriptions and deprecations) - * @param {Object} options.model the underlying strapi model of those fields - */ -const fieldsToSDL = ({ fields, configurations, model }) => { - return Object.entries(fields) - .map(([key, value]) => { - const [attr] = key.split('('); - const attributeName = _.trim(attr); - - const description = _.isString(configurations[attributeName]) - ? configurations[attributeName] - : _.get(configurations, [attributeName, 'description']) || - _.get(model, ['attributes', attributeName, 'description']); - - const deprecated = - _.get(configurations, [attributeName, 'deprecated']) || - _.get(model, ['attributes', attributeName, 'deprecated']); - - return applyMetadatas(`${key}: ${value}`, { description, deprecated }); - }) - .join('\n'); -}; - -/** - * Generated a SDL for a query or a mutation object - * @param {Object} options - * @param {Object} options.fields fields to convert to SDL - * @param {Object} options.configurations fields configurations (descriptions and deprecations) - */ -const operationToSDL = ({ fields, configurations }) => { - return Object.entries(fields) - .map(([key, value]) => { - if (typeof value === 'string') { - const [attr] = key.split('('); - const attributeName = _.trim(attr); - - 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 - * @param {Object} metadatas field metadatas - * @param {string} metadatas.description field description - * @param {string} metadatas.deprecated field deprecation - */ -const applyMetadatas = (definition, metadatas = {}) => { - const { description, deprecated } = metadatas; - - let tmpDef = definition; - if (description) { - tmpDef = `"""\n${description}\n"""\n${tmpDef}`; - } - - if (deprecated) { - tmpDef = `${tmpDef} @deprecated(reason: "${deprecated}")`; - } - - return tmpDef; -}; - -module.exports = { - toSDL, - getTypeDescription, -}; diff --git a/packages/plugins/graphql/services/schema-generator.js b/packages/plugins/graphql/services/schema-generator.js deleted file mode 100644 index 9e77b8cb62..0000000000 --- a/packages/plugins/graphql/services/schema-generator.js +++ /dev/null @@ -1,178 +0,0 @@ -'use strict'; - -/** - * GraphQL.js service - * - * @description: A set of functions similar to controller's actions to avoid code duplication. - */ -const { filterSchema } = require('@graphql-tools/utils'); -const { buildFederatedSchema } = require('@apollo/federation'); -const { gql, makeExecutableSchema } = require('apollo-server-koa'); -const _ = require('lodash'); -const graphql = require('graphql'); -const PublicationState = require('../types/publication-state'); -const Types = require('./type-builder'); -const buildShadowCrud = require('./shadow-crud'); -const { createDefaultSchema, diffResolvers } = require('./utils'); -const { toSDL } = require('./schema-definitions'); -const { buildQuery, buildMutation } = require('./resolvers-builder'); - -/** - * Generate GraphQL schema. - * - * @return Schema - */ - -const generateSchema = () => { - const isFederated = _.get(strapi.plugins.graphql.config, 'federation', false); - const shadowCRUDEnabled = strapi.plugins.graphql.config.shadowCRUD !== false; - - 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; - - // Polymorphic. - const polymorphicSchema = Types.addPolymorphicUnionType(definition + shadowCRUD.definition); - - const builtResolvers = _.merge({}, shadowCRUD.resolvers, polymorphicSchema.resolvers); - - const extraResolvers = diffResolvers(_schema.resolver, builtResolvers); - - const resolvers = _.merge({}, builtResolvers, buildResolvers(extraResolvers)); - - // Return empty schema when there is no model. - if (_.isEmpty(shadowCRUD.definition) && _.isEmpty(definition)) { - return {}; - } - - const queryFields = shadowCRUD.query && toSDL(shadowCRUD.query, resolver.Query, null, 'query'); - - const mutationFields = - shadowCRUD.mutation && toSDL(shadowCRUD.mutation, resolver.Mutation, null, 'mutation'); - - Object.assign(resolvers, PublicationState.resolver); - - const scalars = Types.getScalars(); - - Object.assign(resolvers, scalars); - - const scalarDef = Object.keys(scalars) - .map(key => `scalar ${key}`) - .join('\n'); - - // Concatenate. - // Manually defined to avoid exposing all attributes (like password etc.) - let typeDefs = ` - ${definition} - ${shadowCRUD.definition} - ${polymorphicSchema.definition} - ${Types.addInput()} - - ${PublicationState.definition} - - type AdminUser { - id: ID! - username: String - firstname: String! - lastname: String! - } - - type Query { - ${queryFields} - ${query} - } - - type Mutation { - ${mutationFields} - ${mutation} - } - ${scalarDef} - `; - - // Build schema. - const schema = makeExecutableSchema({ - typeDefs, - resolvers, - }); - - const generatedSchema = filterDisabledResolvers(schema, extraResolvers); - - if (strapi.config.environment !== 'production') { - writeGenerateSchema(generatedSchema); - } - - return isFederated ? getFederatedSchema(generatedSchema, resolvers) : generatedSchema; -}; - -const getFederatedSchema = (schema, resolvers) => - buildFederatedSchema([{ typeDefs: gql(graphql.printSchema(schema)), resolvers }]); - -const filterDisabledResolvers = (schema, extraResolvers) => - filterSchema({ - schema, - rootFieldFilter(operationName, fieldName) { - const resolver = _.get(extraResolvers[operationName], fieldName, true); - - // resolvers set to false are filtered from the schema - if (resolver === false) { - return false; - } - return true; - }, - }); - -/** - * Save into a file the readable GraphQL schema. - * - * @return void - */ -const writeGenerateSchema = schema => { - const printSchema = graphql.printSchema(schema); - return strapi.fs.writeAppFile('exports/graphql/schema.graphql', printSchema); -}; - -const buildResolvers = resolvers => { - // Transform object to only contain function. - return Object.keys(resolvers).reduce((acc, type) => { - if (graphql.isScalarType(resolvers[type])) { - return acc; - } - - return Object.keys(resolvers[type]).reduce((acc, resolverName) => { - const resolverObj = resolvers[type][resolverName]; - - // Disabled this query. - if (resolverObj === false) return acc; - - if (_.isFunction(resolverObj)) { - return _.set(acc, [type, resolverName], resolverObj); - } - - switch (type) { - case 'Mutation': { - _.set(acc, [type, resolverName], buildMutation(resolverName, resolverObj)); - - break; - } - default: { - _.set(acc, [type, resolverName], buildQuery(resolverName, resolverObj)); - break; - } - } - - return acc; - }, acc); - }, {}); -}; - -module.exports = { - generateSchema, -}; diff --git a/packages/plugins/graphql/services/shadow-crud.js b/packages/plugins/graphql/services/shadow-crud.js deleted file mode 100644 index a1193c46e2..0000000000 --- a/packages/plugins/graphql/services/shadow-crud.js +++ /dev/null @@ -1,612 +0,0 @@ -'use strict'; - -const _ = require('lodash'); -const { contentTypes } = require('@strapi/utils'); - -const { - hasDraftAndPublish, - constants: { DP_PUB_STATE_LIVE }, -} = contentTypes; - -const DynamicZoneScalar = require('../types/dynamiczoneScalar'); - -const { formatModelConnectionsGQL } = require('./build-aggregation'); -const types = require('./type-builder'); -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 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', -}; - -/** - * 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)) { - return element.map(el => assignOptions(el, parent)); - } - - return _.set(element, OPTIONS, _.get(parent, OPTIONS, {})); -}; - -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 isTypeAttributeEnabled = (model, attr) => - _.get(strapi.plugins.graphql, `config._schema.graphql.type.${model.globalId}.${attr}`) !== false; -const isNotPrivate = _.curry((model, attributeName) => { - return !contentTypes.isPrivateAttribute(model, attributeName); -}); - -const wrapPublicationStateResolver = query => async (parent, args, ctx, ast) => { - const results = await query(parent, args, ctx, ast); - - const queryOptions = _.pick(args, 'publicationState'); - return assignOptions(results, { [OPTIONS]: queryOptions }); -}; - -const buildTypeDefObj = model => { - const { associations = [], attributes, primaryKey, globalId } = model; - - const typeDef = { - id: 'ID!', - [primaryKey]: 'ID!', - }; - - // Add timestamps attributes. - if (_.isArray(_.get(model, 'options.timestamps'))) { - const [createdAtKey, updatedAtKey] = model.options.timestamps; - typeDef[createdAtKey] = 'DateTime!'; - typeDef[updatedAtKey] = 'DateTime!'; - } - - Object.keys(attributes) - .filter(isNotPrivate(model)) - .filter(attributeName => isTypeAttributeEnabled(model, attributeName)) - .forEach(attributeName => { - const attribute = attributes[attributeName]; - // Convert our type to the GraphQL type. - typeDef[attributeName] = types.convertType({ - attribute, - modelName: globalId, - attributeName, - }); - }); - - // Change field definition for collection relations - associations - .filter(association => association.type === 'collection') - .filter(association => isNotPrivate(model, association.alias)) - .filter(attributeName => isTypeAttributeEnabled(model, attributeName)) - .forEach(association => { - typeDef[`${association.alias}(sort: String, limit: Int, start: Int, where: JSON)`] = - typeDef[association.alias]; - - delete typeDef[association.alias]; - }); - - return typeDef; -}; - -const generateEnumDefinitions = (model, globalId) => { - const { attributes } = model; - return Object.keys(attributes) - .filter(attribute => attributes[attribute].type === 'enumeration') - .filter(attribute => isTypeAttributeEnabled(model, attribute)) - .map(attribute => { - const definition = attributes[attribute]; - - const name = types.convertEnumType(definition, globalId, attribute); - const values = definition.enum.map(v => `\t${v}`).join('\n'); - return `enum ${name} {\n${values}\n}\n`; - }) - .join(''); -}; - -const generateDynamicZoneDefinitions = (attributes, globalId, schema) => { - Object.keys(attributes) - .filter(attribute => attributes[attribute].type === 'dynamiczone') - .forEach(attribute => { - const { components } = attributes[attribute]; - - const typeName = `${globalId}${_.upperFirst(_.camelCase(attribute))}DynamicZone`; - - if (components.length === 0) { - // Create dummy type because graphql doesn't support empty ones - schema.definition += `type ${typeName} { _:Boolean}`; - } else { - const componentsTypeNames = components.map(componentUID => { - const compo = strapi.components[componentUID]; - if (!compo) { - throw new Error( - `Trying to creating dynamiczone type with unkown component ${componentUID}` - ); - } - - return compo.globalId; - }); - - const unionType = `union ${typeName} = ${componentsTypeNames.join(' | ')}`; - - schema.definition += `\n${unionType}\n`; - } - - const inputTypeName = `${typeName}Input`; - schema.definition += `\nscalar ${inputTypeName}\n`; - - schema.resolvers[typeName] = { - __resolveType(obj) { - return strapi.components[obj.__component].globalId; - }, - }; - - schema.resolvers[inputTypeName] = new DynamicZoneScalar({ - name: inputTypeName, - attribute, - globalId, - components, - }); - }); -}; - -const initQueryOptions = (targetModel, parent) => { - if (hasDraftAndPublish(targetModel)) { - return { - _publicationState: _.get(parent, [OPTIONS, 'publicationState'], DP_PUB_STATE_LIVE), - }; - } - - return {}; -}; - -const buildAssocResolvers = model => { - const { primaryKey, associations = [] } = model; - - return associations - .filter(association => isNotPrivate(model, association.alias)) - .filter(association => isTypeAttributeEnabled(model, association.alias)) - .reduce((resolver, association) => { - const target = association.model || association.collection; - const targetModel = strapi.getModel(target, association.plugin); - - const { nature, alias } = association; - - switch (nature) { - case 'oneToManyMorph': - case 'manyMorphToOne': - case 'manyMorphToMany': - case 'manyToManyMorph': { - resolver[alias] = async obj => { - if (obj[alias]) { - return assignOptions(obj[alias], obj); - } - - const params = { - ...initQueryOptions(targetModel, obj), - id: obj[primaryKey], - }; - - const entry = await strapi.query(model.uid).findOne(params, [alias]); - - return assignOptions(entry[alias], obj); - }; - break; - } - default: { - resolver[alias] = async (obj, options) => { - // force component relations to be refetched - if (model.modelType === 'component') { - obj[alias] = _.get(obj[alias], targetModel.primaryKey, obj[alias]); - } - - const loader = strapi.plugins.graphql.services['data-loaders'].loaders[targetModel.uid]; - - const localId = obj[model.primaryKey]; - const targetPK = targetModel.primaryKey; - const foreignId = _.get(obj[alias], targetModel.primaryKey, obj[alias]); - - const params = { - ...initQueryOptions(targetModel, obj), - ...convertToParams(_.omit(amountLimiting(options), 'where')), - ...convertToQuery(options.where), - }; - - if (['oneToOne', 'oneWay', 'manyToOne'].includes(nature)) { - if (!_.has(obj, alias) || _.isNil(foreignId)) { - return null; - } - - // check this is an entity and not a mongo ID - if (_.has(obj[alias], targetPK)) { - return assignOptions(obj[alias], obj); - } - - const query = { - single: true, - filters: { - ...params, - [targetPK]: foreignId, - }, - }; - - return loader.load(query).then(r => assignOptions(r, obj)); - } - - if ( - nature === 'oneToMany' || - (nature === 'manyToMany' && association.dominant !== true) - ) { - const { via } = association; - - const filters = { - ...params, - [via]: localId, - }; - - return loader.load({ filters }).then(r => assignOptions(r, obj)); - } - - if ( - nature === 'manyWay' || - (nature === 'manyToMany' && association.dominant === true) - ) { - let targetIds = []; - - // find the related ids to query them and apply the filters - if (Array.isArray(obj[alias])) { - targetIds = obj[alias].map(value => value[targetPK] || value); - } else { - const entry = await strapi - .query(model.uid) - .findOne({ [primaryKey]: obj[primaryKey] }, [alias]); - - if (_.isEmpty(entry[alias])) { - return []; - } - - targetIds = entry[alias].map(el => el[targetPK]); - } - - const filters = { - ...params, - [`${targetPK}_in`]: targetIds.map(_.toString), - }; - - return loader.load({ filters }).then(r => assignOptions(r, obj)); - } - }; - break; - } - } - - return resolver; - }, {}); -}; - -/** - * Construct the GraphQL query & definition and apply the right resolvers. - * - * @return Object - */ -const buildModels = (models, ctx) => { - return models.map(model => { - const { kind, modelType } = model; - - if (modelType === 'component') { - return buildComponent(model); - } - - switch (kind) { - case 'singleType': - return buildSingleType(model, ctx); - default: - return buildCollectionType(model, ctx); - } - }); -}; - -const buildModelDefinition = (model, globalType = {}) => { - const { globalId, primaryKey } = model; - - const typeDefObj = buildTypeDefObj(model); - - const schema = { - definition: '', - query: {}, - mutation: {}, - resolvers: { - Query: {}, - Mutation: {}, - [globalId]: { - id: parent => parent[primaryKey] || parent.id, - ...buildAssocResolvers(model), - }, - }, - typeDefObj, - }; - - schema.definition += generateEnumDefinitions(model, globalId); - generateDynamicZoneDefinitions(model.attributes, globalId, schema); - - const description = getTypeDescription(globalType, model); - const fields = toSDL(typeDefObj, globalType, model); - const typeDef = `${description}type ${globalId} {${fields}}\n`; - - schema.definition += typeDef; - return schema; -}; - -const buildComponent = component => { - const { globalId } = component; - const schema = buildModelDefinition(component); - - schema.definition += types.generateInputModel(component, globalId, { - allowIds: true, - }); - - return schema; -}; - -const buildSingleType = (model, ctx) => { - const { uid, modelName } = model; - - const singularName = toSingular(modelName); - - const globalType = _.get(ctx.schema, `type.${model.globalId}`, {}); - - const localSchema = buildModelDefinition(model, globalType); - - // Add definition to the schema but this type won't be "queriable" or "mutable". - if (globalType === false) { - return localSchema; - } - - if (isQueryEnabled(ctx.schema, singularName)) { - const resolverOpts = { - resolver: `${uid}.find`, - ...getQueryInfo(ctx.schema, singularName), - }; - - const resolver = buildQuery(singularName, resolverOpts); - - const query = { - query: { - [singularName]: { - args: { - publicationState: 'PublicationState', - ...(resolverOpts.args || {}), - }, - type: model.globalId, - }, - }, - resolvers: { - Query: { - [singularName]: wrapPublicationStateResolver(resolver), - }, - }, - }; - - _.merge(localSchema, query); - } - - // Add model Input definition. - localSchema.definition += types.generateInputModel(model, modelName); - - // build every mutation - ['update', 'delete'].forEach(action => { - const mutationSchema = buildMutationTypeDef({ model, action }, ctx); - - mergeSchemas(localSchema, mutationSchema); - }); - - return localSchema; -}; - -const buildCollectionType = (model, ctx) => { - const { plugin, modelName, uid } = model; - - const singularName = toSingular(modelName); - const pluralName = toPlural(modelName); - - const globalType = _.get(ctx.schema, `type.${model.globalId}`, {}); - - const localSchema = buildModelDefinition(model, globalType); - const { typeDefObj } = localSchema; - - // Add definition to the schema but this type won't be "queriable" or "mutable". - if (globalType === false) { - return localSchema; - } - - if (isQueryEnabled(ctx.schema, singularName)) { - const resolverOpts = { - resolver: `${uid}.findOne`, - ...getQueryInfo(ctx.schema, singularName), - }; - - if (actionExists(resolverOpts)) { - const resolver = buildQuery(singularName, resolverOpts); - - const query = { - query: { - [singularName]: { - args: { - ...FIND_ONE_QUERY_ARGUMENTS, - ...(resolverOpts.args || {}), - }, - type: model.globalId, - }, - }, - resolvers: { - Query: { - [singularName]: wrapPublicationStateResolver(resolver), - }, - }, - }; - - _.merge(localSchema, query); - } - } - - if (isQueryEnabled(ctx.schema, pluralName)) { - const resolverOpts = { - resolver: `${uid}.find`, - ...getQueryInfo(ctx.schema, pluralName), - }; - - if (actionExists(resolverOpts)) { - const resolver = buildQuery(pluralName, resolverOpts); - - const query = { - query: { - [pluralName]: { - args: { - ...FIND_QUERY_ARGUMENTS, - ...(resolverOpts.args || {}), - }, - type: `[${model.globalId}]`, - }, - }, - resolvers: { - Query: { - [pluralName]: wrapPublicationStateResolver(resolver), - }, - }, - }; - - _.merge(localSchema, query); - - if (isQueryEnabled(ctx.schema, `${pluralName}Connection`)) { - // Generate the aggregation for the given model - const aggregationSchema = formatModelConnectionsGQL({ - fields: typeDefObj, - model, - name: modelName, - resolver: resolverOpts, - plugin, - }); - - mergeSchemas(localSchema, aggregationSchema); - } - } - } - - // Add model Input definition. - localSchema.definition += types.generateInputModel(model, modelName); - - // build every mutation - ['create', 'update', 'delete'].forEach(action => { - const mutationSchema = buildMutationTypeDef({ model, action }, ctx); - mergeSchemas(localSchema, mutationSchema); - }); - - return localSchema; -}; - -// TODO: -// - Implement batch methods (need to update the content-manager as well). -// - Implement nested transactional methods (create/update). -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 }), - ...getMutationInfo(ctx.schema, mutationName), - isShadowCrud: true, - }; - - if (!actionExists(resolverOpts)) { - return {}; - } - - const definition = types.generateInputPayloadArguments({ - model, - name: model.modelName, - mutationName, - action, - }); - - // ignore if disabled - if (!isMutationEnabled(ctx.schema, mutationName)) { - return { - definition, - }; - } - - const { kind } = model; - - const args = {}; - - if (kind !== 'singleType' || action !== 'delete') { - Object.assign(args, { - input: `${mutationName}Input`, - }); - } - - return { - definition, - mutation: { - [mutationName]: { - args: { - ...args, - ...(resolverOpts.args || {}), - }, - type: `${mutationName}Payload`, - }, - }, - resolvers: { - Mutation: { - [mutationName]: buildMutation(mutationName, resolverOpts), - }, - }, - }; -}; - -module.exports = buildShadowCrud; diff --git a/packages/plugins/graphql/services/type-builder.js b/packages/plugins/graphql/services/type-builder.js deleted file mode 100644 index 3e60721aa4..0000000000 --- a/packages/plugins/graphql/services/type-builder.js +++ /dev/null @@ -1,311 +0,0 @@ -'use strict'; - -/** - * Types.js service - * - * @description: A set of functions to make the schema easier to build. - */ - -const _ = require('lodash'); -const { GraphQLUpload } = require('graphql-upload'); -const graphql = require('graphql'); -const { GraphQLJSON } = require('graphql-type-json'); -const { GraphQLDate, GraphQLDateTime } = require('graphql-iso-date'); -const GraphQLLong = require('graphql-type-long'); - -const Time = require('../types/time'); -const { toSingular, toInputName } = require('./naming'); - -const isScalarAttribute = ({ type }) => type && !['component', 'dynamiczone'].includes(type); -const isTypeAttributeEnabled = (model, attr) => - _.get(strapi.plugins.graphql, `config._schema.graphql.type.${model.globalId}.${attr}`) !== false; - -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({ - attribute = {}, - modelName = '', - attributeName = '', - rootType = 'query', - action = '', - }) { - // Type - if (isScalarAttribute(attribute)) { - let type = 'String'; - - switch (attribute.type) { - case 'boolean': - type = 'Boolean'; - break; - case 'integer': - type = 'Int'; - break; - case 'biginteger': - type = 'Long'; - break; - case 'float': - case 'decimal': - type = 'Float'; - break; - case 'json': - type = 'JSON'; - break; - case 'date': - type = 'Date'; - break; - case 'time': - type = 'Time'; - break; - case 'datetime': - case 'timestamp': - type = 'DateTime'; - break; - case 'enumeration': - type = this.convertEnumType(attribute, modelName, attributeName); - break; - } - - if (attribute.required) { - if (rootType !== 'mutation' || (action !== 'update' && attribute.default === undefined)) { - type += '!'; - } - } - - return type; - } - - if (attribute.type === 'component') { - const { required, repeatable, component } = attribute; - - const globalId = strapi.components[component].globalId; - - let typeName = required === true ? `${globalId}` : globalId; - - if (rootType === 'mutation') { - typeName = - action === 'update' - ? `edit${_.upperFirst(toSingular(globalId))}Input` - : `${_.upperFirst(toSingular(globalId))}Input${required ? '!' : ''}`; - } - - if (repeatable === true) { - return `[${typeName}]`; - } - return `${typeName}`; - } - - if (attribute.type === 'dynamiczone') { - const { required } = attribute; - - const unionName = `${modelName}${_.upperFirst(_.camelCase(attributeName))}DynamicZone`; - - let typeName = unionName; - - if (rootType === 'mutation') { - typeName = `${unionName}Input!`; - } - - return `[${typeName}]${required ? '!' : ''}`; - } - - const ref = attribute.target; - - // Association - if (ref && ref !== '*') { - // Add bracket or not - const globalId = strapi.getModel(ref).globalId; - const plural = !_.isEmpty(attribute.collection); - - if (plural) { - if (rootType === 'mutation') { - return '[ID]'; - } - - return `[${globalId}]`; - } - - if (rootType === 'mutation') { - return 'ID'; - } - - return globalId; - } - - if (rootType === 'mutation') { - return attribute.model ? 'ID' : '[ID]'; - } - - return attribute.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) { - return definition.enumName - ? definition.enumName - : `ENUM_${model.toUpperCase()}_${field.toUpperCase()}`; - }, - - /** - * Add custom scalar type such as JSON. - * - * @return void - */ - - getScalars() { - return { - JSON: GraphQLJSON, - DateTime: GraphQLDateTime, - Time, - Date: GraphQLDate, - Long: GraphQLLong, - Upload: GraphQLUpload, - }; - }, - - /** - * Add Union Type that contains the types defined by the user. - * - * @return string - */ - - addPolymorphicUnionType(definition) { - const types = graphql - .parse(definition) - .definitions.filter(def => def.kind === 'ObjectTypeDefinition' && def.name.value !== 'Query') - .map(def => def.name.value); - - if (types.length > 0) { - return { - definition: `union Morph = ${types.join(' | ')}`, - resolvers: { - Morph: { - __resolveType(obj) { - return obj.kind || obj.__contentType || null; - }, - }, - }, - }; - } - - return { - definition: '', - resolvers: {}, - }; - }, - - addInput() { - return ` - input InputID { id: ID!} - `; - }, - - generateInputModel(model, name, { allowIds = false } = {}) { - const globalId = model.globalId; - const inputName = `${_.upperFirst(toSingular(name))}Input`; - const hasAllAttributesDisabled = Object.keys(model.attributes).every( - attr => !isTypeAttributeEnabled(model, attr) - ); - - if (_.isEmpty(model.attributes) || hasAllAttributesDisabled) { - return ` - input ${inputName} { - _: String - } - - input edit${inputName} { - ${allowIds ? 'id: ID' : '_: String'} - } - `; - } - - const inputs = ` - input ${inputName} { - - ${Object.keys(model.attributes) - .filter(attributeName => isTypeAttributeEnabled(model, attributeName)) - .map(attributeName => { - return `${attributeName}: ${this.convertType({ - attribute: model.attributes[attributeName], - modelName: globalId, - attributeName, - rootType: 'mutation', - })}`; - }) - .join('\n')} - } - - input edit${inputName} { - ${allowIds ? 'id: ID' : ''} - ${Object.keys(model.attributes) - .filter(attributeName => isTypeAttributeEnabled(model, attributeName)) - .map(attributeName => { - return `${attributeName}: ${this.convertType({ - attribute: model.attributes[attributeName], - modelName: globalId, - attributeName, - rootType: 'mutation', - action: 'update', - })}`; - }) - .join('\n')} - } - `; - - return inputs; - }, - - generateInputPayloadArguments({ model, name, mutationName, action }) { - const singularName = toSingular(name); - const inputName = toInputName(name); - - const { kind } = model; - - switch (action) { - case 'create': - return ` - input ${mutationName}Input { data: ${inputName} } - type ${mutationName}Payload { ${singularName}: ${model.globalId} } - `; - case 'update': - if (kind === 'singleType') { - return ` - input ${mutationName}Input { data: edit${inputName} } - type ${mutationName}Payload { ${singularName}: ${model.globalId} } - `; - } - - return ` - input ${mutationName}Input { where: InputID, data: edit${inputName} } - type ${mutationName}Payload { ${singularName}: ${model.globalId} } - `; - case 'delete': - if (kind === 'singleType') { - return ` - type ${mutationName}Payload { ${singularName}: ${model.globalId} } - `; - } - - return ` - input ${mutationName}Input { where: InputID } - type ${mutationName}Payload { ${singularName}: ${model.globalId} } - `; - default: - // Nothing - } - }, -}; diff --git a/packages/plugins/graphql/services/utils.js b/packages/plugins/graphql/services/utils.js deleted file mode 100644 index c719d6f7c7..0000000000 --- a/packages/plugins/graphql/services/utils.js +++ /dev/null @@ -1,200 +0,0 @@ -'use strict'; - -const _ = require('lodash'); -const { QUERY_OPERATORS } = require('@strapi/utils'); - -/** - * @typedef {object} Schema - * @property {object} resolvers - * @property {object} mutation - * @property {object} query - * @property {string} definition - */ - -/** - * 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; - - object.definition += '\n' + definition; - _.merge(object, { - query, - mutation, - resolvers, - }); - }); - - return object; -}; - -/** - * Returns an empty schema - * @returns {Schema} - */ -const createDefaultSchema = () => ({ - definition: '', - query: {}, - mutation: {}, - resolvers: {}, -}); - -const diffResolvers = (object, base) => { - let newObj = {}; - - Object.keys(object).forEach(type => { - Object.keys(object[type]).forEach(resolver => { - if (type === 'Query' || type === 'Mutation') { - if (!_.has(base, [type, resolver])) { - _.set(newObj, [type, resolver], _.get(object, [type, resolver])); - } - } else { - _.set(newObj, [type, resolver], _.get(object, [type, resolver])); - } - }); - }); - - return newObj; -}; - -const convertToParams = params => { - return Object.keys(params).reduce((acc, current) => { - const key = current === 'id' ? 'id' : `_${current}`; - acc[key] = params[current]; - return acc; - }, {}); -}; - -const convertToQuery = params => { - const result = {}; - - _.forEach(params, (value, key) => { - if (QUERY_OPERATORS.includes(key)) { - result[key] = _.isArray(value) ? value.map(convertToQuery) : convertToQuery(value); - } else if (_.isPlainObject(value)) { - const flatObject = convertToQuery(value); - _.forEach(flatObject, (_value, _key) => { - result[`${key}.${_key}`] = _value; - }); - } else { - result[key] = value; - } - }); - - return result; -}; - -const amountLimiting = (params = {}) => { - const { amountLimit } = strapi.plugins.graphql.config; - - if (!amountLimit) return params; - - if (_.isNil(params.limit) || params.limit === -1 || params.limit > amountLimit) { - params.limit = amountLimit; - } else if (params.limit < 0) { - params.limit = 0; - } - - return params; -}; - -const nonRequired = type => type.replace('!', ''); - -const actionExists = ({ resolver, resolverOf }) => { - if (isResolvablePath(resolverOf)) { - return true; - } else if (_.isFunction(resolver)) { - return true; - } else if (_.isString(resolver)) { - return _.isFunction(getActionFn(getActionDetails(resolver))); - } else { - throw new Error( - `Error building query. Expected \`resolver\` as string or a function, or \`resolverOf\` as a string. got ${{ - resolver, - resolverOf, - }}` - ); - } -}; - -const getAction = resolver => { - if (!_.isString(resolver)) { - throw new Error(`Error building query. Expected a string, got ${resolver}`); - } - - const actionDetails = getActionDetails(resolver); - const actionFn = getActionFn(actionDetails); - - if (!actionFn) { - throw new Error( - `[GraphQL] Cannot find action "${resolver}". Check your graphql configurations.` - ); - } - - return actionFn; -}; - -const getActionFn = details => { - const { controller, action, plugin, api } = details; - - if (plugin) { - return _.get(strapi.plugins, [_.toLower(plugin), 'controllers', _.toLower(controller), action]); - } - - return _.get(strapi.api, [_.toLower(api), 'controllers', _.toLower(controller), action]); -}; - -const getActionDetails = resolver => { - if (resolver.startsWith('plugin::')) { - const [, path] = resolver.split('::'); - const [plugin, controller, action] = path.split('.'); - - return { plugin, controller, action }; - } - - if (resolver.startsWith('api::')) { - const [, path] = resolver.split('::'); - const [api, controller, action] = path.split('.'); - - return { api, controller, action }; - } - - const args = resolver.split('.'); - - if (args.length === 3) { - const [api, controller, action] = args; - return { api, controller, action }; - } - - // if direct api access - if (args.length === 2) { - const [controller, action] = args; - return { api: controller, controller, action }; - } - - throw new Error( - `[GraphQL] Could not find action for resolver "${resolver}". Check your graphql configurations.` - ); -}; - -const isResolvablePath = path => _.isString(path) && !_.isEmpty(path); - -module.exports = { - diffResolvers, - mergeSchemas, - createDefaultSchema, - convertToParams, - convertToQuery, - amountLimiting, - nonRequired, - actionExists, - getAction, - getActionDetails, - getActionFn, - isResolvablePath, -}; diff --git a/packages/plugins/graphql/strapi-server.js b/packages/plugins/graphql/strapi-server.js index d268986501..a52ffb5827 100644 --- a/packages/plugins/graphql/strapi-server.js +++ b/packages/plugins/graphql/strapi-server.js @@ -1,15 +1,11 @@ 'use strict'; +const bootstrap = require('./server/bootstrap'); +const services = require('./server/services'); + module.exports = (/* strapi, config */) => { return { - bootstrap() {}, - destroy() {}, - config: {}, - routes: [], - controllers: {}, - services() {}, - policies: {}, - middlewares: {}, - contentTypes: {}, + bootstrap, + services, }; }; diff --git a/packages/plugins/graphql/tests/graphql-connection.test.e2e.js b/packages/plugins/graphql/tests/graphql-connection.test.e2e.js deleted file mode 100644 index 3737196862..0000000000 --- a/packages/plugins/graphql/tests/graphql-connection.test.e2e.js +++ /dev/null @@ -1,137 +0,0 @@ -'use strict'; - -// Helpers. -const { createTestBuilder } = require('../../../../test/helpers/builder'); -const { createStrapiInstance } = require('../../../../test/helpers/strapi'); -const { createAuthRequest } = require('../../../../test/helpers/request'); - -const builder = createTestBuilder(); -let strapi; -let rq; -let graphqlQuery; - -const postModel = { - attributes: { - name: { - type: 'string', - }, - rating: { - type: 'integer', - }, - }, - name: 'post', - description: '', - collectionName: '', -}; - -const postFixtures = [ - { - name: 'post 1', - rating: 4, - }, - { - name: 'post 2', - rating: 3, - }, - { - name: 'post 3', - rating: 3, - }, - { - name: 'post 4', - rating: 4, - }, -]; - -describe('Test Graphql Connection', () => { - beforeAll(async () => { - await builder - .addContentType(postModel) - .addFixtures(postModel.name, postFixtures) - .build(); - - strapi = await createStrapiInstance(); - rq = await createAuthRequest({ strapi }); - - graphqlQuery = body => { - return rq({ - url: '/graphql', - method: 'POST', - body, - }); - }; - }); - - afterAll(async () => { - await strapi.destroy(); - await builder.cleanup(); - }); - - describe('Test values connection', () => { - test('List posts', async () => { - const res = await graphqlQuery({ - query: /* GraphQL */ ` - { - postsConnection { - values { - name - rating - } - } - } - `, - }); - - expect(res.statusCode).toBe(200); - expect(res.body.data.postsConnection.values.length).toBe(postFixtures.length); - expect(res.body.data.postsConnection.values).toEqual(expect.arrayContaining(postFixtures)); - }); - - test('List posts with limit', async () => { - const res = await graphqlQuery({ - query: /* GraphQL */ ` - { - postsConnection(limit: 1) { - values { - name - rating - } - } - } - `, - }); - - expect(res.statusCode).toBe(200); - expect(res.body.data.postsConnection.values.length).toBe(1); - }); - }); - - describe('Test groupBy', () => { - test('Groupby simple query', async () => { - const res = await graphqlQuery({ - query: /* GraphQL */ ` - { - postsConnection { - groupBy { - rating { - key - } - } - } - } - `, - }); - - expect(res.statusCode).toBe(200); - expect(res.body.data.postsConnection.groupBy.rating.length).toBe(2); - expect(res.body.data.postsConnection.groupBy.rating).toEqual( - expect.arrayContaining([ - { - key: 3, - }, - { key: 4 }, - ]) - ); - }); - }); -}); diff --git a/packages/plugins/graphql/tests/graphql-crud.test.e2e.js b/packages/plugins/graphql/tests/graphql-crud.test.e2e.js index 0f00622d10..97fab9e5f9 100644 --- a/packages/plugins/graphql/tests/graphql-crud.test.e2e.js +++ b/packages/plugins/graphql/tests/graphql-crud.test.e2e.js @@ -1,5 +1,7 @@ 'use strict'; +const { omit, prop } = require('lodash/fp'); + // Helpers. const { createTestBuilder } = require('../../../../test/helpers/builder'); const { createStrapiInstance } = require('../../../../test/helpers/strapi'); @@ -60,20 +62,20 @@ describe('Test Graphql API End to End', () => { test.each(postsPayload)('Create Post %o', async post => { const res = await graphqlQuery({ query: /* GraphQL */ ` - mutation createPost($input: createPostInput) { - createPost(input: $input) { - post { - name - bigint - nullable + mutation createPost($data: PostInput!) { + createPost(data: $data) { + data { + attributes { + name + bigint + nullable + } } } } `, variables: { - input: { - data: post, - }, + data: post, }, }); @@ -83,7 +85,9 @@ describe('Test Graphql API End to End', () => { expect(body).toEqual({ data: { createPost: { - post, + data: { + attributes: post, + }, }, }, }); @@ -94,10 +98,14 @@ describe('Test Graphql API End to End', () => { query: /* GraphQL */ ` { posts { - id - name - bigint - nullable + data { + id + attributes { + name + bigint + nullable + } + } } } `, @@ -108,32 +116,50 @@ describe('Test Graphql API End to End', () => { expect(res.statusCode).toBe(200); expect(body).toMatchObject({ data: { - posts: postsPayload, + posts: { + data: postsPayload.map(entry => ({ + id: expect.any(String), + attributes: omit('id', entry), + })), + }, }, }); // assign for later use - data.posts = res.body.data.posts; + data.posts = res.body.data.posts.data.map(({ id, attributes }) => ({ id, ...attributes })); }); test('List posts with limit', async () => { const res = await graphqlQuery({ query: /* GraphQL */ ` { - posts(limit: 1) { - id - name - bigint - nullable + posts(pagination: { limit: 1 }) { + data { + id + attributes { + name + bigint + nullable + } + } } } `, }); + const expectedPost = data.posts[0]; + expect(res.statusCode).toBe(200); expect(res.body).toEqual({ data: { - posts: [data.posts[0]], + posts: { + data: [ + { + id: expectedPost.id, + attributes: omit('id', expectedPost), + }, + ], + }, }, }); }); @@ -143,19 +169,30 @@ describe('Test Graphql API End to End', () => { query: /* GraphQL */ ` { posts(sort: "name:desc") { - id - name - bigint - nullable + data { + id + attributes { + name + bigint + nullable + } + } } } `, }); + const expectedPosts = [...data.posts].reverse().map(entry => ({ + id: expect.any(String), + attributes: omit('id', entry), + })); + expect(res.statusCode).toBe(200); expect(res.body).toEqual({ data: { - posts: [...data.posts].reverse(), + posts: { + data: expectedPosts, + }, }, }); }); @@ -164,20 +201,33 @@ describe('Test Graphql API End to End', () => { const res = await graphqlQuery({ query: /* GraphQL */ ` { - posts(start: 1) { - id - name - bigint - nullable + posts(pagination: { start: 1 }) { + data { + id + attributes { + name + bigint + nullable + } + } } } `, }); + const expectedPost = data.posts[1]; + expect(res.statusCode).toBe(200); expect(res.body).toEqual({ data: { - posts: [data.posts[1]], + posts: { + data: [ + { + id: expectedPost.id, + attributes: omit('id', expectedPost), + }, + ], + }, }, }); }); @@ -187,15 +237,13 @@ describe('Test Graphql API End to End', () => { query: /* GraphQL */ ` { posts(start: 1) { - id - name - bigint - nullable - createdBy { - username - } - updatedBy { - username + data { + id + attributes { + name + bigint + nullable + } } } } @@ -214,145 +262,116 @@ describe('Test Graphql API End to End', () => { test.each([ [ { - name: 'post 1', - bigint: 1316130638171, + name: { eq: 'post 1' }, + bigint: { eq: 1316130638171 }, }, [postsPayload[0]], ], [ { - name_eq: 'post 1', - bigint_eq: 1316130638171, - }, - [postsPayload[0]], - ], - [ - { - name_ne: 'post 1', - bigint_ne: 1316130638171, + name: { not: { eq: 'post 1' } }, + bigint: { not: { eq: 1316130638171 } }, }, [postsPayload[1]], ], [ { - name_contains: 'Post', + name: { contains: 'post' }, }, postsPayload, ], [ { - name_contains: 'Post 1', + name: { contains: 'post 1' }, }, [postsPayload[0]], ], [ { - name_containss: 'post', + name: { containsi: 'Post' }, }, postsPayload, ], [ { - name_ncontainss: 'post 1', + name: { not: { containsi: 'Post 1' } }, }, [postsPayload[1]], ], [ { - name_in: ['post 1', 'post 2', 'post 3'], + name: { in: ['post 1', 'post 2', 'post 3'] }, }, postsPayload, ], [ { - name_nin: ['post 2'], + name: { not: { in: ['post 2'] } }, }, [postsPayload[0]], ], [ { - nullable_null: true, - }, - [postsPayload[1]], - ], - [ - { - nullable_null: false, - }, - [postsPayload[0]], - ], - [ - { - _or: [{ name_in: ['post 2'] }, { bigint_eq: 1316130638171 }], + or: [{ name: { in: ['post 2'] } }, { bigint: { eq: 1316130638171 } }], }, [postsPayload[0], postsPayload[1]], ], [ { - _where: { nullable_null: false }, - }, - [postsPayload[0]], - ], - [ - { - _where: { _or: { nullable_null: false } }, - }, - [postsPayload[0]], - ], - [ - { - _where: [{ nullable_null: false }], - }, - [postsPayload[0]], - ], - [ - { - _where: [{ _or: [{ name_in: ['post 2'] }, { bigint_eq: 1316130638171 }] }], + and: [{ or: [{ name: { in: ['post 2'] } }, { bigint: { eq: 1316130638171 } }] }], }, [postsPayload[0], postsPayload[1]], ], [ { - _where: [ + and: [ { - _or: [ - { name_in: ['post 2'] }, - { _or: [{ bigint_eq: 1316130638171 }, { nullable_null: false }] }, + or: [ + { name: { in: ['post 2'] } }, + { or: [{ bigint: { eq: 1316130638171 } }, { nullable: { not: { null: true } } }] }, ], }, ], }, [postsPayload[0], postsPayload[1]], ], - ])('List posts with where clause %o', async (where, expected) => { + ])('List posts with filters clause %o', async (filters, expected) => { const res = await graphqlQuery({ query: /* GraphQL */ ` - query findPosts($where: JSON) { - posts(where: $where) { - name - bigint - nullable + query findPosts($filters: PostFiltersInput) { + posts(filters: $filters) { + data { + attributes { + name + bigint + nullable + } + } } } `, variables: { - where, + filters, }, }); expect(res.statusCode).toBe(200); + const { data: posts } = res.body.data.posts; + // same length - expect(res.body.data.posts.length).toBe(expected.length); + expect(posts.length).toBe(expected.length); // all the posts returned are in the expected array - res.body.data.posts.forEach(post => { - expect(expected).toEqual(expect.arrayContaining([post])); + posts.map(prop('attributes')).forEach(post => { + expect(expected.map(omit('id'))).toEqual(expect.arrayContaining([post])); }); // all expected values are in the result expected.forEach(expectedPost => { - expect(res.body.data.posts).toEqual(expect.arrayContaining([expectedPost])); + expect(posts.map(prop('attributes'))).toEqual( + expect.arrayContaining([omit('id', expectedPost)]) + ); }); }); @@ -361,10 +380,14 @@ describe('Test Graphql API End to End', () => { query: /* GraphQL */ ` query getPost($id: ID!) { post(id: $id) { - id - name - bigint - nullable + data { + id + attributes { + name + bigint + nullable + } + } } } `, @@ -376,7 +399,12 @@ describe('Test Graphql API End to End', () => { expect(res.statusCode).toBe(200); expect(res.body).toEqual({ data: { - post: data.posts[0], + post: { + data: { + id: data.posts[0].id, + attributes: omit('id', data.posts[0]), + }, + }, }, }); }); @@ -385,23 +413,21 @@ describe('Test Graphql API End to End', () => { const newName = 'new post name'; const res = await graphqlQuery({ query: /* GraphQL */ ` - mutation updatePost($input: updatePostInput) { - updatePost(input: $input) { - post { + mutation updatePost($id: ID!, $data: PostInput!) { + updatePost(id: $id, data: $data) { + data { id - name + attributes { + name + } } } } `, variables: { - input: { - where: { - id: data.posts[0].id, - }, - data: { - name: newName, - }, + id: data.posts[0].id, + data: { + name: newName, }, }, }); @@ -410,37 +436,43 @@ describe('Test Graphql API End to End', () => { expect(res.body).toEqual({ data: { updatePost: { - post: { + data: { id: data.posts[0].id, - name: newName, + attributes: { + name: newName, + }, }, }, }, }); - data.posts[0] = res.body.data.updatePost.post; + const newPost = res.body.data.updatePost.data; + + data.posts[0] = { + id: newPost.id, + ...newPost.attributes, + }; }); test('Delete Posts', async () => { for (let post of data.posts) { const res = await graphqlQuery({ query: /* GraphQL */ ` - mutation deletePost($input: deletePostInput) { - deletePost(input: $input) { - post { + mutation deletePost($id: ID!) { + deletePost(id: $id) { + data { id - name - bigint + attributes { + name + nullable + bigint + } } } } `, variables: { - input: { - where: { - id: post.id, - }, - }, + id: post.id, }, }); @@ -448,8 +480,9 @@ describe('Test Graphql API End to End', () => { expect(res.body).toMatchObject({ data: { deletePost: { - post: { + data: { id: post.id, + attributes: omit('id', post), }, }, }, diff --git a/packages/plugins/graphql/tests/graphql-relations.test.e2e.js b/packages/plugins/graphql/tests/graphql-relations.test.e2e.js index 52f6259115..0b09864c13 100644 --- a/packages/plugins/graphql/tests/graphql-relations.test.e2e.js +++ b/packages/plugins/graphql/tests/graphql-relations.test.e2e.js @@ -1,7 +1,7 @@ 'use strict'; // Helpers. -const _ = require('lodash'); +const { pick } = require('lodash/fp'); const { createTestBuilder } = require('../../../../test/helpers/builder'); const { createStrapiInstance } = require('../../../../test/helpers/strapi'); const { createAuthRequest } = require('../../../../test/helpers/request'); @@ -11,8 +11,8 @@ let strapi; let rq; let graphqlQuery; -// utils -const selectFields = doc => _.pick(doc, ['id', 'name', 'color']); +// Utils +const selectFields = pick(['name', 'color']); const rgbColorComponent = { attributes: { @@ -142,77 +142,10 @@ describe('Test Graphql Relations API End to End', () => { test.each(labelsPayload)('Create label %o', async label => { const res = await graphqlQuery({ query: /* GraphQL */ ` - mutation createLabel($input: createLabelInput) { - createLabel(input: $input) { - label { - name - color { - name - red - green - blue - } - } - } - } - `, - variables: { - input: { - data: label, - }, - }, - }); - - expect(res.statusCode).toBe(200); - expect(res.body).toEqual({ - data: { - createLabel: { - label, - }, - }, - }); - }); - - test('List labels', async () => { - const res = await graphqlQuery({ - query: /* GraphQL */ ` - { - labels { - id - name - color { - name - red - green - blue - } - } - } - `, - }); - - const { body } = res; - - expect(res.statusCode).toBe(200); - expect(body).toMatchObject({ - data: { - labels: labelsPayload, - }, - }); - - // assign for later use - data.labels = data.labels.concat(res.body.data.labels); - }); - - test.each(documentsPayload)('Create document linked to every labels %o', async document => { - const res = await graphqlQuery({ - query: /* GraphQL */ ` - mutation createDocument($input: createDocumentInput) { - createDocument(input: $input) { - document { - name - labels { - id + mutation createLabel($data: LabelInput!) { + createLabel(data: $data) { + data { + attributes { name color { name @@ -226,14 +159,94 @@ describe('Test Graphql Relations API End to End', () => { } `, variables: { - input: { + data: label, + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + data: { + createLabel: { data: { - ...document, - labels: data.labels.map(t => t.id), + attributes: label, }, }, }, }); + }); + + test('List labels', async () => { + const res = await graphqlQuery({ + query: /* GraphQL */ ` + { + labels { + data { + id + attributes { + name + color { + name + red + green + blue + } + } + } + } + } + `, + }); + + const { body } = res; + + expect(res.statusCode).toBe(200); + expect(body).toMatchObject({ + data: { + labels: { + data: labelsPayload.map(label => ({ id: expect.any(String), attributes: label })), + }, + }, + }); + + // assign for later use + data.labels = data.labels.concat(res.body.data.labels.data); + }); + + test.each(documentsPayload)('Create document linked to every labels %o', async document => { + const res = await graphqlQuery({ + query: /* GraphQL */ ` + mutation createDocument($data: DocumentInput!) { + createDocument(data: $data) { + data { + id + attributes { + name + labels { + data { + id + attributes { + name + color { + name + red + green + blue + } + } + } + } + } + } + } + } + `, + variables: { + data: { + ...document, + labels: data.labels.map(t => t.id), + }, + }, + }); const { body } = res; @@ -242,13 +255,20 @@ describe('Test Graphql Relations API End to End', () => { expect(body).toMatchObject({ data: { createDocument: { - document: { - ...selectFields(document), - labels: expect.arrayContaining(data.labels.map(selectFields)), + data: { + id: expect.any(String), + attributes: { + ...selectFields(document), + labels: { + data: expect.arrayContaining(data.labels), + }, + }, }, }, }, }); + + data.documents.push(body.data.createDocument.data); }); test('List documents with labels', async () => { @@ -256,16 +276,24 @@ describe('Test Graphql Relations API End to End', () => { query: /* GraphQL */ ` { documents { - id - name - labels { + data { id - name - color { + attributes { name - red - green - blue + labels { + data { + id + attributes { + name + color { + name + red + green + blue + } + } + } + } } } } @@ -278,17 +306,14 @@ describe('Test Graphql Relations API End to End', () => { expect(res.statusCode).toBe(200); expect(body).toMatchObject({ data: { - documents: expect.arrayContaining( - data.documents.map(document => ({ - ...selectFields(document), - labels: expect.arrayContaining(data.labels.map(selectFields)), - })) - ), + documents: { + data: expect.arrayContaining(data.documents), + }, }, }); // assign for later use - data.documents = res.body.data.documents; + data.documents = res.body.data.documents.data; }); test('List Labels with documents', async () => { @@ -296,17 +321,25 @@ describe('Test Graphql Relations API End to End', () => { query: /* GraphQL */ ` { labels { - id - name - color { - name - red - green - blue - } - documents { + data { id - name + attributes { + name + color { + name + red + green + blue + } + documents { + data { + id + attributes { + name + } + } + } + } } } } @@ -318,34 +351,54 @@ describe('Test Graphql Relations API End to End', () => { expect(res.statusCode).toBe(200); expect(body).toMatchObject({ data: { - labels: expect.arrayContaining( - data.labels.map(label => ({ - ...selectFields(label), - documents: expect.arrayContaining(data.documents.map(selectFields)), - })) - ), + labels: { + data: expect.arrayContaining( + data.labels.map(label => ({ + id: label.id, + attributes: { + ...label.attributes, + documents: { + data: expect.arrayContaining( + data.documents.map(document => ({ + id: document.id, + attributes: selectFields(document.attributes), + })) + ), + }, + }, + })) + ), + }, }, }); // assign for later use - data.labels = res.body.data.labels; + data.labels = res.body.data.labels.data; }); test('Deep query', async () => { const res = await graphqlQuery({ query: /* GraphQL */ ` { - documents(where: { labels: { name_contains: "label 1" } }) { - id - name - labels { + documents(filters: { labels: { name: { contains: "label 1" } } }) { + data { id - name - color { + attributes { name - red - green - blue + labels { + data { + id + attributes { + name + color { + name + red + green + blue + } + } + } + } } } } @@ -356,28 +409,39 @@ describe('Test Graphql Relations API End to End', () => { expect(res.statusCode).toBe(200); expect(res.body).toMatchObject({ data: { - documents: expect.arrayContaining(data.documents), + documents: { + data: expect.arrayContaining(data.documents), + }, }, }); }); test('Update Document relations removes correctly a relation', async () => { + const document = data.documents[0]; + const labels = [data.labels[0]]; + // if I remove a label from an document is it working const res = await graphqlQuery({ query: /* GraphQL */ ` - mutation updateDocument($input: updateDocumentInput) { - updateDocument(input: $input) { - document { + mutation updateDocument($id: ID!, $data: DocumentInput!) { + updateDocument(id: $id, data: $data) { + data { id - name - labels { - id + attributes { name - color { - name - red - green - blue + labels { + data { + id + attributes { + name + color { + name + red + green + blue + } + } + } } } } @@ -385,24 +449,30 @@ describe('Test Graphql Relations API End to End', () => { } `, variables: { - input: { - where: { - id: data.documents[0].id, - }, - data: { - labels: [data.labels[0].id], - }, + id: document.id, + data: { + labels: labels.map(label => label.id), }, }, }); - expect(res.statusCode).toBe(200); expect(res.body).toMatchObject({ data: { updateDocument: { - document: { - ...selectFields(data.documents[0]), - labels: [selectFields(data.labels[0])], + data: { + id: document.id, + attributes: { + ...selectFields(document.attributes), + labels: { + data: labels.map(label => ({ + id: label.id, + attributes: { + ...selectFields(label.attributes), + color: null, + }, + })), + }, + }, }, }, }, @@ -413,27 +483,25 @@ describe('Test Graphql Relations API End to End', () => { for (let label of data.labels) { const res = await graphqlQuery({ query: /* GraphQL */ ` - mutation deleteLabel($input: deleteLabelInput) { - deleteLabel(input: $input) { - label { + mutation deleteLabel($id: ID!) { + deleteLabel(id: $id) { + data { id - name - color { + attributes { name - red - green - blue + color { + name + red + green + blue + } } } } } `, variables: { - input: { - where: { - id: label.id, - }, - }, + id: label.id, }, }); @@ -441,8 +509,12 @@ describe('Test Graphql Relations API End to End', () => { expect(res.body).toMatchObject({ data: { deleteLabel: { - label: { + data: { id: label.id, + attributes: { + ...selectFields(label.attributes), + color: null, + }, }, }, }, @@ -453,16 +525,24 @@ describe('Test Graphql Relations API End to End', () => { query: /* GraphQL */ ` { documents { - id - name - labels { + data { id - name - color { + attributes { name - red - green - blue + labels { + data { + id + attributes { + name + color { + name + red + green + blue + } + } + } + } } } } @@ -475,12 +555,17 @@ describe('Test Graphql Relations API End to End', () => { expect(res.statusCode).toBe(200); expect(body).toMatchObject({ data: { - documents: expect.arrayContaining( - data.documents.map(document => ({ - ...selectFields(document), - labels: [], - })) - ), + documents: { + data: expect.arrayContaining( + data.documents.map(document => ({ + id: document.id, + attributes: { + ...selectFields(document.attributes), + labels: { data: [] }, + }, + })) + ), + }, }, }); }); @@ -489,21 +574,32 @@ describe('Test Graphql Relations API End to End', () => { for (let document of data.documents) { const res = await graphqlQuery({ query: /* GraphQL */ ` - mutation deleteDocument($input: deleteDocumentInput) { - deleteDocument(input: $input) { - document { + mutation deleteDocument($id: ID!) { + deleteDocument(id: $id) { + data { id - name + attributes { + name + labels { + data { + id + attributes { + name + color { + name + red + green + } + } + } + } + } } } } `, variables: { - input: { - where: { - id: document.id, - }, - }, + id: document.id, }, }); @@ -511,8 +607,12 @@ describe('Test Graphql Relations API End to End', () => { expect(res.body).toMatchObject({ data: { deleteDocument: { - document: { + data: { id: document.id, + attributes: { + ...selectFields(document.attributes), + labels: { data: [] }, + }, }, }, }, @@ -525,21 +625,22 @@ describe('Test Graphql Relations API End to End', () => { name: 'Chuck Norris', privateName: 'Jean-Eude', }; + const res = await graphqlQuery({ query: /* GraphQL */ ` - mutation createPerson($input: createPersonInput) { - createPerson(input: $input) { - person { + mutation createPerson($data: PersonInput!) { + createPerson(data: $data) { + data { id - name + attributes { + name + } } } } `, variables: { - input: { - data: person, - }, + data: person, }, }); @@ -547,14 +648,17 @@ describe('Test Graphql Relations API End to End', () => { expect(res.body).toEqual({ data: { createPerson: { - person: { + data: { id: expect.anything(), - name: person.name, + attributes: { + name: person.name, + }, }, }, }, }); - data.people.push(res.body.data.createPerson.person); + + data.people.push(res.body.data.createPerson.data); }); test("Can't list a private field", async () => { @@ -562,8 +666,12 @@ describe('Test Graphql Relations API End to End', () => { query: /* GraphQL */ ` { people { - name - privateName + data { + attributes { + name + privateName + } + } } } `, @@ -584,27 +692,30 @@ describe('Test Graphql Relations API End to End', () => { name: 'Peugeot 508', person: data.people[0].id, }; + const res = await graphqlQuery({ query: /* GraphQL */ ` - mutation createCar($input: createCarInput) { - createCar(input: $input) { - car { + mutation createCar($data: CarInput!) { + createCar(data: $data) { + data { id - name - person { - id + attributes { name + person { + data { + id + attributes { + name + } + } + } } } } } `, variables: { - input: { - data: { - ...car, - }, - }, + data: car, }, }); @@ -612,16 +723,20 @@ describe('Test Graphql Relations API End to End', () => { expect(res.body).toMatchObject({ data: { createCar: { - car: { + data: { id: expect.anything(), - name: car.name, - person: data.people[0], + attributes: { + name: car.name, + person: { + data: data.people[0], + }, + }, }, }, }, }); - data.cars.push({ id: res.body.data.createCar.car.id }); + data.cars.push({ id: res.body.data.createCar.data.id }); }); test("Can't list a private oneToMany relation", async () => { @@ -629,8 +744,12 @@ describe('Test Graphql Relations API End to End', () => { query: /* GraphQL */ ` { people { - name - privateCars + data { + attributes { + name + privateCars + } + } } } `, @@ -654,33 +773,34 @@ describe('Test Graphql Relations API End to End', () => { const mutationRes = await graphqlQuery({ query: /* GraphQL */ ` - mutation updatePerson($input: updatePersonInput) { - updatePerson(input: $input) { - person { + mutation updatePerson($id: ID!, $data: PersonInput!) { + updatePerson(id: $id, data: $data) { + data { id } } } `, variables: { - input: { - where: { - id: data.people[0].id, - }, - data: { - ...newPerson, - }, - }, + id: data.people[0].id, + data: newPerson, }, }); + expect(mutationRes.statusCode).toBe(200); const queryRes = await graphqlQuery({ query: /* GraphQL */ ` query($id: ID!) { car(id: $id) { - person { - id + data { + attributes { + person { + data { + id + } + } + } } } } @@ -689,11 +809,18 @@ describe('Test Graphql Relations API End to End', () => { id: data.cars[0].id, }, }); + expect(queryRes.statusCode).toBe(200); expect(queryRes.body).toEqual({ data: { car: { - person: null, + data: { + attributes: { + person: { + data: null, + }, + }, + }, }, }, }); diff --git a/packages/plugins/graphql/tests/graphqlSchema.test.e2e.js b/packages/plugins/graphql/tests/graphqlSchema.test.e2e.js deleted file mode 100644 index 900b9912e8..0000000000 --- a/packages/plugins/graphql/tests/graphqlSchema.test.e2e.js +++ /dev/null @@ -1,96 +0,0 @@ -'use strict'; - -const types = require('../services/type-builder'); -const buildShadowCrud = require('../services/shadow-crud'); - -const playerModel = { - attributes: { - lastname: { - type: 'text', - }, - firstname: { - type: 'text', - }, - age: { - type: 'integer', - }, - level: { - type: 'enumeration', - enum: ['amateur', 'intermediary', 'pro'], - default: 'amateur', - }, - }, - connection: 'default', - name: 'player', - description: '', - collectionName: '', - globalId: 'Player', - kind: 'collectionType', - modelName: 'player', -}; - -describe('generateInputModel', () => { - test('removes disabled attributes', () => { - global.strapi = { - plugins: { - graphql: { - config: { - _schema: { - graphql: { - type: { - Player: { - age: false, - level: false, - }, - }, - }, - }, - }, - }, - }, - }; - expect(types.generateInputModel(playerModel, 'player')).toEqual( - ` - input PlayerInput { - - lastname: String -firstname: String - } - - input editPlayerInput { - - lastname: String -firstname: String - } - ` - ); - }); -}); - -describe('buildShadowCrud', () => { - test('removes disabled attributes', () => { - global.strapi = { - plugins: { - graphql: { - config: { - _schema: { - graphql: { - type: { - Player: { - age: false, - level: false, - }, - }, - }, - }, - }, - }, - }, - }; - global.strapi.contentTypes = [playerModel]; - global.strapi.components = {}; - expect(JSON.stringify(buildShadowCrud({}))).toEqual( - '{"definition":"\\ntype Player {id: ID!\\nundefined: ID!\\nlastname: String\\nfirstname: String}\\n\\n input PlayerInput {\\n\\n lastname: String\\nfirstname: String\\n }\\n\\n input editPlayerInput {\\n \\n lastname: String\\nfirstname: String\\n }\\n ","query":{},"mutation":{},"resolvers":{"Query":{},"Mutation":{},"Player":{}}}' - ); - }); -}); diff --git a/packages/plugins/graphql/tests/single-type.test.e2e.js b/packages/plugins/graphql/tests/single-type.test.e2e.js index 2811d83a33..c27727c03a 100644 --- a/packages/plugins/graphql/tests/single-type.test.e2e.js +++ b/packages/plugins/graphql/tests/single-type.test.e2e.js @@ -24,19 +24,15 @@ const homePageModel = { const updateContent = data => { return graphqlQuery({ query: /* GraphQL */ ` - mutation updateHomePage($input: updateHomePageInput) { - updateHomePage(input: $input) { - homePage { + mutation updateHomePage($data: HomePageInput!) { + updateHomePage(data: $data) { + data { id } } } `, - variables: { - input: { - data, - }, - }, + variables: { data }, }); }; @@ -67,7 +63,9 @@ describe('Single type Graphql support', () => { query: /* GraphQL */ ` { homePages { - id + data { + id + } } } `, @@ -92,8 +90,12 @@ describe('Single type Graphql support', () => { query: /* GraphQL */ ` { homePage { - id - title + data { + id + attributes { + title + } + } } } `, @@ -102,12 +104,16 @@ describe('Single type Graphql support', () => { expect(res.statusCode).toBe(200); expect(res.body.data).toEqual({ homePage: { - id: expect.anything(), - title: 'Test', + data: { + id: expect.anything(), + attributes: { + title: 'Test', + }, + }, }, }); - data.id = res.body.data.homePage.id; + data.id = res.body.data.homePage.data.id; }); }); @@ -136,20 +142,20 @@ describe('Single type Graphql support', () => { test('update a single type does not require id', async () => { const updateRes = await graphqlQuery({ query: /* GraphQL */ ` - mutation updateHomePage($input: updateHomePageInput) { - updateHomePage(input: $input) { - homePage { + mutation updateHomePage($data: HomePageInput!) { + updateHomePage(data: $data) { + data { id - title + attributes { + title + } } } } `, variables: { - input: { - data: { - title: 'New Title', - }, + data: { + title: 'New Title', }, }, }); @@ -157,9 +163,11 @@ describe('Single type Graphql support', () => { expect(updateRes.statusCode).toBe(200); expect(updateRes.body.data).toEqual({ updateHomePage: { - homePage: { + data: { id: data.id, - title: 'New Title', + attributes: { + title: 'New Title', + }, }, }, }); @@ -168,8 +176,12 @@ describe('Single type Graphql support', () => { query: /* GraphQL */ ` { homePage { - id - title + data { + id + attributes { + title + } + } } } `, @@ -178,8 +190,12 @@ describe('Single type Graphql support', () => { expect(getRes.statusCode).toBe(200); expect(getRes.body.data).toEqual({ homePage: { - id: data.id, - title: 'New Title', + data: { + id: data.id, + attributes: { + title: 'New Title', + }, + }, }, }); }); @@ -189,9 +205,11 @@ describe('Single type Graphql support', () => { query: /* GraphQL */ ` mutation { deleteHomePage { - homePage { + data { id - title + attributes { + title + } } } } @@ -201,9 +219,11 @@ describe('Single type Graphql support', () => { expect(deleteRes.statusCode).toBe(200); expect(deleteRes.body.data).toEqual({ deleteHomePage: { - homePage: { + data: { id: data.id, - title: 'New Title', + attributes: { + title: 'New Title', + }, }, }, }); @@ -212,8 +232,12 @@ describe('Single type Graphql support', () => { query: /* GraphQL */ ` { homePage { - id - title + data { + id + attributes { + title + } + } } } `, @@ -221,7 +245,9 @@ describe('Single type Graphql support', () => { expect(getRes.statusCode).toBe(200); expect(getRes.body.data).toEqual({ - homePage: null, + homePage: { + data: null, + }, }); }); }); diff --git a/packages/plugins/graphql/types/dynamiczoneScalar.js b/packages/plugins/graphql/types/dynamiczoneScalar.js deleted file mode 100644 index 0a743d1500..0000000000 --- a/packages/plugins/graphql/types/dynamiczoneScalar.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -const _ = require('lodash'); -const { Kind, GraphQLScalarType, valueFromASTUntyped, GraphQLError } = require('graphql'); - -module.exports = function DynamicZoneScalar({ name, attribute, globalId, components }) { - const parseData = value => { - const compo = Object.values(strapi.components).find( - compo => compo.globalId === value.__typename - ); - - if (!compo) { - throw new GraphQLError( - `Component not found. expected one of: ${components - .map(uid => strapi.components[uid].globalId) - .join(', ')}` - ); - } - - const finalValue = { - __component: compo.uid, - ..._.omit(value, ['__typename']), - }; - - return finalValue; - }; - - return new GraphQLScalarType({ - name, - description: `Input type for dynamic zone ${attribute} of ${globalId}`, - serialize: value => value, - parseValue: value => parseData(value), - parseLiteral(ast, variables) { - if (ast.kind !== Kind.OBJECT) return undefined; - - const value = valueFromASTUntyped(ast, variables); - return parseData(value); - }, - }); -}; diff --git a/packages/plugins/graphql/types/publication-state.js b/packages/plugins/graphql/types/publication-state.js deleted file mode 100644 index 3fcdd4d282..0000000000 --- a/packages/plugins/graphql/types/publication-state.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - -module.exports = { - definition: ` - enum PublicationState { - LIVE - PREVIEW - } - `, - resolver: { - PublicationState: { - LIVE: 'live', - PREVIEW: 'preview', - }, - }, -}; diff --git a/packages/plugins/graphql/types/time.js b/packages/plugins/graphql/types/time.js deleted file mode 100644 index 852ade4a97..0000000000 --- a/packages/plugins/graphql/types/time.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict'; - -const { parseType } = require('@strapi/utils'); - -const { Kind, GraphQLScalarType } = require('graphql'); - -const Time = new GraphQLScalarType({ - name: 'Time', - description: 'A time string with format: HH:mm:ss.SSS', - serialize(value) { - return parseType({ type: 'time', value }); - }, - parseValue(value) { - return parseType({ type: 'time', value }); - }, - parseLiteral(ast) { - if (ast.kind !== Kind.STRING) { - throw new TypeError(`Time cannot represent non string type`); - } - - const value = ast.value; - return parseType({ type: 'time', value }); - }, -}); - -module.exports = Time; diff --git a/packages/plugins/i18n/server/graphql.js b/packages/plugins/i18n/server/graphql.js new file mode 100644 index 0000000000..a84591f1fd --- /dev/null +++ b/packages/plugins/i18n/server/graphql.js @@ -0,0 +1,231 @@ +'use strict'; + +const { prop, propEq, identity, merge } = require('lodash/fp'); + +const LOCALE_SCALAR_TYPENAME = 'I18NLocaleCode'; +const LOCALE_ARG_PLUGIN_NAME = 'I18NLocaleArg'; + +const getLocalizedTypesFromRegistry = ({ strapi, typeRegistry }) => { + const { KINDS } = strapi.plugin('graphql').service('constants'); + const { isLocalizedContentType } = strapi.plugin('i18n').service('content-types'); + + return typeRegistry.where( + ({ config }) => config.kind === KINDS.type && isLocalizedContentType(config.contentType) + ); +}; + +module.exports = ({ strapi }) => ({ + register() { + const { service: getGraphQLService } = strapi.plugin('graphql'); + const { service: getI18NService } = strapi.plugin('i18n'); + + const { isLocalizedContentType } = getI18NService('content-types'); + + const extensionService = getGraphQLService('extension'); + + const getCreateLocalizationMutationType = contentType => { + const { getTypeName } = getGraphQLService('utils').naming; + + return `create${getTypeName(contentType)}Localization`; + }; + + extensionService.shadowCRUD('plugin::i18n.locale').disableMutations(); + + // Disable unwanted fields for localized content types + Object.entries(strapi.contentTypes).forEach(([uid, ct]) => { + if (isLocalizedContentType(ct)) { + // Disable locale field in localized inputs + extensionService + .shadowCRUD(uid) + .field('locale') + .disableInput(); + + // Disable localizations field in localized inputs + extensionService + .shadowCRUD(uid) + .field('localizations') + .disableInput(); + } + }); + + extensionService.use(({ nexus, typeRegistry }) => { + const i18nLocaleArgPlugin = getI18nLocaleArgPlugin({ nexus, typeRegistry }); + const i18nLocaleScalar = getLocaleScalar({ nexus }); + const { + mutations: createLocalizationMutations, + resolversConfig: createLocalizationResolversConfig, + } = getCreateLocalizationMutations({ nexus, typeRegistry }); + + return { + plugins: [i18nLocaleArgPlugin], + types: [i18nLocaleScalar, createLocalizationMutations], + + resolversConfig: { + // Auth for createLocalization mutations + ...createLocalizationResolversConfig, + // locale arg transformation for localized createEntity mutations + ...getLocalizedCreateMutationsResolversConfigs({ typeRegistry }), + }, + }; + }); + + const getLocaleScalar = ({ nexus }) => { + const locales = getI18NService('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 getCreateLocalizationMutations = ({ nexus, typeRegistry }) => { + const localizedContentTypes = getLocalizedTypesFromRegistry({ strapi, typeRegistry }).map( + prop('config.contentType') + ); + + const createLocalizationComponents = localizedContentTypes.map(ct => + getCreateLocalizationComponents(ct, { nexus }) + ); + + // Extract & merge each resolverConfig into a single object + const resolversConfig = createLocalizationComponents + .map(prop('resolverConfig')) + .reduce(merge, {}); + + const mutations = createLocalizationComponents.map(prop('mutation')); + + return { mutations, resolversConfig }; + }; + + const getCreateLocalizationComponents = (contentType, { nexus }) => { + const { getEntityResponseName, getContentTypeInputName } = getGraphQLService('utils').naming; + const { createCreateLocalizationHandler } = getI18NService('core-api'); + + const responseType = getEntityResponseName(contentType); + const mutationName = getCreateLocalizationMutationType(contentType); + + const resolverHandler = createCreateLocalizationHandler(contentType); + + const mutation = nexus.extendType({ + type: 'Mutation', + + definition(t) { + t.field(mutationName, { + type: responseType, + + // The locale arg will be automatically added through the i18n graphql extension + args: { + id: 'ID', + data: getContentTypeInputName(contentType), + }, + + async resolve(parent, args) { + const { id, locale, data } = args; + + const ctx = { + id, + data: { ...data, locale }, + }; + + const value = await resolverHandler(ctx); + + return { value, info: { args, resourceUID: contentType.uid } }; + }, + }); + }, + }); + + const resolverConfig = { + [`Mutation.${mutationName}`]: { + auth: { + scope: [`${contentType.uid}.createLocalization`], + }, + }, + }; + + return { mutation, resolverConfig }; + }; + + const getLocalizedCreateMutationsResolversConfigs = ({ typeRegistry }) => { + const localizedCreateMutationsNames = getLocalizedTypesFromRegistry({ + strapi, + typeRegistry, + }) + .map(prop('config.contentType')) + .map(getGraphQLService('utils').naming.getCreateMutationTypeName); + + return localizedCreateMutationsNames.reduce( + (acc, mutationName) => ({ + ...acc, + + [`Mutation.${mutationName}`]: { + middlewares: [ + // Set data's locale using args' locale + (resolve, parent, args, context, info) => { + args.data.locale = args.locale; + + return resolve(parent, args, context, info); + }, + ], + }, + }), + {} + ); + }; + + const getI18nLocaleArgPlugin = ({ nexus, typeRegistry }) => { + const { isLocalizedContentType } = getI18NService('content-types'); + + const addLocaleArg = config => { + const { parentType } = config; + + // Only target queries or mutations + if (parentType !== 'Query' && parentType !== 'Mutation') { + return; + } + + const registryType = typeRegistry.get(config.type); + + if (!registryType) { + return; + } + + const contentType = registryType.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); + }, + }); + }; + }, +}); diff --git a/packages/plugins/i18n/server/register.js b/packages/plugins/i18n/server/register.js index d0634d9a31..a601a810a7 100644 --- a/packages/plugins/i18n/server/register.js +++ b/packages/plugins/i18n/server/register.js @@ -83,4 +83,13 @@ const extendLocalizedContentTypes = strapi => { // coreApiService.addGraphqlLocalizationAction(contentType); // TODO: to implement } }); + + if (strapi.plugin('graphql')) { + require('./graphql')({ strapi }).register(); + } + + // TODO: to implement + // strapi.db.migrations.register(fieldMigration); + // strapi.db.migrations.register(enableContentTypeMigration); + // strapi.db.migrations.register(disableContentTypeMigration); }; diff --git a/packages/plugins/i18n/server/services/core-api.js b/packages/plugins/i18n/server/services/core-api.js index 93d2ba007e..260c49692d 100644 --- a/packages/plugins/i18n/server/services/core-api.js +++ b/packages/plugins/i18n/server/services/core-api.js @@ -1,7 +1,7 @@ 'use strict'; const _ = require('lodash'); -const { has, prop, pick, reduce, map, keys, toPath } = require('lodash/fp'); +const { prop, pick, reduce, map, keys, toPath, isNil } = require('lodash/fp'); const { contentTypes, parseMultipartData, sanitizeEntity } = require('@strapi/utils'); const { getService } = require('../utils'); @@ -84,74 +84,65 @@ const createSanitizer = contentType => { * @returns {(object) => void} */ const createLocalizationHandler = contentType => { + const handler = createCreateLocalizationHandler(contentType); + + return (ctx = {}) => { + const { id } = ctx.params; + const { data, files } = parseMultipartData(ctx); + + return handler({ id, data, files }); + }; +}; + +const createCreateLocalizationHandler = contentType => async (ctx = {}) => { const { copyNonLocalizedAttributes } = getService('content-types'); const { sanitizeInput, sanitizeInputFiles } = createSanitizer(contentType); - /** - * Create localized entry from another one - */ - const createFromBaseEntry = async (ctx, entry) => { - const { data, files } = parseMultipartData(ctx); + const entry = isSingleType(contentType) + ? await strapi.query(contentType.uid).findOne({ populate: ['localizations'] }) + : await strapi + .query(contentType.uid) + .findOne({ where: { id: ctx.id }, populate: ['localizations'] }); - const { findByCode } = getService('locales'); - - if (!has('locale', data)) { - throw strapi.errors.badRequest('locale.missing'); - } - - const matchingLocale = await findByCode(data.locale); - if (!matchingLocale) { - throw strapi.errors.badRequest('locale.invalid'); - } - - const usedLocales = getAllLocales(entry); - if (usedLocales.includes(data.locale)) { - throw strapi.errors.badRequest('locale.already.used'); - } - - const sanitizedData = { - ...copyNonLocalizedAttributes(contentType, entry), - ...sanitizeInput(data), - locale: data.locale, - localizations: getAllLocalizationsIds(entry), - }; - - const sanitizedFiles = sanitizeInputFiles(files); - - const newEntry = await strapi.entityService.create(contentType.uid, { - data: sanitizedData, - files: sanitizedFiles, - }); - - ctx.body = sanitizeEntity(newEntry, { model: strapi.getModel(contentType.uid) }); - }; - - if (isSingleType(contentType)) { - return async function(ctx) { - const entry = await strapi.query(contentType.uid).findOne({ populate: ['localizations'] }); - - if (!entry) { - throw strapi.errors.notFound('baseEntryId.invalid'); - } - - await createFromBaseEntry(ctx, entry); - }; + if (!entry) { + throw strapi.errors.notFound('baseEntryId.invalid'); } - return async function(ctx) { - const { id: baseEntryId } = ctx.params; + const { data, files } = ctx; - const entry = await strapi - .query(contentType.uid) - .findOne({ where: { id: baseEntryId }, populate: ['localizations'] }); + const { findByCode } = getService('locales'); - if (!entry) { - throw strapi.errors.notFound('baseEntryId.invalid'); - } + if (isNil(data.locale)) { + throw strapi.errors.badRequest('locale.missing'); + } - await createFromBaseEntry(ctx, entry); + const matchingLocale = await findByCode(data.locale); + if (!matchingLocale) { + throw strapi.errors.badRequest('locale.invalid'); + } + + const usedLocales = getAllLocales(entry); + if (usedLocales.includes(data.locale)) { + throw strapi.errors.badRequest('locale.already.used'); + } + + const sanitizedData = { + ...copyNonLocalizedAttributes(contentType, entry), + ...sanitizeInput(data), + locale: data.locale, + localizations: getAllLocalizationsIds(entry), }; + + const sanitizedFiles = sanitizeInputFiles(files); + + const newEntry = await strapi.entityService.create(contentType.uid, { + data: sanitizedData, + files: sanitizedFiles, + populate: ['localizations'], + }); + + return sanitizeEntity(newEntry, { model: strapi.getModel(contentType.uid) }); }; /** @@ -285,4 +276,5 @@ module.exports = () => ({ addCreateLocalizationAction, addGraphqlLocalizationAction, createSanitizer, + createCreateLocalizationHandler, }); diff --git a/packages/plugins/i18n/server/services/entity-service-decorator.js b/packages/plugins/i18n/server/services/entity-service-decorator.js index 3f40d6499e..e1d0c627be 100644 --- a/packages/plugins/i18n/server/services/entity-service-decorator.js +++ b/packages/plugins/i18n/server/services/entity-service-decorator.js @@ -17,6 +17,7 @@ const paramsContain = (key, params) => { /** * Adds default locale or replaces locale by locale in query params * @param {object} params - query params + * @param {object} ctx */ // TODO: remove _locale const wrapParams = async (params = {}, ctx = {}) => { @@ -106,10 +107,9 @@ const decorator = service => ({ }, /** - * Creates an entry & make links between it and its related localizaionts + * Creates an entry & make links between it and its related localizations + * @param {string} uid - Model uid * @param {object} opts - Query options object (params, data, files, populate) - * @param {object} ctx - Query context - * @param {object} ctx.model - Model that is being used */ async create(uid, opts) { const model = strapi.getModel(uid); @@ -133,9 +133,9 @@ const decorator = service => ({ /** * Updates an entry & update related localizations fields + * @param {string} uid + * @param {string} entityId * @param {object} opts - Query options object (params, data, files, populate) - * @param {object} ctx - Query context - * @param {object} ctx.model - Model that is being used */ async update(uid, entityId, opts) { const model = strapi.getModel(uid); diff --git a/packages/plugins/i18n/server/services/localizations.js b/packages/plugins/i18n/server/services/localizations.js index 4b69305555..234ac2f8e0 100644 --- a/packages/plugins/i18n/server/services/localizations.js +++ b/packages/plugins/i18n/server/services/localizations.js @@ -17,7 +17,7 @@ const assignDefaultLocale = async data => { }; /** - * Syncronize related localizations from a root one + * Synchronize related localizations from a root one * @param {Object} entry entry to update * @param {Object} options * @param {Object} options.model corresponding model diff --git a/packages/plugins/i18n/tests/graphql.test.e2e.js b/packages/plugins/i18n/tests/graphql.test.e2e.js index 998e4a0a8e..94cb2d70b5 100644 --- a/packages/plugins/i18n/tests/graphql.test.e2e.js +++ b/packages/plugins/i18n/tests/graphql.test.e2e.js @@ -22,7 +22,7 @@ const recipesModel = { localized: true, }, }, - name: 'recipes', + name: 'recipe', description: '', collectionName: '', }; @@ -51,68 +51,80 @@ describe('Test Graphql API create localization', () => { afterAll(async () => { await strapi.query('plugin::i18n.locale').delete({ where: { id: localeId } }); - await strapi.query('api::recipes.recipes').deleteMany(); + await strapi.query('api::recipe.recipe').deleteMany(); await strapi.destroy(); await builder.cleanup(); }); - test('Create localization for a model with plural name', async () => { + test('Create localization', async () => { const createResponse = await graphqlQuery({ query: /* GraphQL */ ` - mutation createRecipe($input: createRecipeInput) { - createRecipe(input: $input) { - recipe { + mutation createRecipe($data: RecipeInput!) { + createRecipe(data: $data) { + data { id - name - locale + attributes { + name + locale + } } } } `, variables: { - input: { - data: { - name: 'Recipe Name', - }, + data: { + name: 'Recipe Name', }, }, }); expect(createResponse.statusCode).toBe(200); - expect(createResponse.body.data.createRecipe.recipe).toMatchObject({ - name: 'Recipe Name', - locale: 'en', + expect(createResponse.body).toMatchObject({ + data: { + createRecipe: { + data: { + attributes: { + name: 'Recipe Name', + locale: 'en', + }, + }, + }, + }, }); - const recipeId = createResponse.body.data.createRecipe.recipe.id; + const recipeId = createResponse.body.data.createRecipe.data.id; const createLocalizationResponse = await graphqlQuery({ query: /* GraphQL */ ` - mutation createRecipeLocalization($input: updateRecipeInput!) { - createRecipeLocalization(input: $input) { - id - name - locale + mutation createRecipeLocalization($id: ID!, $locale: I18NLocaleCode, $data: RecipeInput!) { + createRecipeLocalization(id: $id, locale: $locale, data: $data) { + data { + id + attributes { + name + locale + } + } } } `, variables: { - input: { - where: { - id: recipeId, - }, - data: { - name: 'Recipe Name fr', - locale: 'fr', - }, + id: recipeId, + locale: 'fr', + data: { + name: 'Recipe Name fr', }, }, }); expect(createLocalizationResponse.statusCode).toBe(200); expect(createLocalizationResponse.body.data.createRecipeLocalization).toMatchObject({ - name: 'Recipe Name fr', - locale: 'fr', + data: { + attributes: { + name: 'Recipe Name fr', + locale: 'fr', + }, + }, }); }); }); diff --git a/packages/plugins/users-permissions/server/graphql/index.js b/packages/plugins/users-permissions/server/graphql/index.js new file mode 100644 index 0000000000..4780f3241a --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/index.js @@ -0,0 +1,44 @@ +'use strict'; + +const getTypes = require('./types'); +const getQueries = require('./queries'); +const getMutations = require('./mutations'); +const getResolversConfig = require('./resolvers-configs'); + +module.exports = ({ strapi }) => { + const { config: graphQLConfig } = strapi.plugin('graphql'); + const extensionService = strapi.plugin('graphql').service('extension'); + + const isShadowCRUDEnabled = graphQLConfig('shadowCRUD', true); + + if (!isShadowCRUDEnabled) { + return; + } + + // Disable Permissions queries & mutations but allow the + // type to be used/selected in filters or nested resolvers + extensionService + .shadowCRUD('plugin::users-permissions.permission') + .disableQueries() + .disableMutations(); + + // Disable User & Role's Create/Update/Delete actions so they can be replaced + const actionsToDisable = ['create', 'update', 'delete']; + + extensionService.shadowCRUD('plugin::users-permissions.user').disableActions(actionsToDisable); + extensionService.shadowCRUD('plugin::users-permissions.role').disableActions(actionsToDisable); + + // Register new types & resolvers config + extensionService.use(({ nexus }) => { + const types = getTypes({ strapi, nexus }); + const queries = getQueries({ strapi, nexus }); + const mutations = getMutations({ strapi, nexus }); + const resolversConfig = getResolversConfig({ strapi }); + + return { + types: [types, queries, mutations], + + resolversConfig, + }; + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/auth/email-confirmation.js b/packages/plugins/users-permissions/server/graphql/mutations/auth/email-confirmation.js new file mode 100644 index 0000000000..70c55f3149 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/auth/email-confirmation.js @@ -0,0 +1,39 @@ +'use strict'; + +const { toPlainObject } = require('lodash/fp'); + +const { checkBadRequest } = require('../../utils'); + +module.exports = ({ nexus, strapi }) => { + const { nonNull } = nexus; + + return { + type: 'UsersPermissionsLoginPayload', + + args: { + confirmation: nonNull('String'), + }, + + description: 'Confirm an email users email address', + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.request.body = toPlainObject(args); + + await strapi + .plugin('users-permissions') + .controller('auth') + .emailConfirmation(koaContext, null, true); + + const output = koaContext.body; + + checkBadRequest(output); + + return { + user: output.user || output, + jwt: output.jwt, + }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/auth/forgot-password.js b/packages/plugins/users-permissions/server/graphql/mutations/auth/forgot-password.js new file mode 100644 index 0000000000..44f312e370 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/auth/forgot-password.js @@ -0,0 +1,38 @@ +'use strict'; + +const { toPlainObject } = require('lodash/fp'); + +const { checkBadRequest } = require('../../utils'); + +module.exports = ({ nexus, strapi }) => { + const { nonNull } = nexus; + + return { + type: 'UsersPermissionsPasswordPayload', + + args: { + email: nonNull('String'), + }, + + description: 'Request a reset password token', + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.request.body = toPlainObject(args); + + await strapi + .plugin('users-permissions') + .controller('auth') + .forgotPassword(koaContext); + + const output = koaContext.body; + + checkBadRequest(output); + + return { + ok: output.ok || output, + }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/auth/login.js b/packages/plugins/users-permissions/server/graphql/mutations/auth/login.js new file mode 100644 index 0000000000..2cfeddbe9f --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/auth/login.js @@ -0,0 +1,38 @@ +'use strict'; + +const { toPlainObject } = require('lodash/fp'); + +const { checkBadRequest } = require('../../utils'); + +module.exports = ({ nexus, strapi }) => { + const { nonNull } = nexus; + + return { + type: nonNull('UsersPermissionsLoginPayload'), + + args: { + input: nonNull('UsersPermissionsLoginInput'), + }, + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.params = { provider: args.input.provider }; + koaContext.request.body = toPlainObject(args.input); + + await strapi + .plugin('users-permissions') + .controller('auth') + .callback(koaContext); + + const output = koaContext.body; + + checkBadRequest(output); + + return { + user: output.user || output, + jwt: output.jwt, + }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/auth/register.js b/packages/plugins/users-permissions/server/graphql/mutations/auth/register.js new file mode 100644 index 0000000000..496e9a7132 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/auth/register.js @@ -0,0 +1,39 @@ +'use strict'; + +const { toPlainObject } = require('lodash/fp'); + +const { checkBadRequest } = require('../../utils'); + +module.exports = ({ nexus, strapi }) => { + const { nonNull } = nexus; + + return { + type: nonNull('UsersPermissionsLoginPayload'), + + args: { + input: nonNull('UsersPermissionsRegisterInput'), + }, + + description: 'Register a user', + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.request.body = toPlainObject(args.input); + + await strapi + .plugin('users-permissions') + .controller('auth') + .register(koaContext); + + const output = koaContext.body; + + checkBadRequest(output); + + return { + user: output.user || output, + jwt: output.jwt, + }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/auth/reset-password.js b/packages/plugins/users-permissions/server/graphql/mutations/auth/reset-password.js new file mode 100644 index 0000000000..c1eb16e85f --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/auth/reset-password.js @@ -0,0 +1,41 @@ +'use strict'; + +const { toPlainObject } = require('lodash/fp'); + +const { checkBadRequest } = require('../../utils'); + +module.exports = ({ nexus, strapi }) => { + const { nonNull } = nexus; + + return { + type: 'UsersPermissionsLoginPayload', + + args: { + password: nonNull('String'), + passwordConfirmation: nonNull('String'), + code: nonNull('String'), + }, + + description: 'Reset user password. Confirm with a code (resetToken from forgotPassword)', + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.request.body = toPlainObject(args); + + await strapi + .plugin('users-permissions') + .controller('auth') + .forgotPassword(koaContext); + + const output = koaContext.body; + + checkBadRequest(output); + + return { + user: output.user || output, + jwt: output.jwt, + }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/crud/role/create-role.js b/packages/plugins/users-permissions/server/graphql/mutations/crud/role/create-role.js new file mode 100644 index 0000000000..dc83a99d5d --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/crud/role/create-role.js @@ -0,0 +1,37 @@ +'use strict'; + +const { toPlainObject } = require('lodash/fp'); + +const usersPermissionsRoleUID = 'plugin::users-permissions.role'; + +module.exports = ({ nexus, strapi }) => { + const { getContentTypeInputName } = strapi.plugin('graphql').service('utils').naming; + const { nonNull } = nexus; + + const roleContentType = strapi.getModel(usersPermissionsRoleUID); + + const roleInputName = getContentTypeInputName(roleContentType); + + return { + type: 'UsersPermissionsCreateRolePayload', + + args: { + data: nonNull(roleInputName), + }, + + description: 'Create a new role', + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.request.body = toPlainObject(args.data); + + await strapi + .plugin('users-permissions') + .controller('role') + .createRole(koaContext); + + return { ok: true }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/crud/role/delete-role.js b/packages/plugins/users-permissions/server/graphql/mutations/crud/role/delete-role.js new file mode 100644 index 0000000000..db57c09d3b --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/crud/role/delete-role.js @@ -0,0 +1,28 @@ +'use strict'; + +module.exports = ({ nexus, strapi }) => { + const { nonNull } = nexus; + + return { + type: 'UsersPermissionsDeleteRolePayload', + + args: { + id: nonNull('ID'), + }, + + description: 'Delete an existing role', + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.params = { role: args.id }; + + await strapi + .plugin('users-permissions') + .controller('role') + .deleteRole(koaContext); + + return { ok: true }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/crud/role/update-role.js b/packages/plugins/users-permissions/server/graphql/mutations/crud/role/update-role.js new file mode 100644 index 0000000000..7615050d8b --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/crud/role/update-role.js @@ -0,0 +1,38 @@ +'use strict'; + +const usersPermissionsRoleUID = 'plugin::users-permissions.role'; + +module.exports = ({ nexus, strapi }) => { + const { getContentTypeInputName } = strapi.plugin('graphql').service('utils').naming; + const { nonNull } = nexus; + + const roleContentType = strapi.getModel(usersPermissionsRoleUID); + + const roleInputName = getContentTypeInputName(roleContentType); + + return { + type: 'UsersPermissionsUpdateRolePayload', + + args: { + id: nonNull('ID'), + data: nonNull(roleInputName), + }, + + description: 'Update an existing role', + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.params = { role: args.id }; + koaContext.request.body = args.data; + koaContext.request.body.role = args.id; + + await strapi + .plugin('users-permissions') + .controller('role') + .updateRole(koaContext); + + return { ok: true }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/crud/user/create-user.js b/packages/plugins/users-permissions/server/graphql/mutations/crud/user/create-user.js new file mode 100644 index 0000000000..584751b3c0 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/crud/user/create-user.js @@ -0,0 +1,48 @@ +'use strict'; + +const { toPlainObject } = require('lodash/fp'); + +const { checkBadRequest } = require('../../../utils'); + +const usersPermissionsUserUID = 'plugin::users-permissions.user'; + +module.exports = ({ nexus, strapi }) => { + const { nonNull } = nexus; + const { getContentTypeInputName, getEntityResponseName } = strapi + .plugin('graphql') + .service('utils').naming; + + const userContentType = strapi.getModel(usersPermissionsUserUID); + + const userInputName = getContentTypeInputName(userContentType); + const responseName = getEntityResponseName(userContentType); + + return { + type: nonNull(responseName), + + args: { + data: nonNull(userInputName), + }, + + description: 'Create a new user', + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.params = {}; + koaContext.request.body = toPlainObject(args.data); + + await strapi + .plugin('users-permissions') + .controller('user') + .create(koaContext); + + checkBadRequest(koaContext.body); + + return { + value: koaContext.body, + info: { args, resourceUID: 'plugin::users-permissions.user' }, + }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/crud/user/delete-user.js b/packages/plugins/users-permissions/server/graphql/mutations/crud/user/delete-user.js new file mode 100644 index 0000000000..e6226c24f1 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/crud/user/delete-user.js @@ -0,0 +1,42 @@ +'use strict'; + +const { checkBadRequest } = require('../../../utils'); + +const usersPermissionsUserUID = 'plugin::users-permissions.user'; + +module.exports = ({ nexus, strapi }) => { + const { nonNull } = nexus; + const { getEntityResponseName } = strapi.plugin('graphql').service('utils').naming; + + const userContentType = strapi.getModel(usersPermissionsUserUID); + + const responseName = getEntityResponseName(userContentType); + + return { + type: nonNull(responseName), + + args: { + id: nonNull('ID'), + }, + + description: 'Update an existing user', + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.params = { id: args.id }; + + await strapi + .plugin('users-permissions') + .controller('user') + .destroy(koaContext); + + checkBadRequest(koaContext.body); + + return { + value: koaContext.body, + info: { args, resourceUID: 'plugin::users-permissions.user' }, + }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/crud/user/update-user.js b/packages/plugins/users-permissions/server/graphql/mutations/crud/user/update-user.js new file mode 100644 index 0000000000..5db39f48ec --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/crud/user/update-user.js @@ -0,0 +1,49 @@ +'use strict'; + +const { toPlainObject } = require('lodash/fp'); + +const { checkBadRequest } = require('../../../utils'); + +const usersPermissionsUserUID = 'plugin::users-permissions.user'; + +module.exports = ({ nexus, strapi }) => { + const { nonNull } = nexus; + const { getContentTypeInputName, getEntityResponseName } = strapi + .plugin('graphql') + .service('utils').naming; + + const userContentType = strapi.getModel(usersPermissionsUserUID); + + const userInputName = getContentTypeInputName(userContentType); + const responseName = getEntityResponseName(userContentType); + + return { + type: nonNull(responseName), + + args: { + id: nonNull('ID'), + data: nonNull(userInputName), + }, + + description: 'Update an existing user', + + async resolve(parent, args, context) { + const { koaContext } = context; + + koaContext.params = { id: args.id }; + koaContext.request.body = toPlainObject(args.data); + + await strapi + .plugin('users-permissions') + .controller('user') + .update(koaContext); + + checkBadRequest(koaContext.body); + + return { + value: koaContext.body, + info: { args, resourceUID: 'plugin::users-permissions.user' }, + }; + }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/mutations/index.js b/packages/plugins/users-permissions/server/graphql/mutations/index.js new file mode 100644 index 0000000000..23a8028e3e --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/mutations/index.js @@ -0,0 +1,42 @@ +'use strict'; + +const userUID = 'plugin::users-permissions.user'; +const roleUID = 'plugin::users-permissions.role'; + +module.exports = context => { + const { nexus, strapi } = context; + + const { naming } = strapi.plugin('graphql').service('utils'); + + const user = strapi.getModel(userUID); + const role = strapi.getModel(roleUID); + + const mutations = { + // CRUD (user & role) + [naming.getCreateMutationTypeName(role)]: require('./crud/role/create-role'), + [naming.getUpdateMutationTypeName(role)]: require('./crud/role/update-role'), + [naming.getDeleteMutationTypeName(role)]: require('./crud/role/delete-role'), + [naming.getCreateMutationTypeName(user)]: require('./crud/user/create-user'), + [naming.getUpdateMutationTypeName(user)]: require('./crud/user/update-user'), + [naming.getDeleteMutationTypeName(user)]: require('./crud/user/delete-user'), + + // Other mutations + login: require('./auth/login'), + register: require('./auth/register'), + forgotPassword: require('./auth/forgot-password'), + resetPassword: require('./auth/reset-password'), + emailConfirmation: require('./auth/email-confirmation'), + }; + + return nexus.extendType({ + type: 'Mutation', + + definition(t) { + for (const [name, getConfig] of Object.entries(mutations)) { + const config = getConfig(context); + + t.field(name, config); + } + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/queries/index.js b/packages/plugins/users-permissions/server/graphql/queries/index.js new file mode 100644 index 0000000000..d45d0f278c --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/queries/index.js @@ -0,0 +1,13 @@ +'use strict'; + +const me = require('./me'); + +module.exports = ({ nexus }) => { + return nexus.extendType({ + type: 'Query', + + definition(t) { + t.field('me', me({ nexus })); + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/queries/me.js b/packages/plugins/users-permissions/server/graphql/queries/me.js new file mode 100644 index 0000000000..0835ad6586 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/queries/me.js @@ -0,0 +1,17 @@ +'use strict'; + +module.exports = () => ({ + type: 'UsersPermissionsMe', + + args: {}, + + resolve(parent, args, context) { + const { user } = context.state; + + if (!user) { + throw new Error('Authentication requested'); + } + + return user; + }, +}); diff --git a/packages/plugins/users-permissions/server/graphql/resolvers-configs.js b/packages/plugins/users-permissions/server/graphql/resolvers-configs.js new file mode 100644 index 0000000000..fb116defea --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/resolvers-configs.js @@ -0,0 +1,37 @@ +'use strict'; + +const userUID = 'plugin::users-permissions.user'; +const roleUID = 'plugin::users-permissions.role'; + +module.exports = ({ strapi }) => { + const { naming } = strapi.plugin('graphql').service('utils'); + + const user = strapi.getModel(userUID); + const role = strapi.getModel(roleUID); + + const createRole = naming.getCreateMutationTypeName(role); + const updateRole = naming.getUpdateMutationTypeName(role); + const deleteRole = naming.getDeleteMutationTypeName(role); + const createUser = naming.getCreateMutationTypeName(user); + const updateUser = naming.getUpdateMutationTypeName(user); + const deleteUser = naming.getDeleteMutationTypeName(user); + + return { + // Disabled auth for some operations + 'Mutation.login': { auth: false }, + 'Mutation.register': { auth: false }, + 'Mutation.forgotPassword': { auth: false }, + 'Mutation.resetPassword': { auth: false }, + 'Mutation.emailConfirmation': { auth: false }, + + // Scoped auth for replaced CRUD operations + // Role + [`Mutation.${createRole}`]: { auth: { scope: [`${roleUID}.create`] } }, + [`Mutation.${updateRole}`]: { auth: { scope: [`${roleUID}.update`] } }, + [`Mutation.${deleteRole}`]: { auth: { scope: [`${roleUID}.delete`] } }, + // User + [`Mutation.${createUser}`]: { auth: { scope: [`${userUID}.create`] } }, + [`Mutation.${updateUser}`]: { auth: { scope: [`${userUID}.update`] } }, + [`Mutation.${deleteUser}`]: { auth: { scope: [`${userUID}.delete`] } }, + }; +}; diff --git a/packages/plugins/users-permissions/server/graphql/types/create-role-payload.js b/packages/plugins/users-permissions/server/graphql/types/create-role-payload.js new file mode 100644 index 0000000000..33b5c47632 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/types/create-role-payload.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = ({ nexus }) => { + return nexus.objectType({ + name: 'UsersPermissionsCreateRolePayload', + + definition(t) { + t.nonNull.boolean('ok'); + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/types/delete-role-payload.js b/packages/plugins/users-permissions/server/graphql/types/delete-role-payload.js new file mode 100644 index 0000000000..7e060055cb --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/types/delete-role-payload.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = ({ nexus }) => { + return nexus.objectType({ + name: 'UsersPermissionsDeleteRolePayload', + + definition(t) { + t.nonNull.boolean('ok'); + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/types/index.js b/packages/plugins/users-permissions/server/graphql/types/index.js new file mode 100644 index 0000000000..29a9883022 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/types/index.js @@ -0,0 +1,21 @@ +'use strict'; + +const typesFactories = [ + require('./me'), + require('./me-role'), + require('./register-input'), + require('./login-input'), + require('./password-payload'), + require('./login-payload'), + require('./create-role-payload'), + require('./update-role-payload'), + require('./delete-role-payload'), +]; + +/** + * @param {object} context + * @param {object} context.nexus + * @param {object} context.strapi + * @return {any[]} + */ +module.exports = context => typesFactories.map(factory => factory(context)); diff --git a/packages/plugins/users-permissions/server/graphql/types/login-input.js b/packages/plugins/users-permissions/server/graphql/types/login-input.js new file mode 100644 index 0000000000..b8d7f414f3 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/types/login-input.js @@ -0,0 +1,13 @@ +'use strict'; + +module.exports = ({ nexus }) => { + return nexus.inputObjectType({ + name: 'UsersPermissionsLoginInput', + + definition(t) { + t.nonNull.string('identifier'); + t.nonNull.string('password'); + t.nonNull.string('provider', { default: 'local' }); + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/types/login-payload.js b/packages/plugins/users-permissions/server/graphql/types/login-payload.js new file mode 100644 index 0000000000..65f8ec49ee --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/types/login-payload.js @@ -0,0 +1,12 @@ +'use strict'; + +module.exports = ({ nexus }) => { + return nexus.objectType({ + name: 'UsersPermissionsLoginPayload', + + definition(t) { + t.string('jwt'); + t.nonNull.field('user', { type: 'UsersPermissionsMe' }); + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/types/me-role.js b/packages/plugins/users-permissions/server/graphql/types/me-role.js new file mode 100644 index 0000000000..485fedb150 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/types/me-role.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = ({ nexus }) => { + return nexus.objectType({ + name: 'UsersPermissionsMeRole', + + definition(t) { + t.nonNull.id('id'); + t.nonNull.string('name'); + t.string('description'); + t.string('type'); + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/types/me.js b/packages/plugins/users-permissions/server/graphql/types/me.js new file mode 100644 index 0000000000..d3a8aee0e5 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/types/me.js @@ -0,0 +1,16 @@ +'use strict'; + +module.exports = ({ nexus }) => { + return nexus.objectType({ + name: 'UsersPermissionsMe', + + definition(t) { + t.nonNull.id('id'); + t.nonNull.string('username'); + t.string('email'); + t.boolean('confirmed'); + t.boolean('blocked'); + t.field('role', { type: 'UsersPermissionsMeRole' }); + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/types/password-payload.js b/packages/plugins/users-permissions/server/graphql/types/password-payload.js new file mode 100644 index 0000000000..5d68c8ea0b --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/types/password-payload.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = ({ nexus }) => { + return nexus.objectType({ + name: 'UsersPermissionsPasswordPayload', + + definition(t) { + t.nonNull.boolean('ok'); + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/types/register-input.js b/packages/plugins/users-permissions/server/graphql/types/register-input.js new file mode 100644 index 0000000000..1585761a13 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/types/register-input.js @@ -0,0 +1,13 @@ +'use strict'; + +module.exports = ({ nexus }) => { + return nexus.inputObjectType({ + name: 'UsersPermissionsRegisterInput', + + definition(t) { + t.nonNull.string('username'); + t.nonNull.string('email'); + t.nonNull.string('password'); + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/types/update-role-payload.js b/packages/plugins/users-permissions/server/graphql/types/update-role-payload.js new file mode 100644 index 0000000000..cea281dcd7 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/types/update-role-payload.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = ({ nexus }) => { + return nexus.objectType({ + name: 'UsersPermissionsUpdateRolePayload', + + definition(t) { + t.nonNull.boolean('ok'); + }, + }); +}; diff --git a/packages/plugins/users-permissions/server/graphql/utils.js b/packages/plugins/users-permissions/server/graphql/utils.js new file mode 100644 index 0000000000..8439021245 --- /dev/null +++ b/packages/plugins/users-permissions/server/graphql/utils.js @@ -0,0 +1,27 @@ +'use strict'; + +const { getOr } = require('lodash/fp'); + +/** + * Throws an ApolloError if context body contains a bad request + * @param contextBody - body of the context object given to the resolver + * @throws ApolloError if the body is a bad request + */ +function checkBadRequest(contextBody) { + const statusCode = getOr(200, 'statusCode', contextBody); + + if (statusCode !== 200) { + const errorMessage = getOr('Bad Request', 'error', contextBody); + + const exception = new Error(errorMessage); + + exception.code = statusCode || 400; + exception.data = contextBody; + + throw exception; + } +} + +module.exports = { + checkBadRequest, +}; diff --git a/packages/plugins/users-permissions/server/register.js b/packages/plugins/users-permissions/server/register.js index f729c247a8..74de5546bb 100644 --- a/packages/plugins/users-permissions/server/register.js +++ b/packages/plugins/users-permissions/server/register.js @@ -4,4 +4,8 @@ const authStrategy = require('./strategies/users-permissions'); module.exports = strapi => { strapi.container.get('auth').register('content-api', authStrategy); + + if (strapi.plugin('graphql')) { + require('./graphql')({ strapi }); + } }; diff --git a/packages/plugins/users-permissions/server/schema.graphql.js b/packages/plugins/users-permissions/server/schema.graphql.js deleted file mode 100644 index 270ba9085b..0000000000 --- a/packages/plugins/users-permissions/server/schema.graphql.js +++ /dev/null @@ -1,317 +0,0 @@ -'use strict'; - -const _ = require('lodash'); - -/** - * Throws an ApolloError if context body contains a bad request - * @param contextBody - body of the context object given to the resolver - * @throws ApolloError if the body is a bad request - */ -function checkBadRequest(contextBody) { - if (_.get(contextBody, 'statusCode', 200) !== 200) { - const message = _.get(contextBody, 'error', 'Bad Request'); - const exception = new Error(message); - exception.code = _.get(contextBody, 'statusCode', 400); - exception.data = contextBody; - throw exception; - } -} - -module.exports = { - type: { - UsersPermissionsPermission: false, // Make this type NOT queriable. - }, - definition: /* GraphQL */ ` - type UsersPermissionsMe { - id: ID! - username: String! - email: String! - confirmed: Boolean - blocked: Boolean - role: UsersPermissionsMeRole - } - - type UsersPermissionsMeRole { - id: ID! - name: String! - description: String - type: String - } - - input UsersPermissionsRegisterInput { - username: String! - email: String! - password: String! - } - - input UsersPermissionsLoginInput { - identifier: String! - password: String! - provider: String = "local" - } - - type UsersPermissionsLoginPayload { - jwt: String - user: UsersPermissionsMe! - } - - type UserPermissionsPasswordPayload { - ok: Boolean! - } - `, - query: ` - me: UsersPermissionsMe - `, - mutation: ` - login(input: UsersPermissionsLoginInput!): UsersPermissionsLoginPayload! - register(input: UsersPermissionsRegisterInput!): UsersPermissionsLoginPayload! - forgotPassword(email: String!): UserPermissionsPasswordPayload - resetPassword(password: String!, passwordConfirmation: String!, code: String!): UsersPermissionsLoginPayload - emailConfirmation(confirmation: String!): UsersPermissionsLoginPayload - `, - resolver: { - Query: { - me: { - resolver: 'plugin::users-permissions.user.me', - }, - role: { - resolverOf: 'plugin::users-permissions.users-permissions.getRole', - async resolver(obj, options, { context }) { - context.params = { ...context.params, ...options.input }; - - await strapi - .plugin('users-permissions') - .controller('users-permissions') - .getRole(context); - - return context.body.role; - }, - }, - roles: { - description: `Retrieve all the existing roles. You can't apply filters on this query.`, - resolverOf: 'plugin::users-permissions.users-permissions.getRoles', // Apply the `getRoles` permissions on the resolver. - async resolver(obj, options, { context }) { - context.params = { ...context.params, ...options.input }; - - await strapi - .plugin('users-permissions') - .controller('users-permissions') - .getRoles(context); - - return context.body.roles; - }, - }, - }, - Mutation: { - createRole: { - description: 'Create a new role', - resolverOf: 'plugin::users-permissions.users-permissions.createRole', - async resolver(obj, options, { context }) { - await strapi - .plugin('users-permissions') - .controller('users-permissions') - .createRole(context); - - return { ok: true }; - }, - }, - updateRole: { - description: 'Update an existing role', - resolverOf: 'plugin::users-permissions.users-permissions.updateRole', - async resolver(obj, options, { context }) { - context.params = { ...context.params, ...options.input }; - context.params.role = context.params.id; - - await strapi - .plugin('users-permissions') - .controller('users-permissions') - .updateRole(context); - - return { ok: true }; - }, - }, - deleteRole: { - description: 'Delete an existing role', - resolverOf: 'plugin::users-permissions.users-permissions.deleteRole', - async resolver(obj, options, { context }) { - context.params = { ...context.params, ...options.input }; - context.params.role = context.params.id; - - await strapi - .plugin('users-permissions') - .controller('users-permissions') - .deleteRole(context); - - return { ok: true }; - }, - }, - createUser: { - description: 'Create a new user', - resolverOf: 'plugin::users-permissions.user.create', - async resolver(obj, options, { context }) { - context.params = _.toPlainObject(options.input.where); - context.request.body = _.toPlainObject(options.input.data); - - await strapi - .plugin('users-permissions') - .controller('user') - .create(context); - - return { - user: context.body, - }; - }, - }, - updateUser: { - description: 'Update an existing user', - resolverOf: 'plugin::users-permissions.user.update', - async resolver(obj, options, { context }) { - context.params = _.toPlainObject(options.input.where); - context.request.body = _.toPlainObject(options.input.data); - - await strapi - .plugin('users-permissions') - .controller('user') - .update(context); - - return { - user: context.body, - }; - }, - }, - deleteUser: { - description: 'Delete an existing user', - resolverOf: 'plugin::users-permissions.user.destroy', - async resolver(obj, options, { context }) { - // Set parameters to context. - context.params = _.toPlainObject(options.input.where); - context.request.body = _.toPlainObject(options.input.data); - - // Retrieve user to be able to return it because - // Bookshelf doesn't return the row once deleted. - await strapi - .plugin('users-permissions') - .controller('user') - .findOne(context); - // Assign result to user. - const user = context.body; - - // Run destroy query. - await strapi - .plugin('users-permissions') - .controller('user') - .destroy(context); - - return { - user, - }; - }, - }, - register: { - description: 'Register a user', - resolverOf: 'plugin::users-permissions.auth.register', - async resolver(obj, options, { context }) { - context.request.body = _.toPlainObject(options.input); - - await strapi - .plugin('users-permissions') - .controller('auth') - .register(context); - - let output = context.body; - - checkBadRequest(output); - return { - user: output.user || output, - jwt: output.jwt, - }; - }, - }, - login: { - resolverOf: 'plugin::users-permissions.auth.callback', - async resolver(obj, options, { context }) { - context.params = { - ...context.params, - provider: options.input.provider, - }; - context.request.body = _.toPlainObject(options.input); - - await strapi - .plugin('users-permissions') - .controller('auth') - .callback(context); - - let output = context.body; - - checkBadRequest(output); - return { - user: output.user || output, - jwt: output.jwt, - }; - }, - }, - forgotPassword: { - description: 'Request a reset password token', - resolverOf: 'plugin::users-permissions.auth.forgotPassword', - async resolver(obj, options, { context }) { - context.request.body = _.toPlainObject(options); - - await strapi - .plugin('users-permissions') - .controller('auth') - .forgotPassword(context); - - let output = context.body; - - checkBadRequest(output); - - return { - ok: output.ok || output, - }; - }, - }, - resetPassword: { - description: 'Reset user password. Confirm with a code (resetToken from forgotPassword)', - resolverOf: 'plugin::users-permissions.auth.resetPassword', - async resolver(obj, options, { context }) { - context.request.body = _.toPlainObject(options); - - await strapi - .plugin('users-permissions') - .controller('auth') - .resetPassword(context); - - let output = context.body; - - checkBadRequest(output); - - return { - user: output.user || output, - jwt: output.jwt, - }; - }, - }, - emailConfirmation: { - description: 'Confirm an email users email address', - resolverOf: 'plugin::users-permissions.auth.emailConfirmation', - async resolver(obj, options, { context }) { - context.query = _.toPlainObject(options); - - await strapi - .plugin('users-permissions') - .controller('auth') - .emailConfirmation(context, null, true); - - let output = context.body; - - checkBadRequest(output); - - return { - user: output.user || output, - jwt: output.jwt, - }; - }, - }, - }, - }, -}; diff --git a/packages/plugins/users-permissions/tests/graphql.test.e2e.js b/packages/plugins/users-permissions/tests/graphql.test.e2e.js index f442302f2f..25903790c2 100644 --- a/packages/plugins/users-permissions/tests/graphql.test.e2e.js +++ b/packages/plugins/users-permissions/tests/graphql.test.e2e.js @@ -6,11 +6,10 @@ const { createAuthRequest, createRequest } = require('../../../../test/helpers/r let strapi; let authReq; -const data = {}; describe('Test Graphql user service', () => { beforeAll(async () => { - strapi = await createStrapiInstance(); + strapi = await createStrapiInstance({ bypassAuth: false }); authReq = await createAuthRequest({ strapi }); }); @@ -27,10 +26,14 @@ describe('Test Graphql user service', () => { body: { query: /* GraphQL */ ` mutation { - createUser(input: { data: { username: "test", email: "test", password: "test" } }) { - user { + createUsersPermissionsUser( + data: { username: "test", email: "test", password: "test" } + ) { + data { id - username + attributes { + username + } } } } @@ -40,32 +43,30 @@ describe('Test Graphql user service', () => { expect(res.statusCode).toBe(200); expect(res.body).toMatchObject({ - data: { - createUser: null, - }, + data: null, errors: [ { - message: 'Forbidden', + message: 'Forbidden access', }, ], }); }); - test('createUser is authorized for admins', async () => { + test('createUser is forbidden for admins', async () => { const res = await authReq({ url: '/graphql', method: 'POST', body: { query: /* GraphQL */ ` mutation { - createUser( - input: { - data: { username: "test", email: "test-graphql@strapi.io", password: "test" } - } + createUsersPermissionsUser( + data: { username: "test", email: "test", password: "test" } ) { - user { + data { id - username + attributes { + username + } } } } @@ -73,19 +74,12 @@ describe('Test Graphql user service', () => { }, }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(401); expect(res.body).toMatchObject({ - data: { - createUser: { - user: { - id: expect.anything(), - username: 'test', - }, - }, - }, + error: 'Unauthorized', + message: 'Missing or invalid credentials', + statusCode: 401, }); - - data.user = res.body.data.createUser.user; }); }); @@ -98,15 +92,15 @@ describe('Test Graphql user service', () => { body: { query: /* GraphQL */ ` mutation { - updateUser( - input: { - where: { id: 1 } - data: { username: "test", email: "test", password: "test" } - } + updateUsersPermissionsUser( + id: 1 + data: { username: "test", email: "test", password: "test" } ) { - user { + data { id - username + attributes { + username + } } } } @@ -116,118 +110,105 @@ describe('Test Graphql user service', () => { expect(res.statusCode).toBe(200); expect(res.body).toMatchObject({ - data: { - updateUser: null, - }, + data: null, errors: [ { - message: 'Forbidden', + message: 'Forbidden access', }, ], }); }); - test('updateUser is authorized for admins', async () => { + test('updateUser is forbidden for admins', async () => { const res = await authReq({ url: '/graphql', method: 'POST', body: { query: /* GraphQL */ ` - mutation updateUser($id: ID!) { - updateUser(input: { where: { id: $id }, data: { username: "newUsername" } }) { - user { + mutation { + updateUsersPermissionsUser( + id: 1 + data: { username: "test", email: "test", password: "test" } + ) { + data { id - username + attributes { + username + } } } } `, - variables: { - id: data.user.id, - }, }, }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(401); expect(res.body).toMatchObject({ - data: { - updateUser: { - user: { - id: expect.anything(), - username: 'newUsername', + error: 'Unauthorized', + message: 'Missing or invalid credentials', + statusCode: 401, + }); + }); + + describe('Check deleteUser authorizations', () => { + test('deleteUser is forbidden to public', async () => { + const rq = createRequest({ strapi }); + const res = await rq({ + url: '/graphql', + method: 'POST', + body: { + query: /* GraphQL */ ` + mutation deleteUser { + deleteUsersPermissionsUser(id: 1) { + data { + id + attributes { + username + } + } + } + } + `, + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + data: null, + errors: [ + { + message: 'Forbidden access', }, - }, - }, + ], + }); }); - data.user = res.body.data.updateUser.user; - }); - }); - - describe('Check deleteUser authorizations', () => { - test('deleteUser is forbidden to public', async () => { - const rq = createRequest({ strapi }); - const res = await rq({ - url: '/graphql', - method: 'POST', - body: { - query: /* GraphQL */ ` - mutation deleteUser($id: ID!) { - deleteUser(input: { where: { id: $id } }) { - user { - id - username + test('deleteUser is authorized for admins', async () => { + const res = await authReq({ + url: '/graphql', + method: 'POST', + body: { + query: /* GraphQL */ ` + mutation deleteUser { + deleteUsersPermissionsUser(id: 1) { + data { + id + attributes { + username + } + } } } - } - `, - variables: { - id: data.user.id, + `, }, - }, - }); + }); - expect(res.statusCode).toBe(200); - expect(res.body).toMatchObject({ - data: { - deleteUser: null, - }, - errors: [ - { - message: 'Forbidden', - }, - ], - }); - }); - - test('deleteUser is authorized for admins', async () => { - const res = await authReq({ - url: '/graphql', - method: 'POST', - body: { - query: /* GraphQL */ ` - mutation deleteUser($id: ID!) { - deleteUser(input: { where: { id: $id } }) { - user { - id - username - } - } - } - `, - variables: { - id: data.user.id, - }, - }, - }); - - expect(res.statusCode).toBe(200); - expect(res.body).toMatchObject({ - data: { - deleteUser: { - user: data.user, - }, - }, + expect(res.statusCode).toBe(401); + expect(res.body).toMatchObject({ + error: 'Unauthorized', + message: 'Missing or invalid credentials', + statusCode: 401, + }); }); }); }); diff --git a/packages/plugins/users-permissions/tests/users-graphql.test.e2e.js b/packages/plugins/users-permissions/tests/users-graphql.test.e2e.js index aabfec1779..32af20df1d 100644 --- a/packages/plugins/users-permissions/tests/users-graphql.test.e2e.js +++ b/packages/plugins/users-permissions/tests/users-graphql.test.e2e.js @@ -3,17 +3,23 @@ // Test a simple default API with no relations const { createStrapiInstance } = require('../../../../test/helpers/strapi'); -const { createAuthRequest } = require('../../../../test/helpers/request'); +const { createRequest } = require('../../../../test/helpers/request'); let strapi; let rq; let graphqlQuery; let data = {}; +const user = { + username: 'User 1', + email: 'user1@strapi.io', + password: 'test1234', +}; + describe('Test Graphql Users API End to End', () => { beforeAll(async () => { strapi = await createStrapiInstance(); - rq = await createAuthRequest({ strapi }); + rq = await createRequest({ strapi }); graphqlQuery = body => { return rq({ @@ -29,12 +35,6 @@ describe('Test Graphql Users API End to End', () => { }); describe('Test register and login', () => { - const user = { - username: 'User 1', - email: 'user1@strapi.io', - password: 'test1234', - }; - test('Register a user', async () => { const res = await graphqlQuery({ query: /* GraphQL */ ` @@ -67,6 +67,7 @@ describe('Test Graphql Users API End to End', () => { }, }, }); + data.user = res.body.data.register.user; }); @@ -105,26 +106,29 @@ describe('Test Graphql Users API End to End', () => { }, }, }); + + // Use the JWT returned by the login request to + // authentify the next queries or mutations + rq.setLoggedUser(user).setToken(res.body.data.login.jwt); + data.user = res.body.data.login.user; }); test('Delete a user', async () => { const res = await graphqlQuery({ query: /* GraphQL */ ` - mutation deleteUser($input: deleteUserInput) { - deleteUser(input: $input) { - user { - email + mutation deleteUser($id: ID!) { + deleteUsersPermissionsUser(id: $id) { + data { + attributes { + email + } } } } `, variables: { - input: { - where: { - id: data.user.id, - }, - }, + id: data.user.id, }, }); @@ -133,9 +137,11 @@ describe('Test Graphql Users API End to End', () => { expect(res.statusCode).toBe(200); expect(body).toMatchObject({ data: { - deleteUser: { - user: { - email: data.user.email, + deleteUsersPermissionsUser: { + data: { + attributes: { + email: data.user.email, + }, }, }, }, diff --git a/test/helpers/strapi.js b/test/helpers/strapi.js index 528d10c662..33a1153c53 100644 --- a/test/helpers/strapi.js +++ b/test/helpers/strapi.js @@ -16,19 +16,25 @@ const superAdminLoginInfo = _.pick(superAdminCredentials, ['email', 'password']) const TEST_APP_URL = path.resolve(__dirname, '../../testApp'); -const createStrapiInstance = async ({ ensureSuperAdmin = true, logLevel = 'fatal' } = {}) => { +const createStrapiInstance = async ({ + ensureSuperAdmin = true, + logLevel = 'fatal', + bypassAuth = true, +} = {}) => { const options = { dir: TEST_APP_URL }; const instance = strapi(options); - instance.container.get('auth').register('content-api', { - name: 'test-auth', - authenticate() { - return { authenticated: true }; - }, - verify() { - return; - }, - }); + if (bypassAuth) { + instance.container.get('auth').register('content-api', { + name: 'test-auth', + authenticate() { + return { authenticated: true }; + }, + verify() { + return; + }, + }); + } await instance.load(); diff --git a/yarn.lock b/yarn.lock index 0919d284f5..54fc63fb8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,14 +2,13 @@ # yarn lockfile v1 -"@apollo/federation@^0.20.7": - version "0.20.7" - resolved "https://registry.yarnpkg.com/@apollo/federation/-/federation-0.20.7.tgz#0d26dcc3bbc92c168dc40d4f407f56d26338ef7a" - integrity sha512-URIayksqBaJ+xlcJmyGCf+OqHP60lX2CYGv9fDWQ1KM48sEN1ABHGXkEa0vwgWMH0XUVo94lYDVY11BAJUsuCw== +"@apollo/federation@^0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@apollo/federation/-/federation-0.28.0.tgz#bbfcde3f327b3ec65dcfd98c6f52d6623a38b251" + integrity sha512-M5Dp0XJhuxEOzYjPWWK5VtIqEI1IFRioh1+XHrls90UC8R+b6VXa0UxMO/zfKv00APr4gBODMcfRe5w97NSruw== dependencies: - apollo-graphql "^0.6.0" - apollo-server-env "^2.4.5" - core-js "^3.4.0" + apollo-graphql "^0.9.3" + apollo-server-types "^3.0.2" lodash.xorby "^4.7.0" "@apollo/protobufjs@1.2.2": @@ -31,38 +30,18 @@ "@types/node" "^10.1.0" long "^4.0.0" -"@apollographql/apollo-tools@^0.5.0": +"@apollographql/apollo-tools@^0.5.1": version "0.5.1" resolved "https://registry.yarnpkg.com/@apollographql/apollo-tools/-/apollo-tools-0.5.1.tgz#f0baef739ff7e2fafcb8b98ad29f6ac817e53e32" integrity sha512-ZII+/xUFfb9ezDU2gad114+zScxVFMVlZ91f8fGApMzlS1kkqoyLnC4AJaQ1Ya/X+b63I20B4Gd+eCL8QuB4sA== -"@apollographql/graphql-playground-html@1.6.27": - version "1.6.27" - resolved "https://registry.yarnpkg.com/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.27.tgz#bc9ab60e9445aa2a8813b4e94f152fa72b756335" - integrity sha512-tea2LweZvn6y6xFV11K0KC8ETjmm52mQrW+ezgB2O/aTQf8JGyFmMcRPFgUaQZeHbWdm8iisDC6EjOKsXu0nfw== +"@apollographql/graphql-playground-html@1.6.29": + version "1.6.29" + resolved "https://registry.yarnpkg.com/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.29.tgz#a7a646614a255f62e10dcf64a7f68ead41dec453" + integrity sha512-xCcXpoz52rI4ksJSdOCxeOCn2DLocxwHf9dVT/Q90Pte1LX+LY+91SFtJF3KXVHH8kEin+g1KKCQPKBjZJfWNA== dependencies: xss "^1.0.8" -"@apollographql/graphql-upload-8-fork@^8.1.3": - version "8.1.3" - resolved "https://registry.yarnpkg.com/@apollographql/graphql-upload-8-fork/-/graphql-upload-8-fork-8.1.3.tgz#a0d4e0d5cec8e126d78bd915c264d6b90f5784bc" - integrity sha512-ssOPUT7euLqDXcdVv3Qs4LoL4BPtfermW1IOouaqEmj36TpHYDmYDIbKoSQxikd9vtMumFnP87OybH7sC9fJ6g== - dependencies: - "@types/express" "*" - "@types/fs-capacitor" "*" - "@types/koa" "*" - busboy "^0.3.1" - fs-capacitor "^2.0.4" - http-errors "^1.7.3" - object-path "^0.11.4" - -"@ardatan/aggregate-error@0.0.6": - version "0.0.6" - resolved "https://registry.yarnpkg.com/@ardatan/aggregate-error/-/aggregate-error-0.0.6.tgz#fe6924771ea40fc98dc7a7045c2e872dc8527609" - integrity sha512-vyrkEHG1jrukmzTPtyWB4NLPauUw5bQeg4uhn8f+1SSynmrOcyvlb1GKQjjgoBzElLdfXCRYX8UnBlhklOHYRQ== - dependencies: - tslib "~2.0.1" - "@babel/cli@7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.14.5.tgz#9551b194f02360729de6060785bbdcce52c69f0a" @@ -1636,14 +1615,50 @@ through2 "^3.0.0" xdg-basedir "^3.0.0" -"@graphql-tools/utils@7.2.4": - version "7.2.4" - resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.2.4.tgz#1164cf268988254f281b4cfbbc0e8f7ca24a8a41" - integrity sha512-EDSb98dTWX8FngvayWejip1DutOl0wGtNbXC7a3CZf5fiJS7bGHQ/8cSlMhe9XaHwpLJCbAk/Ijnp/dYbXk33w== +"@graphql-tools/merge@^8.0.2", "@graphql-tools/merge@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.1.0.tgz#e8bdf860f63880ff657cb85de4ac6ab078db67ab" + integrity sha512-Lza419UHgnn0w42wLpviHYmg/k42bdxTsguAaUwfrgMbJ99nyx8/1Owu1ij6k1bc5RN0YynS5N/rLGw7skw8vQ== dependencies: - "@ardatan/aggregate-error" "0.0.6" - camel-case "4.1.2" - tslib "~2.1.0" + "@graphql-tools/utils" "^8.2.0" + tslib "~2.3.0" + +"@graphql-tools/mock@^8.1.2": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@graphql-tools/mock/-/mock-8.3.0.tgz#779780af477914fa872ed9163b6bf55a57e19edf" + integrity sha512-+at9bTfuP60bTFpcMRJ9/KWwJ1E3A+NUVM+15NEp2uHQk4E2l2OO3hnZ4P3JKtMoQY15a/xUVLByC7RqMb7PnA== + dependencies: + "@graphql-tools/schema" "^8.2.0" + "@graphql-tools/utils" "^8.2.0" + fast-json-stable-stringify "^2.1.0" + tslib "~2.3.0" + +"@graphql-tools/schema@8.1.2": + version "8.1.2" + resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-8.1.2.tgz#913879da1a7889a9488e9b7dc189e7c83eff74be" + integrity sha512-rX2pg42a0w7JLVYT+f/yeEKpnoZL5PpLq68TxC3iZ8slnNBNjfVfvzzOn8Q8Q6Xw3t17KP9QespmJEDfuQe4Rg== + dependencies: + "@graphql-tools/merge" "^8.0.2" + "@graphql-tools/utils" "^8.1.1" + tslib "~2.3.0" + value-or-promise "1.0.10" + +"@graphql-tools/schema@^8.0.0", "@graphql-tools/schema@^8.2.0": + version "8.2.0" + resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-8.2.0.tgz#ae75cbb2df6cee9ed6d89fce56be467ab23758dc" + integrity sha512-ufmI5mJQa8NJczzfkh0pUttKvspqDcT5LLakA3jUmOrrE4d4NVj6onZlazdTzF5sAepSNqanFnwhrxZpCAJMKg== + dependencies: + "@graphql-tools/merge" "^8.1.0" + "@graphql-tools/utils" "^8.2.0" + tslib "~2.3.0" + value-or-promise "1.0.10" + +"@graphql-tools/utils@^8.0.0", "@graphql-tools/utils@^8.0.2", "@graphql-tools/utils@^8.1.1", "@graphql-tools/utils@^8.2.0": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.2.1.tgz#381e79fa04041f91f132d252216adcb3970e3c1e" + integrity sha512-xjyetFpDy2/MY8P4+NiE7i1czCrAI36Twjm+jcoBfPctMnJegZkZnLfepmjwYQ92Sv9hnhr+x52OUQAddj29CQ== + dependencies: + tslib "~2.3.0" "@hapi/boom@9.1.4": version "9.1.4" @@ -1887,20 +1902,13 @@ resolved "https://registry.yarnpkg.com/@josephg/resolvable/-/resolvable-1.0.1.tgz#69bc4db754d79e1a2f17a650d3466e038d94a5eb" integrity sha512-CtzORUwWTTOTqfVtHaKRJ0I1kNQd1bpn3sUh8I3nJDVY+5/M/Oe1DnEWzPQvqq/xPIIkzzzIP7mfCoAjFRvDhg== -"@koa/cors@3.1.0": +"@koa/cors@3.1.0", "@koa/cors@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.1.0.tgz#618bb073438cfdbd3ebd0e648a76e33b84f3a3b2" integrity sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q== dependencies: vary "^1.1.2" -"@koa/cors@^2.2.1": - version "2.2.3" - resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-2.2.3.tgz#c32a9907acbee1e72fedfb0b9ac840d2e6f9be57" - integrity sha512-tCVVXa39ETsit5kGBtEWWimjLn1sDaeu8+0phgb8kT3GmBDZOykkI3ZO8nMjV2p3MGkJI4K5P+bxR8Ztq0bwsA== - dependencies: - vary "^1.1.2" - "@koa/router@10.1.1": version "10.1.1" resolved "https://registry.yarnpkg.com/@koa/router/-/router-10.1.1.tgz#8e5a85c9b243e0bc776802c0de564561e57a5f78" @@ -3494,13 +3502,6 @@ dependencies: "@types/node" "*" -"@types/fs-capacitor@*": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@types/fs-capacitor/-/fs-capacitor-2.0.0.tgz#17113e25817f584f58100fb7a08eed288b81956e" - integrity sha512-FKVPOCFbhCvZxpVAMhdBdTfVfXUpsh15wFHgqOKxh9N9vzWZVuWCSijZ5T4U34XYNnuj2oduh6xcs1i+LPI+BQ== - dependencies: - "@types/node" "*" - "@types/glob@^7.1.1": version "7.1.4" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.4.tgz#ea59e21d2ee5c517914cb4bc8e4153b99e566672" @@ -3608,21 +3609,21 @@ dependencies: "@types/node" "*" -"@types/koa-bodyparser@^4.2.1": +"@types/koa-bodyparser@^4.3.0": version "4.3.3" resolved "https://registry.yarnpkg.com/@types/koa-bodyparser/-/koa-bodyparser-4.3.3.tgz#9c7d4295576bc863d550002f732f1c57dd88cc58" integrity sha512-/ileIpXsy1fFEzgZhZ07eZH8rAVL7jwuk/kaoVEfauO6s80g2LIDIJKEyDbuAL9S/BWflKzEC0PHD6aXkmaSbw== dependencies: "@types/koa" "*" -"@types/koa-compose@*", "@types/koa-compose@^3.2.2": +"@types/koa-compose@*", "@types/koa-compose@^3.2.5": version "3.2.5" resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d" integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ== dependencies: "@types/koa" "*" -"@types/koa@*", "@types/koa@^2.0.46": +"@types/koa@*", "@types/koa@^2.11.6": version "2.13.4" resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.4.tgz#10620b3f24a8027ef5cbae88b393d1b31205726b" integrity sha512-dfHYMfU+z/vKtQB7NUrthdAEiSvnLebvBjwHtfFmpZmB7em2N3WVQdHgnFq+xvyVgxW5jKDmjWfLD3lw4g4uTw== @@ -3636,10 +3637,10 @@ "@types/koa-compose" "*" "@types/node" "*" -"@types/koa__cors@^2.2.1": - version "2.2.3" - resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-2.2.3.tgz#227154b4c70439083b33c89627eb780a517cd05b" - integrity sha512-RfG2EuSc+nv/E+xbDSLW8KCoeri/3AkqwVPuENfF/DctllRoXhooboO//Sw7yFtkLvj7nG7O1H3JcZmoTQz8nQ== +"@types/koa__cors@^3.0.1": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.0.3.tgz#49d75813b443ba3d4da28ea6cf6244b7e99a3b23" + integrity sha512-74Xb4hJOPGKlrQ4PRBk1A/p0gfLpgbnpT0o67OMVbwyeMXvlBN+ZCRztAAmkKZs+8hKbgMutUlZVbA52Hr/0IA== dependencies: "@types/koa" "*" @@ -3692,14 +3693,6 @@ "@types/bson" "*" "@types/node" "*" -"@types/node-fetch@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" - integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== - dependencies: - "@types/node" "*" - form-data "^3.0.0" - "@types/node@*": version "16.9.6" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.6.tgz#040a64d7faf9e5d9e940357125f0963012e66f04" @@ -3844,13 +3837,6 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== -"@types/ws@^7.0.0": - version "7.4.7" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702" - integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww== - dependencies: - "@types/node" "*" - "@types/yargs-parser@*": version "20.2.1" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129" @@ -4008,13 +3994,6 @@ resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.5.2.tgz#ea584b637ff63c5a477f6f21604b5a205b72c9ec" integrity sha512-vgJ5OLWadI8aKjDlOH3rb+dYyPd2GTZuQC/Tihjct6F9GpXGZINo3Y/IVuZVTM1eDQB+/AOsjPUWH/WySDaXvw== -"@wry/equality@^0.1.2": - version "0.1.11" - resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790" - integrity sha512-mwEVBDUVODlsQQ5dfuLUS5/Tf7jqUKyhKYHmVi4fPB6bDMOfWvUPJmKgS1Z7Za/sOI3vzWt4+O7yCiL/70MogA== - dependencies: - tslib "^1.9.3" - "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -4062,7 +4041,7 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" -accepts@^1.3.5, accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: +accepts@^1.3.5, accepts@^1.3.7, accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== @@ -4291,7 +4270,7 @@ ansi-styles@^5.0.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== -any-promise@^1.0.0, any-promise@^1.1.0, any-promise@^1.3.0: +any-promise@^1.0.0, any-promise@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= @@ -4312,41 +4291,15 @@ anymatch@^3.0.3, anymatch@~3.1.1, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -apollo-cache-control@^0.14.0: - version "0.14.0" - resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.14.0.tgz#95f20c3e03e7994e0d1bd48c59aeaeb575ed0ce7" - integrity sha512-qN4BCq90egQrgNnTRMUHikLZZAprf3gbm8rC5Vwmc6ZdLolQ7bFsa769Hqi6Tq/lS31KLsXBLTOsRbfPHph12w== +apollo-datasource@^3.0.3, apollo-datasource@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/apollo-datasource/-/apollo-datasource-3.1.0.tgz#44153cb99c7602f4524397ebc8f13e486a010c09" + integrity sha512-ywcVjuWNo84eMB9uBOYygQI+00+Ne4ShyPIxJzT//sn1j1Fu3J+KStMNd6s1jyERWgjGZzxkiLn6nLmwsGymBg== dependencies: - apollo-server-env "^3.1.0" - apollo-server-plugin-base "^0.13.0" + apollo-server-caching "^3.1.0" + apollo-server-env "^4.0.3" -apollo-datasource@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/apollo-datasource/-/apollo-datasource-0.9.0.tgz#b0b2913257a6103a5f4c03cb56d78a30e9d850db" - integrity sha512-y8H99NExU1Sk4TvcaUxTdzfq2SZo6uSj5dyh75XSQvbpH6gdAXIW9MaBcvlNC7n0cVPsidHmOcHOWxJ/pTXGjA== - dependencies: - apollo-server-caching "^0.7.0" - apollo-server-env "^3.1.0" - -apollo-env@^0.6.6: - version "0.6.6" - resolved "https://registry.yarnpkg.com/apollo-env/-/apollo-env-0.6.6.tgz#d7880805c4e96ee3d4142900a405176a04779438" - integrity sha512-hXI9PjJtzmD34XviBU+4sPMOxnifYrHVmxpjykqI/dUD2G3yTiuRaiQqwRwB2RCdwC1Ug/jBfoQ/NHDTnnjndQ== - dependencies: - "@types/node-fetch" "2.5.7" - core-js "^3.0.1" - node-fetch "^2.2.0" - sha.js "^2.4.11" - -apollo-graphql@^0.6.0: - version "0.6.1" - resolved "https://registry.yarnpkg.com/apollo-graphql/-/apollo-graphql-0.6.1.tgz#d0bf0aff76f445de3da10e08f6974f1bf65f5753" - integrity sha512-ZRXAV+k+hboCVS+FW86FW/QgnDR7gm/xMUwJPGXEbV53OLGuQQdIT0NCYK7AzzVkCfsbb7NJ3mmEclkZY9uuxQ== - dependencies: - apollo-env "^0.6.6" - lodash.sortby "^4.7.0" - -apollo-graphql@^0.9.0: +apollo-graphql@^0.9.0, apollo-graphql@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/apollo-graphql/-/apollo-graphql-0.9.3.tgz#1ca6f625322ae10a66f57a39642849a07a7a5dc9" integrity sha512-rcAl2E841Iko4kSzj4Pt3PRBitmyq1MvoEmpl04TQSpGnoVgl1E/ZXuLBYxMTSnEAm7umn2IsoY+c6Ll9U/10A== @@ -4355,153 +4308,118 @@ apollo-graphql@^0.9.0: lodash.sortby "^4.7.0" sha.js "^2.4.11" -apollo-link@^1.2.14: - version "1.2.14" - resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.14.tgz#3feda4b47f9ebba7f4160bef8b977ba725b684d9" - integrity sha512-p67CMEFP7kOG1JZ0ZkYZwRDa369w5PIjtMjvrQd/HnIV8FRsHRqLqK+oAZQnFa1DDdZtOtHTi+aMIW6EatC2jg== - dependencies: - apollo-utilities "^1.3.0" - ts-invariant "^0.4.0" - tslib "^1.9.3" - zen-observable-ts "^0.8.21" - -apollo-reporting-protobuf@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/apollo-reporting-protobuf/-/apollo-reporting-protobuf-0.7.0.tgz#622352d3eea943dff2647741a509b39d464f98a9" - integrity sha512-PC+zDqPPJcseemqmvUEqFiDi45pz6UaPWt6czgmrrbcQ+9VWp6IEkm08V5xBKk7V1WGUw19YwiJ7kqXpcgVNyw== +apollo-reporting-protobuf@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/apollo-reporting-protobuf/-/apollo-reporting-protobuf-3.0.0.tgz#a53966b76a3f373d9336bc953f0bc6dede487270" + integrity sha512-jmCD+6gECt8KS7PxP460hztT/5URTbv2Kg0zgnR6iWPGce88IBmSUjcqf1Z6wJJq7Teb8Hu7WbyyMhn0vN5TxQ== dependencies: "@apollo/protobufjs" "1.2.2" -apollo-reporting-protobuf@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/apollo-reporting-protobuf/-/apollo-reporting-protobuf-0.8.0.tgz#ae9d967934d3d8ed816fc85a0d8068ef45c371b9" - integrity sha512-B3XmnkH6Y458iV6OsA7AhfwvTgeZnFq9nPVjbxmLKnvfkEl8hYADtz724uPa0WeBiD7DSFcnLtqg9yGmCkBohg== - dependencies: - "@apollo/protobufjs" "1.2.2" - -apollo-server-caching@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/apollo-server-caching/-/apollo-server-caching-0.7.0.tgz#e6d1e68e3bb571cba63a61f60b434fb771c6ff39" - integrity sha512-MsVCuf/2FxuTFVhGLK13B+TZH9tBd2qkyoXKKILIiGcZ5CDUEBO14vIV63aNkMkS1xxvK2U4wBcuuNj/VH2Mkw== +apollo-server-caching@^3.0.1, apollo-server-caching@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/apollo-server-caching/-/apollo-server-caching-3.1.0.tgz#c68f2159ad8a25a0bdbb18ad6bdbbde59cd4647d" + integrity sha512-bZ4bo0kSAsax9LbMQPlpuMTkQ657idF2ehOYe4Iw+8vj7vfAYa39Ii9IlaVAFMC1FxCYzLNFz+leZBm/Stn/NA== dependencies: lru-cache "^6.0.0" -apollo-server-core@^2.24.0: - version "2.25.2" - resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.25.2.tgz#ff65da5e512d9b5ca54c8e5e8c78ee28b5987247" - integrity sha512-lrohEjde2TmmDTO7FlOs8x5QQbAS0Sd3/t0TaK2TWaodfzi92QAvIsq321Mol6p6oEqmjm8POIDHW1EuJd7XMA== +apollo-server-core@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-3.1.2.tgz#a9d24b9453b7aad89df464f6527d80e2f46b0a6f" + integrity sha512-bFmzPDGBT97vMzdhhjlycL9Ey4YDa0eCVaHjI5TcYQM8Vphzvndd033DvvQFVRPWoZr8uwupeUyVa82Ne/iM6A== dependencies: - "@apollographql/apollo-tools" "^0.5.0" - "@apollographql/graphql-playground-html" "1.6.27" - "@apollographql/graphql-upload-8-fork" "^8.1.3" + "@apollographql/apollo-tools" "^0.5.1" + "@apollographql/graphql-playground-html" "1.6.29" + "@graphql-tools/mock" "^8.1.2" + "@graphql-tools/schema" "^8.0.0" + "@graphql-tools/utils" "^8.0.0" "@josephg/resolvable" "^1.0.0" - "@types/ws" "^7.0.0" - apollo-cache-control "^0.14.0" - apollo-datasource "^0.9.0" + apollo-datasource "^3.0.3" apollo-graphql "^0.9.0" - apollo-reporting-protobuf "^0.8.0" - apollo-server-caching "^0.7.0" - apollo-server-env "^3.1.0" - apollo-server-errors "^2.5.0" - apollo-server-plugin-base "^0.13.0" - apollo-server-types "^0.9.0" - apollo-tracing "^0.15.0" + apollo-reporting-protobuf "^3.0.0" + apollo-server-caching "^3.0.1" + apollo-server-env "^4.0.3" + apollo-server-errors "^3.0.1" + apollo-server-plugin-base "^3.1.1" + apollo-server-types "^3.1.1" async-retry "^1.2.1" - fast-json-stable-stringify "^2.0.0" - graphql-extensions "^0.15.0" + fast-json-stable-stringify "^2.1.0" graphql-tag "^2.11.0" - graphql-tools "^4.0.8" - loglevel "^1.6.7" + loglevel "^1.6.8" lru-cache "^6.0.0" sha.js "^2.4.11" - subscriptions-transport-ws "^0.9.19" uuid "^8.0.0" -apollo-server-env@^2.4.5: - version "2.4.5" - resolved "https://registry.yarnpkg.com/apollo-server-env/-/apollo-server-env-2.4.5.tgz#73730b4f0439094a2272a9d0caa4079d4b661d5f" - integrity sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA== +apollo-server-core@^3.1.2: + version "3.3.0" + resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-3.3.0.tgz#f973c6f755884f8e17452cb9022672ae6f0ed9e7" + integrity sha512-KmkzKVG3yjybouDyUX6Melv39u1EOFipvAKP17IlPis/TjVbubJmb6hkE0am/g2RipyhRvlpxAjHqPaCTXR1dQ== dependencies: - node-fetch "^2.1.2" - util.promisify "^1.0.0" + "@apollographql/apollo-tools" "^0.5.1" + "@apollographql/graphql-playground-html" "1.6.29" + "@graphql-tools/mock" "^8.1.2" + "@graphql-tools/schema" "^8.0.0" + "@graphql-tools/utils" "^8.0.0" + "@josephg/resolvable" "^1.0.0" + apollo-datasource "^3.1.0" + apollo-graphql "^0.9.0" + apollo-reporting-protobuf "^3.0.0" + apollo-server-caching "^3.1.0" + apollo-server-env "^4.0.3" + apollo-server-errors "^3.1.0" + apollo-server-plugin-base "^3.2.0" + apollo-server-types "^3.2.0" + async-retry "^1.2.1" + fast-json-stable-stringify "^2.1.0" + graphql-tag "^2.11.0" + loglevel "^1.6.8" + lru-cache "^6.0.0" + sha.js "^2.4.11" + uuid "^8.0.0" -apollo-server-env@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/apollo-server-env/-/apollo-server-env-3.1.0.tgz#0733c2ef50aea596cc90cf40a53f6ea2ad402cd0" - integrity sha512-iGdZgEOAuVop3vb0F2J3+kaBVi4caMoxefHosxmgzAbbSpvWehB8Y1QiSyyMeouYC38XNVk5wnZl+jdGSsWsIQ== +apollo-server-env@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/apollo-server-env/-/apollo-server-env-4.0.3.tgz#082a5c1dd4dfb3b34de5e1fa7dc170dd15a5062f" + integrity sha512-B32+RUOM4GUJAwnQqQE1mT1BG7+VfW3a0A87Bp3gv/q8iNnhY2BIWe74Qn03pX8n27g3EGVCt0kcBuHhjG5ltA== dependencies: node-fetch "^2.6.1" - util.promisify "^1.0.0" -apollo-server-errors@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.5.0.tgz#5d1024117c7496a2979e3e34908b5685fe112b68" - integrity sha512-lO5oTjgiC3vlVg2RKr3RiXIIQ5pGXBFxYGGUkKDhTud3jMIhs+gel8L8zsEjKaKxkjHhCQAA/bcEfYiKkGQIvA== +apollo-server-errors@^3.0.1, apollo-server-errors@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-3.1.0.tgz#0b890dc7ae36a1f0ca4841d353e8d1c3c6524ee2" + integrity sha512-bUmobPEvtcBFt+OVHYqD390gacX/Cm5s5OI5gNZho8mYKAA6OjgnRlkm/Lti6NzniXVxEQyD5vjkC6Ox30mGFg== -apollo-server-koa@2.24.0: - version "2.24.0" - resolved "https://registry.yarnpkg.com/apollo-server-koa/-/apollo-server-koa-2.24.0.tgz#696673df68342e12abc036054759bcb460686168" - integrity sha512-cv1pWPd/gEuLdq3eXsvWDzuznIRkAlS4O+q0zKeldLQM9aEGa0Z0SeYMZ7zZcX/wBeGRrPx6Bn43bbWNcEHlfg== +apollo-server-koa@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/apollo-server-koa/-/apollo-server-koa-3.1.2.tgz#dac33a32e07e744da48c74be26a59503b302b099" + integrity sha512-mR7qm0xYm5jCBelKeodcBqNqGnFnPPRDCGXyOSOshv8vYLkg58IctjVGFgad9m8Az6ZOqrYYO1clF/sXHo0gJQ== dependencies: - "@apollographql/graphql-playground-html" "1.6.27" - "@koa/cors" "^2.2.1" + "@koa/cors" "^3.1.0" "@types/accepts" "^1.3.5" - "@types/koa" "^2.0.46" - "@types/koa-bodyparser" "^4.2.1" - "@types/koa-compose" "^3.2.2" - "@types/koa__cors" "^2.2.1" - accepts "^1.3.5" - apollo-server-core "^2.24.0" - apollo-server-types "^0.8.0" - graphql-subscriptions "^1.0.0" - graphql-tools "^4.0.8" - koa "2.13.1" - koa-bodyparser "^4.2.1" + "@types/koa" "^2.11.6" + "@types/koa-bodyparser" "^4.3.0" + "@types/koa-compose" "^3.2.5" + "@types/koa__cors" "^3.0.1" + accepts "^1.3.7" + apollo-server-core "^3.1.2" + apollo-server-types "^3.1.1" + koa-bodyparser "^4.3.0" koa-compose "^4.1.0" - type-is "^1.6.16" -apollo-server-plugin-base@^0.13.0: - version "0.13.0" - resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.13.0.tgz#3f85751a420d3c4625355b6cb3fbdd2acbe71f13" - integrity sha512-L3TMmq2YE6BU6I4Tmgygmd0W55L+6XfD9137k+cWEBFu50vRY4Re+d+fL5WuPkk5xSPKd/PIaqzidu5V/zz8Kg== +apollo-server-plugin-base@^3.1.1, apollo-server-plugin-base@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-3.2.0.tgz#415337a0b1b88fc1d5f5620130a51e2935dd8dbf" + integrity sha512-anjyiw79wxU4Cj2bYZFWQqZPjuaZ4mVJvxCoyvkFrNvjPua9dovCOfpng43C5NwdsqJpz78Vqs236eFM2QoeaA== dependencies: - apollo-server-types "^0.9.0" + apollo-server-types "^3.2.0" -apollo-server-types@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.8.0.tgz#5462c99e93c5b6896d686bc234c05850059b2efe" - integrity sha512-adHJnHbRV2kWUY0VQY1M2KpSdGfm+4mX4w+2lROPExqOnkyTI7CGfpJCdEwYMKrIn3aH8HIcOH0SnpWRet6TNw== +apollo-server-types@^3.0.2, apollo-server-types@^3.1.1, apollo-server-types@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-3.2.0.tgz#6243b34d35fbb09ded2cc84bf7e5f59968ccfa21" + integrity sha512-Fh7QP84ufDZHbLzoLyyxyzznlW8cpgEZYYkGsS1i36zY4VaAt5OUOp1f+FxWdLGehq0Arwb6D1W7y712IoZ/JQ== dependencies: - apollo-reporting-protobuf "^0.7.0" - apollo-server-caching "^0.7.0" - apollo-server-env "^3.1.0" - -apollo-server-types@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.9.0.tgz#ccf550b33b07c48c72f104fbe2876232b404848b" - integrity sha512-qk9tg4Imwpk732JJHBkhW0jzfG0nFsLqK2DY6UhvJf7jLnRePYsPxWfPiNkxni27pLE2tiNlCwoDFSeWqpZyBg== - dependencies: - apollo-reporting-protobuf "^0.8.0" - apollo-server-caching "^0.7.0" - apollo-server-env "^3.1.0" - -apollo-tracing@^0.15.0: - version "0.15.0" - resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.15.0.tgz#237fbbbf669aee4370b7e9081b685eabaa8ce84a" - integrity sha512-UP0fztFvaZPHDhIB/J+qGuy6hWO4If069MGC98qVs0I8FICIGu4/8ykpX3X3K6RtaQ56EDAWKykCxFv4ScxMeA== - dependencies: - apollo-server-env "^3.1.0" - apollo-server-plugin-base "^0.13.0" - -apollo-utilities@^1.0.1, apollo-utilities@^1.3.0: - version "1.3.4" - resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.4.tgz#6129e438e8be201b6c55b0f13ce49d2c7175c9cf" - integrity sha512-pk2hiWrCXMAy2fRPwEyhvka+mqwzeP60Jr1tRYi5xru+3ko94HI9o6lK0CT33/w4RDlxWchmdhDCrvdr+pHCig== - dependencies: - "@wry/equality" "^0.1.2" - fast-json-stable-stringify "^2.0.0" - ts-invariant "^0.4.0" - tslib "^1.10.0" + apollo-reporting-protobuf "^3.0.0" + apollo-server-caching "^3.1.0" + apollo-server-env "^4.0.3" aproba@^1.0.3: version "1.2.0" @@ -5010,11 +4928,6 @@ babel-preset-jest@^26.6.2: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" -backo2@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" - integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= - bail@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" @@ -5569,14 +5482,6 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camel-case@4.1.2, camel-case@^4.1.1: - version "4.1.2" - resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" - integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== - dependencies: - pascal-case "^3.1.2" - tslib "^2.0.3" - camel-case@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" @@ -5585,6 +5490,14 @@ camel-case@^3.0.0: no-case "^2.2.0" upper-case "^1.1.1" +camel-case@^4.1.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" + integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== + dependencies: + pascal-case "^3.1.2" + tslib "^2.0.3" + camelcase-keys@^6.2.2: version "6.2.2" resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0" @@ -6518,7 +6431,7 @@ core-js@^2.6.5: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== -core-js@^3.0.1, core-js@^3.4.0, core-js@^3.6.4: +core-js@^3.6.4: version "3.18.0" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.18.0.tgz#9af3f4a6df9ba3428a3fb1b171f1503b3f40cc49" integrity sha512-WJeQqq6jOYgVgg4NrXKL0KLQhi0CT4ZOCvFL+3CQ5o7I6J8HkT5wd53EadMfqTDp1so/MT1J+w2ujhWcCJtN7w== @@ -6924,11 +6837,6 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" -dataloader@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-1.4.0.tgz#bca11d867f5d3f1b9ed9f737bd15970c65dff5c8" - integrity sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw== - date-and-time@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/date-and-time/-/date-and-time-0.6.3.tgz#2daee52df67c28bd93bce862756ac86b68cf4237" @@ -6963,7 +6871,7 @@ debug@2.2.0: dependencies: ms "0.7.1" -debug@3.1.0, debug@~3.1.0: +debug@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== @@ -7207,11 +7115,6 @@ depd@^2.0.0, depd@~2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -deprecated-decorator@^0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/deprecated-decorator/-/deprecated-decorator-0.1.6.tgz#00966317b7a12fe92f3cc831f7583af329b86c37" - integrity sha1-AJZjF7ehL+kvPMgx91g68ym4bDc= - deprecation@^2.0.0, deprecation@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" @@ -8212,11 +8115,6 @@ eventemitter2@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-5.0.1.tgz#6197a095d5fb6b57e8942f6fd7eaad63a09c9452" integrity sha1-YZegldX7a1folC9v1+qtY6CclFI= -eventemitter3@^3.1.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" - integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== - eventemitter3@^4.0.0, eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" @@ -8476,7 +8374,7 @@ fast-json-patch@^2.1.0: dependencies: fast-deep-equal "^2.0.1" -fast-json-stable-stringify@^2.0.0: +fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -8712,13 +8610,6 @@ font-awesome@^4.7.0: resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" integrity sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM= -for-each@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" - integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== - dependencies: - is-callable "^1.1.3" - for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -8811,12 +8702,7 @@ from2@^2.3.0: inherits "^2.0.1" readable-stream "^2.0.0" -fs-capacitor@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/fs-capacitor/-/fs-capacitor-2.0.4.tgz#5a22e72d40ae5078b4fe64fe4d08c0d3fc88ad3c" - integrity sha512-8S4f4WsCryNw2mJJchi46YgB6CR5Ze+4L1h8ewl9tEpL4SJ3ZO+c/bS4BWhB8bK+O3TMqhuZarTitd0S0eh2pA== - -fs-capacitor@^6.1.0: +fs-capacitor@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/fs-capacitor/-/fs-capacitor-6.2.0.tgz#fa79ac6576629163cb84561995602d8999afb7f5" integrity sha512-nKcE1UduoSKX27NSZlg879LdQc94OtbOsEmKMN2MBNudXREvijRKx2GEBsTMTfws+BrbkJoEuynbGSVRSpauvw== @@ -9192,7 +9078,7 @@ glob@7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" -glob@7.2.0, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: +glob@7.2.0, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7: version "7.2.0" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== @@ -9393,15 +9279,6 @@ graphql-depth-limit@^1.1.0: dependencies: arrify "^1.0.1" -graphql-extensions@^0.15.0: - version "0.15.0" - resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.15.0.tgz#3f291f9274876b0c289fa4061909a12678bd9817" - integrity sha512-bVddVO8YFJPwuACn+3pgmrEg6I8iBuYLuwvxiE+lcQQ7POotVZxm2rgGw0PvVYmWWf3DT7nTVDZ5ROh/ALp8mA== - dependencies: - "@apollographql/apollo-tools" "^0.5.0" - apollo-server-env "^3.1.0" - apollo-server-types "^0.9.0" - graphql-iso-date@^3.6.1: version "3.6.1" resolved "https://registry.yarnpkg.com/graphql-iso-date/-/graphql-iso-date-3.6.1.tgz#bd2d0dc886e0f954cbbbc496bbf1d480b57ffa96" @@ -9421,13 +9298,6 @@ graphql-playground-middleware-koa@^1.6.21: dependencies: graphql-playground-html "^1.6.29" -graphql-subscriptions@^1.0.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/graphql-subscriptions/-/graphql-subscriptions-1.2.1.tgz#2142b2d729661ddf967b7388f7cf1dd4cf2e061d" - integrity sha512-95yD/tKi24q8xYa7Q9rhQN16AYj5wPbrb8tmHGM3WRc9EBmWrG/0kkMl+tQG8wcEuE9ibR4zyOM31p5Sdr2v4g== - dependencies: - iterall "^1.3.0" - graphql-tag@^2.11.0: version "2.12.5" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.5.tgz#5cff974a67b417747d05c8d9f5f3cb4495d0db8f" @@ -9435,18 +9305,7 @@ graphql-tag@^2.11.0: dependencies: tslib "^2.1.0" -graphql-tools@4.0.8, graphql-tools@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/graphql-tools/-/graphql-tools-4.0.8.tgz#e7fb9f0d43408fb0878ba66b522ce871bafe9d30" - integrity sha512-MW+ioleBrwhRjalKjYaLQbr+920pHBgy9vM/n47sswtns8+96sRn5M/G+J1eu7IMeKWiN/9p6tmwCHU7552VJg== - dependencies: - apollo-link "^1.2.14" - apollo-utilities "^1.0.1" - deprecated-decorator "^0.1.6" - iterall "^1.1.3" - uuid "^3.1.0" - -graphql-type-json@0.3.2: +graphql-type-json@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/graphql-type-json/-/graphql-type-json-0.3.2.tgz#f53a851dbfe07bd1c8157d24150064baab41e115" integrity sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg== @@ -9456,21 +9315,21 @@ graphql-type-long@^0.1.1: resolved "https://registry.yarnpkg.com/graphql-type-long/-/graphql-type-long-0.1.1.tgz#c1b1323f7b3bb3fe48f05502b883145f90adbfd6" integrity sha512-pIp/F3LR0qqfbF4TX3CwBwPskA7850KG6/DnzaYJtdgxxzw20dcKfutLbyk8okBGg8iHbFoXgnZWHgfwNmxSZw== -graphql-upload@11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/graphql-upload/-/graphql-upload-11.0.0.tgz#24b245ff18f353bab6715e8a055db9fd73035e10" - integrity sha512-zsrDtu5gCbQFDWsNa5bMB4nf1LpKX9KDgh+f8oL1288ijV4RxeckhVozAjqjXAfRpxOHD1xOESsh6zq8SjdgjA== +graphql-upload@12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/graphql-upload/-/graphql-upload-12.0.0.tgz#2351d20d294e920fb25d2eba9f7c352e37a1a02b" + integrity sha512-ovZ3Q7sZ17Bmn8tYl22MfrpNR7nYM/DUszXWgkue7SFIlI9jtqszHAli8id8ZcnGBc9GF0gUTNSskYWW+5aNNQ== dependencies: busboy "^0.3.1" - fs-capacitor "^6.1.0" - http-errors "^1.7.3" + fs-capacitor "^6.2.0" + http-errors "^1.8.0" isobject "^4.0.0" - object-path "^0.11.4" + object-path "^0.11.5" -graphql@15.5.0: - version "15.5.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" - integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== +graphql@15.5.1: + version "15.5.1" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.1.tgz#f2f84415d8985e7b84731e7f3536f8bb9d383aad" + integrity sha512-FeTRX67T3LoE3LWAxxOlW2K3Bz+rMYAC18rRguK4wgXaTZMiJwSUwDmPFo3UadAKbzirKIg5Qy+sNJXbpPRnQw== growl@1.9.2: version "1.9.2" @@ -10459,7 +10318,7 @@ is-buffer@^2.0.0, is-buffer@^2.0.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== -is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.4: +is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== @@ -10988,7 +10847,7 @@ istanbul@~0.4.2: which "^1.1.1" wordwrap "^1.0.0" -iterall@^1.1.3, iterall@^1.2.1, iterall@^1.3.0: +iterall@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== @@ -11787,7 +11646,7 @@ koa-body@4.2.0: co-body "^5.1.1" formidable "^1.1.1" -koa-bodyparser@^4.2.1: +koa-bodyparser@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/koa-bodyparser/-/koa-bodyparser-4.3.0.tgz#274c778555ff48fa221ee7f36a9fbdbace22759a" integrity sha512-uyV8G29KAGwZc4q/0WUAjH+Tsmuv9ImfBUF2oZVyZtaeo0husInagyn/JH85xMSxM0hEk/mbCII5ubLDuqW/Rw== @@ -11800,13 +11659,6 @@ koa-compose@4.1.0, koa-compose@^4.1.0: resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877" integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw== -koa-compose@^3.0.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-3.2.1.tgz#a85ccb40b7d986d8e5a345b3a1ace8eabcf54de7" - integrity sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec= - dependencies: - any-promise "^1.1.0" - koa-compress@5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/koa-compress/-/koa-compress-5.1.0.tgz#7b9fe24f4c1b28d9cae90864597da472c2fcf701" @@ -11818,14 +11670,6 @@ koa-compress@5.1.0: koa-is-json "^1.0.0" statuses "^2.0.1" -koa-convert@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-1.2.0.tgz#da40875df49de0539098d1700b50820cebcd21d0" - integrity sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA= - dependencies: - co "^4.6.0" - koa-compose "^3.0.0" - koa-convert@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5" @@ -11912,35 +11756,6 @@ koa2-ratelimit@^0.9.0: promise-redis "0.0.5" sequelize "^5.8.7" -koa@2.13.1: - version "2.13.1" - resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.1.tgz#6275172875b27bcfe1d454356a5b6b9f5a9b1051" - integrity sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w== - dependencies: - accepts "^1.3.5" - cache-content-type "^1.0.0" - content-disposition "~0.5.2" - content-type "^1.0.4" - cookies "~0.8.0" - debug "~3.1.0" - delegates "^1.0.0" - depd "^2.0.0" - destroy "^1.0.4" - encodeurl "^1.0.2" - escape-html "^1.0.3" - fresh "~0.5.2" - http-assert "^1.3.0" - http-errors "^1.6.3" - is-generator-function "^1.0.7" - koa-compose "^4.1.0" - koa-convert "^1.2.0" - on-finished "^2.3.0" - only "~0.0.2" - parseurl "^1.3.2" - statuses "^1.5.0" - type-is "^1.6.16" - vary "^1.1.2" - koa@2.13.3, koa@^2.13.1: version "2.13.3" resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.3.tgz#a62641ba753ec54bee2c6da1a4f294c5fac35407" @@ -12396,7 +12211,7 @@ logform@^2.2.0: safe-stable-stringify "^1.1.0" triple-beam "^1.3.0" -loglevel@^1.6.7, loglevel@^1.6.8: +loglevel@^1.6.8: version "1.7.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197" integrity sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw== @@ -13381,6 +13196,14 @@ netmask@^1.0.6: resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35" integrity sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU= +nexus@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/nexus/-/nexus-1.1.0.tgz#3d8fa05c29e7a61aa55f64ef5e0ba43dd76b3ed6" + integrity sha512-jUhbg22gKVY2YwZm726BrbfHaQ7Xzc0hNXklygDhuqaVxCuHCgFMhWa2svNWd1npe8kfeiu5nbwnz+UnhNXzCQ== + dependencies: + iterall "^1.3.0" + tslib "^2.0.3" + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -13418,7 +13241,7 @@ node-fetch@2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@2.6.5, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.6.1: +node-fetch@2.6.5, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.6.1: version "2.6.5" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.5.tgz#42735537d7f080a7e5f78b6c549b7146be1742fd" integrity sha512-mmlIVHJEu5rnIxgEgez6b9GgWXbkZj5YZ7fx+2r94a2E+Uirsp6HsPTPlomfdHtpt/B0cdKviwkoaM6pyvUOpQ== @@ -13888,10 +13711,10 @@ object-keys@^1.0.12, object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object-path@^0.11.4: - version "0.11.8" - resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.8.tgz#ed002c02bbdd0070b78a27455e8ae01fc14d4742" - integrity sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA== +object-path@^0.11.5: + version "0.11.7" + resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.7.tgz#5f211161f34bb395e4b13a5f565b79d933b6f65d" + integrity sha512-T4evaK9VfGGQskXBDILcn6F90ZD+WO3OwRFFQ2rmZdUH4vQeDBpiolTpVlPY2yj5xSepyILTjDyM6UvbbdHMZw== object-visit@^1.0.0: version "1.0.1" @@ -13939,7 +13762,7 @@ object.fromentries@^2.0.3, object.fromentries@^2.0.4: es-abstract "^1.18.0-next.2" has "^1.0.3" -object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0, object.getownpropertydescriptors@^2.1.1: +object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.2.tgz#1bd63aeacf0d5d2d2f31b5e393b03a7c601a23f7" integrity sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ== @@ -18093,17 +17916,6 @@ stylis@^4.0.3: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.0.10.tgz#446512d1097197ab3f02fb3c258358c3f7a14240" integrity sha512-m3k+dk7QeJw660eIKRRn3xPF6uuvHs/FFzjX3HQ5ove0qYsiygoAhwn5a3IYKaZPo5LrYD0rfVmtv1gNY1uYwg== -subscriptions-transport-ws@^0.9.19: - version "0.9.19" - resolved "https://registry.yarnpkg.com/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.19.tgz#10ca32f7e291d5ee8eb728b9c02e43c52606cdcf" - integrity sha512-dxdemxFFB0ppCLg10FTtRqH/31FNRL1y1BQv8209MK5I4CwALb7iihQg+7p65lFcIl8MHatINWBLOqpgU4Kyyw== - dependencies: - backo2 "^1.0.2" - eventemitter3 "^3.1.0" - iterall "^1.2.1" - symbol-observable "^1.0.4" - ws "^5.2.0 || ^6.0.0 || ^7.0.0" - sugarss@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-2.0.0.tgz#ddd76e0124b297d40bf3cca31c8b22ecb43bc61d" @@ -18226,11 +18038,6 @@ swap-case@^1.1.0: lower-case "^1.1.1" upper-case "^1.1.1" -symbol-observable@^1.0.4: - version "1.2.0" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" - integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== - symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -18640,13 +18447,6 @@ trough@^1.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== -ts-invariant@^0.4.0: - version "0.4.4" - resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" - integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA== - dependencies: - tslib "^1.9.3" - tsconfig-paths@^3.11.0: version "3.11.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.11.0.tgz#954c1fe973da6339c78e06b03ce2e48810b65f36" @@ -18662,16 +18462,11 @@ tslib@^1.10.0, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0: +tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@~2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== -tslib@~2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" - integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== - tslib@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" @@ -19057,17 +18852,6 @@ util-promisify@^2.1.0: dependencies: object.getownpropertydescriptors "^2.0.3" -util.promisify@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.1.1.tgz#77832f57ced2c9478174149cae9b96e9918cd54b" - integrity sha512-/s3UsZUrIfa6xDhr7zZhnE9SLQ5RIXyYfiVnMMyMDzOc8WhWN4Nbh36H842OyurKbCDAesZOJaVyvmSl6fhGQw== - dependencies: - call-bind "^1.0.0" - define-properties "^1.1.3" - for-each "^0.3.3" - has-symbols "^1.0.1" - object.getownpropertydescriptors "^2.1.1" - util.promisify@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.1.tgz#6baf7774b80eeb0f7520d8b81d07982a59abbaee" @@ -19166,6 +18950,11 @@ value-equal@^1.0.1: resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== +value-or-promise@1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.10.tgz#5bf041f1e9a8e7043911875547636768a836e446" + integrity sha512-1OwTzvcfXkAfabk60UVr5NdjtjJ0Fg0T5+B1bhxtrOEwSH2fe8y4DnLgoksfCyd8yZCOQQHB0qLMQnwgCjbXLQ== + vary@^1.1.2, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -19671,11 +19460,6 @@ write-pkg@^4.0.0: type-fest "^0.4.1" write-json-file "^3.2.0" -"ws@^5.2.0 || ^6.0.0 || ^7.0.0", ws@^7.3.1, ws@^7.4.6: - version "7.5.5" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" - integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w== - ws@^6.2.1: version "6.2.2" resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.2.tgz#dd5cdbd57a9979916097652d78f1cc5faea0c32e" @@ -19683,6 +19467,16 @@ ws@^6.2.1: dependencies: async-limiter "~1.0.0" +ws@^7.3.1: + version "7.5.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.3.tgz#160835b63c7d97bfab418fc1b8a9fced2ac01a74" + integrity sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg== + +ws@^7.4.6: + version "7.5.5" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" + integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w== + xdg-basedir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" @@ -19867,19 +19661,6 @@ yup@0.32.9, yup@^0.32.9: property-expr "^2.0.4" toposort "^2.0.2" -zen-observable-ts@^0.8.21: - version "0.8.21" - resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz#85d0031fbbde1eba3cd07d3ba90da241215f421d" - integrity sha512-Yj3yXweRc8LdRMrCC8nIc4kkjWecPAUVh0TI0OUrWXx6aX790vLcDlWca6I4vsyCGH3LpWxq0dJRcMOFoVqmeg== - dependencies: - tslib "^1.9.3" - zen-observable "^0.8.0" - -zen-observable@^0.8.0: - version "0.8.15" - resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" - integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== - zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"