Merge branch 'releases/v4' into v4/component-relations

This commit is contained in:
Alexandre Bodin 2021-09-28 15:40:42 +02:00
commit d51b783ddd
189 changed files with 7016 additions and 4761 deletions

1
.gitignore vendored
View File

@ -120,6 +120,7 @@ dist
############################
packages/generators/app/files/public/
schema.graphql
############################
# Example app

View File

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

View File

@ -79,8 +79,7 @@
}
},
"slug": {
"type": "uid",
"targetField": "city"
"type": "uid"
},
"notrepeat_req": {
"type": "component",

View File

@ -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);
};

View File

@ -0,0 +1,7 @@
'use strict';
module.exports = strapi => {
if (strapi.plugin('graphql')) {
require('./graphql')({ strapi });
}
};

View File

@ -1,9 +0,0 @@
module.exports = {
resolver: {
Mutation: {
createTest: false,
updateTest: false,
deleteTest: false,
},
},
};

View File

@ -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,

View File

@ -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/',

View File

@ -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,

View File

@ -838,6 +838,10 @@ const createEntityManager = db => {
},
});
if (!entry) {
return null;
}
return entry[field];
},

View File

@ -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

View File

@ -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);

View File

@ -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) => {

View File

@ -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}`);

View File

@ -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;
},
};
};

View File

@ -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,
}));

View File

@ -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);
},
});

View File

@ -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,
};

View File

@ -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',
},
});

View 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] };
});
};

View File

@ -0,0 +1,7 @@
'use strict';
module.exports = strapi => {
if (strapi.plugin('graphql')) {
require('./graphql')({ strapi });
}
};

View File

@ -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 });
};

View File

@ -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,

View File

@ -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',
},
},
},
},
});

View File

@ -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,
};

View 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 };

View File

@ -1,3 +0,0 @@
{
"routes": []
}

View File

@ -1 +0,0 @@
module.exports = {};

View File

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

View File

@ -1,9 +0,0 @@
'use strict';
/**
* GraphQL.js controller
*
* @description: A set of functions called "actions" of the `GraphQL` plugin.
*/
module.exports = {};

View File

@ -1,5 +0,0 @@
{
"graphql": {
"enabled": true
}
}

View File

@ -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,
});
}, {});
};

View File

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

View File

@ -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",

View 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();
});
};

View File

@ -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];
},
};
};

View File

@ -0,0 +1,7 @@
'use strict';
function buildEntityMetaDefinition(/*contentType*/) {}
module.exports = () => ({
buildEntityMetaDefinition,
});

View 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) });
},
});
},
};
};

View 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,
});

View File

@ -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,
};
};

View File

@ -0,0 +1,7 @@
'use strict';
const contentType = require('./content-type');
module.exports = context => ({
...contentType(context),
});

View File

@ -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) });
},
});

View File

@ -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) });
},
});

View File

@ -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 });
},
});

View File

@ -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 });
},
});

View File

@ -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 });
},
});

View File

@ -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 });
},
});

View File

@ -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 });
},
});

View File

@ -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 });
},
});

View File

@ -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) });
},
});

View File

@ -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);

View File

@ -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 });
},
});

View File

@ -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 });
},
});

View File

@ -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 });
},
});

View File

@ -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 });
},
});

View File

@ -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 });
},
});

View File

@ -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) });
},
});

View File

@ -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);
},
});

View File

@ -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 });
}
},
});

View File

@ -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);
},
});

View File

@ -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) });
},
});

View File

@ -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 });
},
});

View File

@ -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);
},
});
},
};
};

View 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 }),
};
};

View 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) });
}
});
},
});
},
};
};

View File

@ -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);
}
},
});
},
};
};

View File

@ -0,0 +1,9 @@
'use strict';
const createCollectionTypeMutationsBuilder = require('./collection-type');
const createSingleTypeMutationsBuilder = require('./single-type');
module.exports = context => ({
...createCollectionTypeMutationsBuilder(context),
...createSingleTypeMutationsBuilder(context),
});

View File

@ -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);
}
},
});
},
};
};

View File

@ -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 };
};

View File

@ -0,0 +1,9 @@
'use strict';
const createCollectionTypeQueriesBuilder = require('./collection-type');
const createSingleTypeQueriesBuilder = require('./single-type');
module.exports = context => ({
...createCollectionTypeQueriesBuilder(context),
...createSingleTypeQueriesBuilder(context),
});

View File

@ -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 };
};

View File

@ -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([])),
});
},
});
},
};
};

View File

@ -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);
};
},
};
};

View File

@ -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);
};
},
});

View File

@ -0,0 +1,9 @@
'use strict';
module.exports = ({ strapi }) => ({
buildDynamicZoneResolver({ contentTypeUID, attributeName }) {
return async parent => {
return strapi.entityService.load(contentTypeUID, parent, attributeName);
};
},
});

View File

@ -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),
});

View File

@ -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);
},
};
},
});

View File

@ -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));
},
};
},
});

View File

@ -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'),
});
},
});
},
};
};

View File

@ -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'),
});
},
});
},
};
};

View 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);
}
});
},
});
},
};
};

View 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;
},
};
};

View 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,
});

View 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 };
};

View File

@ -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,
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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,
};

View File

@ -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 };

View File

@ -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,
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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;

View File

@ -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