mirror of
https://github.com/strapi/strapi.git
synced 2025-12-26 14:44:31 +00:00
Merge branch 'releases/v4' into v4/component-relations
This commit is contained in:
commit
d51b783ddd
1
.gitignore
vendored
1
.gitignore
vendored
@ -120,6 +120,7 @@ dist
|
||||
############################
|
||||
|
||||
packages/generators/app/files/public/
|
||||
schema.graphql
|
||||
|
||||
############################
|
||||
# Example app
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -79,8 +79,7 @@
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "city"
|
||||
"type": "uid"
|
||||
},
|
||||
"notrepeat_req": {
|
||||
"type": "component",
|
||||
|
||||
@ -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);
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = strapi => {
|
||||
if (strapi.plugin('graphql')) {
|
||||
require('./graphql')({ strapi });
|
||||
}
|
||||
};
|
||||
@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
resolver: {
|
||||
Mutation: {
|
||||
createTest: false,
|
||||
updateTest: false,
|
||||
deleteTest: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -3,12 +3,7 @@ module.exports = {
|
||||
testMatch: ['**/?(*.)+(spec|test).e2e.js'],
|
||||
testEnvironment: 'node',
|
||||
setupFilesAfterEnv: ['<rootDir>/test/jest2e2.setup.js'],
|
||||
testPathIgnorePatterns: [
|
||||
'<rootDir>/packages/plugins/graphql',
|
||||
'graphql.test.e2e.js',
|
||||
'graphqlUpload.test.e2e.js',
|
||||
'<rootDir>/packages/core/database.old',
|
||||
],
|
||||
testPathIgnorePatterns: ['<rootDir>/packages/core/database.old'],
|
||||
coveragePathIgnorePatterns: [
|
||||
'<rootDir>/dist/',
|
||||
'<rootDir>/node_modules/',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -838,6 +838,10 @@ const createEntityManager = db => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry[field];
|
||||
},
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
186
packages/core/upload/server/graphql.js
Normal file
186
packages/core/upload/server/graphql.js
Normal file
@ -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<object>}
|
||||
*/
|
||||
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] };
|
||||
});
|
||||
};
|
||||
7
packages/core/upload/server/register.js
Normal file
7
packages/core/upload/server/register.js
Normal file
@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = strapi => {
|
||||
if (strapi.plugin('graphql')) {
|
||||
require('./graphql')({ strapi });
|
||||
}
|
||||
};
|
||||
@ -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 });
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
|
||||
84
packages/core/utils/lib/pagination.js
Normal file
84
packages/core/utils/lib/pagination.js
Normal file
@ -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 };
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"routes": []
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
module.exports = {};
|
||||
@ -1,12 +0,0 @@
|
||||
{
|
||||
"endpoint": "/graphql",
|
||||
"shadowCRUD": true,
|
||||
"playgroundAlways": false,
|
||||
"depthLimit": 7,
|
||||
"amountLimit": 100,
|
||||
"shareEnabled": false,
|
||||
"federation": false,
|
||||
"apolloServer": {
|
||||
"tracing": false
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GraphQL.js controller
|
||||
*
|
||||
* @description: A set of functions called "actions" of the `GraphQL` plugin.
|
||||
*/
|
||||
|
||||
module.exports = {};
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"graphql": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@ -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<Object>} schemas - The list of schemas to merge
|
||||
*/
|
||||
const mergeSchemas = schemas => {
|
||||
return schemas.reduce((acc, el) => {
|
||||
const { definition, query, mutation, type, resolver } = el;
|
||||
|
||||
return _.merge(acc, {
|
||||
definition: `${acc.definition || ''} ${definition || ''}`,
|
||||
query: `${acc.query || ''} ${query || ''}`,
|
||||
mutation: `${acc.mutation || ''} ${mutation || ''}`,
|
||||
type,
|
||||
resolver,
|
||||
});
|
||||
}, {});
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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",
|
||||
|
||||
124
packages/plugins/graphql/server/bootstrap.js
vendored
Normal file
124
packages/plugins/graphql/server/bootstrap.js
vendored
Normal file
@ -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();
|
||||
});
|
||||
};
|
||||
@ -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];
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
function buildEntityMetaDefinition(/*contentType*/) {}
|
||||
|
||||
module.exports = () => ({
|
||||
buildEntityMetaDefinition,
|
||||
});
|
||||
43
packages/plugins/graphql/server/services/builders/entity.js
Normal file
43
packages/plugins/graphql/server/services/builders/entity.js
Normal file
@ -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) });
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
24
packages/plugins/graphql/server/services/builders/enums.js
Normal file
24
packages/plugins/graphql/server/services/builders/enums.js
Normal file
@ -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,
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const contentType = require('./content-type');
|
||||
|
||||
module.exports = context => ({
|
||||
...contentType(context),
|
||||
});
|
||||
@ -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) });
|
||||
},
|
||||
});
|
||||
@ -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) });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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) });
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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) });
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
@ -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) });
|
||||
},
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
92
packages/plugins/graphql/server/services/builders/index.js
Normal file
92
packages/plugins/graphql/server/services/builders/index.js
Normal file
@ -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 }),
|
||||
};
|
||||
};
|
||||
118
packages/plugins/graphql/server/services/builders/input.js
Normal file
118
packages/plugins/graphql/server/services/builders/input.js
Normal file
@ -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) });
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
const createCollectionTypeMutationsBuilder = require('./collection-type');
|
||||
const createSingleTypeMutationsBuilder = require('./single-type');
|
||||
|
||||
module.exports = context => ({
|
||||
...createCollectionTypeMutationsBuilder(context),
|
||||
...createSingleTypeMutationsBuilder(context),
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -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<Query>} 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<Query>} 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 };
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
const createCollectionTypeQueriesBuilder = require('./collection-type');
|
||||
const createSingleTypeQueriesBuilder = require('./single-type');
|
||||
|
||||
module.exports = context => ({
|
||||
...createCollectionTypeQueriesBuilder(context),
|
||||
...createSingleTypeQueriesBuilder(context),
|
||||
});
|
||||
@ -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 };
|
||||
};
|
||||
@ -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([])),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = ({ strapi }) => ({
|
||||
buildDynamicZoneResolver({ contentTypeUID, attributeName }) {
|
||||
return async parent => {
|
||||
return strapi.entityService.load(contentTypeUID, parent, attributeName);
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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),
|
||||
});
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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'),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -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'),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
370
packages/plugins/graphql/server/services/builders/type.js
Normal file
370
packages/plugins/graphql/server/services/builders/type.js
Normal file
@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
131
packages/plugins/graphql/server/services/builders/utils.js
Normal file
131
packages/plugins/graphql/server/services/builders/utils.js
Normal file
@ -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<string, 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<string, string>}
|
||||
*/
|
||||
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;
|
||||
},
|
||||
};
|
||||
};
|
||||
147
packages/plugins/graphql/server/services/constants.js
Normal file
147
packages/plugins/graphql/server/services/constants.js
Normal file
@ -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,
|
||||
});
|
||||
168
packages/plugins/graphql/server/services/content-api/index.js
Normal file
168
packages/plugins/graphql/server/services/content-api/index.js
Normal file
@ -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 };
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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,
|
||||
};
|
||||
@ -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 };
|
||||
@ -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,
|
||||
};
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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<void>}
|
||||
*/
|
||||
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<any>}
|
||||
*/
|
||||
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 };
|
||||
@ -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;
|
||||
@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
const createExtension = require('./extension');
|
||||
|
||||
module.exports = createExtension;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user