diff --git a/lib/configuration/hooks/_api/index.js b/lib/configuration/hooks/_api/index.js index 1f1d5c6ee4..8af0ac00fa 100644 --- a/lib/configuration/hooks/_api/index.js +++ b/lib/configuration/hooks/_api/index.js @@ -144,16 +144,19 @@ module.exports = function (strapi) { } // Merge API controllers with the main ones. - strapi.controllers = _.merge(strapi.controllers, strapi.api[api.name].controllers); + strapi.controllers = _.merge({}, strapi.controllers, strapi.api[api.name].controllers); + + // Merge API services with the main ones. + strapi.services = _.merge({}, strapi.services, strapi.api[api.name].services); // Merge API models with the main ones. - strapi.models = _.merge(strapi.models, strapi.api[api.name].models); + strapi.models = _.merge({}, strapi.models, strapi.api[api.name].models); // Merge API policies with the main ones. - strapi.policies = _.merge(strapi.policies, strapi.api[api.name].policies); + strapi.policies = _.merge({}, strapi.policies, strapi.api[api.name].policies); // Merge API routes with the main ones. - strapi.config.routes = _.merge(strapi.config.routes, strapi.api[api.name].config.routes); + strapi.config.routes = _.merge({}, strapi.config.routes, strapi.api[api.name].config.routes); }); }); diff --git a/lib/configuration/hooks/defaultHooks.js b/lib/configuration/hooks/defaultHooks.js index 7d2b8f4f08..4eeeb18118 100755 --- a/lib/configuration/hooks/defaultHooks.js +++ b/lib/configuration/hooks/defaultHooks.js @@ -26,5 +26,6 @@ module.exports = { router: true, static: true, websockets: true, - studio: true + studio: true, + jsonapi: true }; diff --git a/lib/configuration/hooks/jsonapi/helpers/request.js b/lib/configuration/hooks/jsonapi/helpers/request.js new file mode 100644 index 0000000000..c53a2641b7 --- /dev/null +++ b/lib/configuration/hooks/jsonapi/helpers/request.js @@ -0,0 +1,290 @@ +'use strict'; + +/** + * Module dependencies + */ + +// Public node modules. +const _ = require('lodash'); + +// Local Strapi dependencies. +const utils = require('../utils/'); + +/** + * JSON API helper + */ + +module.exports = { + + default: {}, + + /** + * Parse request + */ + + parse: function * (ctx) { + // Assign used method + this.default.method = ctx.method.toUpperCase(); + + // Detect X-HTTP-Method-Override for some clients + // which don't support PATCH method + if (ctx.header.hasOwnProperty('x-http-method-override') && ctx.header['x-http-method-override'] === 'PATCH') { + this.default.method = 'POST'; + } + + // HTTP methods allowed + switch (this.default.method) { + case 'GET': + try { + yield this.fetchQuery(ctx); + } catch (err) { + throw err; + } + break; + case 'PATCH': + case 'POST': + try { + if (utils.getObject(utils.matchedRoute(ctx)) === 'relationships') { + yield this.fetchRelationRequest(ctx); + yield this.formatRelationBody(ctx); + } else { + yield this.fetchSchema(ctx); + yield this.formatBody(ctx); + } + } catch (err) { + throw err; + } + break; + case 'DELETE': + if (utils.getObject(utils.matchedRoute(ctx)) === 'relationships') { + try { + yield this.fetchRelationRequest(ctx); + yield this.formatRelationBody(ctx); + } catch (err) { + throw err; + } + } + break; + default: + throw { + status: 403, + body: { + message: 'Invalid HTTP method' + } + }; + } + }, + + /** + * Format attributes for relationships request to get more simple API + */ + + formatRelationBody: function * (ctx) { + ctx.request.body = _.map(ctx.request.body.data, 'id'); + }, + + /** + * Fetch request for relationships + */ + + fetchRelationRequest: function * (ctx) { + const body = ctx.request.body; + + if (!body.hasOwnProperty('data')) { + throw { + status: 403, + body: { + message: 'Missing `data` member' + } + }; + } else if (_.isPlainObject(body.data) && !utils.isRessourceObject(body.data)) { + throw { + status: 403, + body: { + message: 'Invalid ressource object' + } + }; + } else if (_.isArray(body.data)) { + const errors = _.remove(_.map(body.data, function (n, position) { + if (!utils.isRessourceObject(n) || _.isUndefined(utils.getType(ctx, _.get(n, 'type')))) { + return { + position: position, + message: 'Invalid ressource object or unknow type' + }; + } + }), function (n) { + return !_.isUndefined(n); + }); + + if (!_.isEmpty(errors)) { + throw { + status: 403, + body: { + message: errors + } + }; + } + } + }, + + /** + * Format attributes for more simple API + */ + + formatBody: function * (ctx) { + const body = ctx.request.body; + const values = _.assign({}, body.data.attributes, _.mapValues(body.data.relationships, function (relation) { + return _.isArray(relation.data) ? _.map(relation.data, 'id') : relation.data.id; + })); + + ctx.request.body = values; + }, + + /** + * Fetch query parameters + */ + + fetchQuery: function * (ctx) { + // Use context namespace for passing information throug middleware + ctx.state.filter = { + fields: {} + }; + + ctx.state.url = ctx.request.url.replace(ctx.request.search, ''); + ctx.state.query = ctx.request.query; + + _.forEach(ctx.query, function (value, key) { + if (_.includes(['include', 'sort', 'filter'], key)) { + throw { + status: 400, + body: { + message: 'Parameter `' + key + '` is not supported yet' + } + }; + } else if (key.indexOf('fields') !== -1) { + const alias = key.match(/\[(.*?)\]/)[1]; + const type = utils.getType(ctx, alias); + + if (_.isUndefined(type)) { + throw { + status: 403, + body: { + message: 'Wrong `type` in fields parameters' + } + }; + } + + ctx.state.filter.fields[type] = value.split(','); + } else if (key === 'page[number]' && _.isPlainObject(strapi.config.jsonapi) && strapi.config.jsonapi.hasOwnProperty('paginate') && strapi.config.jsonapi.paginate === parseInt(strapi.config.jsonapi.paginate, 10)) { + _.set(ctx.request.query, 'limit', strapi.config.jsonapi.paginate); + _.set(ctx.request.query, 'offset', strapi.config.jsonapi.paginate * (value - 1)); + + // Remove JSON API page strategy + ctx.request.query = _.omit(ctx.request.query, 'page[number]'); + } + }); + }, + + /** + * Fetch attributes schema + */ + + fetchSchema: function * (ctx) { + const body = ctx.request.body; + + if (!body.hasOwnProperty('data')) { + throw { + status: 403, + body: { + message: 'Missing `data` member' + } + }; + } else if (!utils.isRessourceObject(body.data) && this.default.method !== 'POST') { + throw { + status: 403, + body: { + message: 'Invalid ressource object' + } + }; + } else if (!body.data.hasOwnProperty('type') && this.default.method === 'POST') { + throw { + status: 403, + body: { + message: 'Invalid ressource object' + } + }; + } else if (!strapi.models.hasOwnProperty(body.data.type)) { + throw { + status: 403, + body: { + message: 'Unknow `type` ' + body.data.type + } + }; + } + + // Extract required attributes + const requiredAttributes = _.omit(_.mapValues(strapi.models[body.data.type].attributes, function (attr) { + return (attr.required && attr.type) ? attr : undefined; + }), _.isUndefined); + // Identify missing attributes + const missingAttributes = body.data.hasOwnProperty('attributes') ? _.difference(_.keys(requiredAttributes), _.keys(body.data.attributes)) : null; + + if (!_.isEmpty(missingAttributes)) { + throw { + status: 403, + body: { + message: 'Missing required attributes (' + missingAttributes.toString() + ')' + } + }; + } + + // Extract required relationships + const requiredRelationships = _.omit(_.mapValues(strapi.models[body.data.type].attributes, function (attr) { + return (attr.required && (attr.model || attr.collection)) ? attr : undefined; + }), _.isUndefined); + // Identify missing relationships + const missingRelationships = body.data.hasOwnProperty('relationships') ? _.difference(_.keys(requiredRelationships), _.keys(body.data.relationships)) : null; + + if (!_.isEmpty(missingRelationships)) { + throw { + status: 403, + body: { + message: 'Missing required relationships (' + missingRelationships.toString() + ')' + } + }; + } + + // Build array of errors + if (_.size(requiredRelationships)) { + const errors = _.remove(_.flattenDeep(_.map(body.data.relationships, function (relation, key) { + if (!relation.hasOwnProperty('data')) { + return { + message: 'Missing `data` member for relationships ' + relation + }; + } else if (_.isArray(relation.data)) { + return _.map(relation.data, function (ressource, position) { + if (!utils.isRessourceObject(ressource)) { + return { + position: position, + message: 'Invalid ressource object in relationships ' + key + }; + } + }); + } else if (!utils.isRessourceObject(relation.data)) { + return { + message: 'Invalid ressource object for relationships ' + key + }; + } + })), function (n) { + return !_.isUndefined(n); + }); + + if (!_.isEmpty(errors)) { + throw { + status: 403, + body: errors + }; + } + } + } +}; diff --git a/lib/configuration/hooks/jsonapi/helpers/response.js b/lib/configuration/hooks/jsonapi/helpers/response.js new file mode 100644 index 0000000000..78edfcac07 --- /dev/null +++ b/lib/configuration/hooks/jsonapi/helpers/response.js @@ -0,0 +1,344 @@ +'use strict'; + +/** + * Module dependencies + */ + +// Public node modules. +const _ = require('lodash'); +const JSONAPI = require('jsonapi-serializer'); + +// Local Strapi dependencies. +const utils = require('../utils/'); +let utilsORM; + +/** + * JSON API helper + */ + +module.exports = { + + /** + * Set response + */ + + set: function * (ctx, matchedRoute, actionRoute) { + const object = utils.getObject(matchedRoute); + const type = utils.getType(ctx, actionRoute.controller); + + // Load right ORM utils + utilsORM = require('strapi-' + strapi.models[type].orm + '/lib/helpers/'); + + // Fetch a relationship that does not exist + // Reject related request with `include` parameter + if (_.isUndefined(type) || (type === 'related' && ctx.params.hasOwnProperty('include'))) { + ctx.response.status = 404; + ctx.response.body = ''; + + return; + } else if (ctx.method === 'DELETE') { + // Request successful and responds with only top-level meta data or nothing. + ctx.response.body = ''; + + return; + } + + // Fetch and format value + const value = this.fetchValue(ctx, object); + + if (!_.isNull(value)) { + ctx.response.body = yield this.serialize(ctx, type, object, value, matchedRoute); + } + }, + + /** + * Serialize response with JSON API specification + */ + + serialize: function * (ctx, type, object, value) { + const toSerialize = { + topLevelLinks: {self: ctx.request.origin + ctx.request.originalUrl}, + keyForAttribute: 'dash-case', + pluralizeType: false, + included: true, + typeForAttribute: function (currentType) { + if (strapi.models.hasOwnProperty(type)) { + return _.first(_.reject(_.map(strapi.models[type].associations, function (relation) { + return (relation.alias === currentType) ? relation.model || relation.collection : undefined; + }), _.isUndefined)) || currentType; + } + } + }; + + // Assign custom configurations + if (_.isPlainObject(strapi.config.jsonapi) && !_.isEmpty(strapi.config.jsonapi)) { + _.assign(toSerialize, _.pick(strapi.config.jsonapi, 'keyForAttribute')); + } + + const PK = utilsORM.getPK(type); + + if (_.isArray(value) && !_.isEmpty(value)) { + // Array + if (!_.isNull(PK)) { + _.forEach(value, function (record) { + if (record.hasOwnProperty(PK)) { + record[PK] = record[PK].toString(); + } + }); + } + + toSerialize.dataLinks = { + self: function (record) { + if (record.hasOwnProperty(PK)) { + return ctx.request.origin + ctx.state.url + '/' + record[PK]; + } + } + }; + + toSerialize.attributes = ctx.state.filter.fields[type] || _.keys(_.last(value)); + } else if (_.isObject(value) && !_.isEmpty(value)) { + // Object + if (!_.isNull(PK) && value.hasOwnProperty(PK)) { + value[PK] = value[PK].toString(); + } + + toSerialize.attributes = ctx.state.filter.fields[type] || _.keys(value); + } + + switch (object) { + case 'collection': + this.includedRelationShips(ctx, toSerialize, type); + break; + case 'ressource': + this.includedRelationShips(ctx, toSerialize, type); + break; + case 'relationships': + // Remove data key + delete toSerialize.dataLinks; + delete toSerialize.attributes; + + // Dirty way to set related URL + toSerialize.topLevelLinks.related = toSerialize.topLevelLinks.self.replace('relationships/', ''); + break; + case 'related': + this.includedRelationShips(ctx, toSerialize, type); + break; + default: + break; + } + + // Display JSON API pagination + if (_.isPlainObject(strapi.config.jsonapi) && strapi.config.jsonapi.hasOwnProperty('paginate') && strapi.config.jsonapi.paginate === parseInt(strapi.config.jsonapi.paginate, 10) && object === 'collection') { + yield this.includePagination(ctx, toSerialize, object, type); + } + + const serialized = new JSONAPI.Serializer(type, value, toSerialize); + + // Display JSON API version support + if (_.isPlainObject(strapi.config.jsonapi) && strapi.config.jsonapi.hasOwnProperty('showVersion') && strapi.config.jsonapi.showVersion === true) { + _.assign(serialized, { + jsonapi: { + version: '1.0' + } + }); + } + + return serialized; + }, + + /** + * Include pagination links to the object + */ + + includePagination: function * (ctx, toSerialize, object, type) { + return new Promise(function (resolve, reject) { + if (strapi.models.hasOwnProperty(type) && strapi.hasOwnProperty(strapi.models[type].orm) && strapi[strapi.models[type].orm].hasOwnProperty('collections')) { + // We force page-based strategy for now. + utilsORM.getCount(type).then(function (count) { + const links = {}; + const pageNumber = Math.ceil(count / strapi.config.jsonapi.paginate); + + // Get current page number + const value = _.first(_.values(_.pick(ctx.state.query, 'page[number]'))); + const currentPage = _.isEmpty(value) || parseInt(value, 10) === 0 ? 1 : value; + + // Verify integer + if (currentPage.toString() === parseInt(currentPage, 10).toString()) { + links.first = ctx.request.origin + ctx.state.url; + links.prev = ctx.request.origin + ctx.state.url + '?page[number]=' + (parseInt(currentPage, 10) - 1); + links.next = ctx.request.origin + ctx.state.url + '?page[number]=' + (parseInt(currentPage, 10) + 1); + links.last = ctx.request.origin + ctx.state.url + '?page[number]=' + pageNumber; + + // Second page + if ((parseInt(currentPage, 10) - 1) === 0) { + links.prev = links.first; + } + + // Before last page + if ((parseInt(currentPage, 10) - 1) === pageNumber) { + links.next = links.last; + } + + // No data + if (pageNumber === 0) { + links.prev = null; + links.next = null; + links.last = null; + } + + // Last page + if (parseInt(currentPage, 10) === pageNumber) { + links.last = null; + links.next = null; + } + + // First page + if (parseInt(currentPage, 10) === 1) { + links.first = null; + links.prev = null; + } + } + + _.assign(toSerialize.topLevelLinks, _.omit(links, _.isNull)); + + resolve(); + }) + .catch(function (err) { + reject(err); + }); + } else { + resolve(); + } + }); + }, + + /** + * Include relationships values to the object + */ + + includedRelationShips: function (ctx, toSerialize, type) { + if (strapi.models.hasOwnProperty(type)) { + _.forEach(strapi.models[type].associations, function (relation) { + const PK = utilsORM.getPK(relation.model) || utilsORM.getPK(relation.collection); + const availableRoutes = { + relSlSelf: utils.isRoute('GET /' + type + '/:' + PK + '/relationships/:relation'), + relSlRelated: utils.isRoute('GET /' + type + '/:' + PK), + incSelf: relation.model ? utils.isRoute('GET /' + relation.model + '/:' + PK) : utils.isRoute('GET /' + relation.collection + '/:' + PK) + }; + + switch (relation.nature) { + case 'oneToOne': + case 'manyToOne': + // Object + toSerialize[relation.alias] = { + ref: PK, + included: strapi.config.jsonapi.included || false, + ignoreRelationshipData: strapi.config.jsonapi.ignoreRelationshipData || false, + attributes: ctx.state.filter.fields[relation.model] || _.keys(_.omit(strapi.models[type].attributes, _.isFunction)), + relationshipLinks: { + self: function (record) { + if (record.hasOwnProperty(PK) && availableRoutes.relSlSelf) { + return ctx.request.origin + '/' + type + '/' + record[PK] + '/relationships/' + relation.alias; + } + + return undefined; + }, + related: function (record) { + if (record.hasOwnProperty(PK) && availableRoutes.relSlRelated) { + return ctx.request.origin + '/' + type + '/' + record[PK]; + } + + return undefined; + } + }, + includedLinks: { + self: function (data, record) { + if (!_.isUndefined(record) && record.hasOwnProperty(PK) && availableRoutes.incSelf) { + return ctx.request.origin + '/' + relation.model + '/' + record[PK]; + } + + return undefined; + } + } + }; + break; + case 'oneToMany': + case 'manyToMany': + // Array + toSerialize[relation.alias] = { + ref: PK, + included: strapi.config.jsonapi.included || false, + ignoreRelationshipData: strapi.config.jsonapi.ignoreRelationshipData || false, + typeForAttribute: relation.collection, + attributes: ctx.state.filter.fields[relation.collection] || _.keys(_.omit(strapi.models[type].attributes, _.isFunction)), + relationshipLinks: { + self: function (record) { + if (record.hasOwnProperty(PK) && availableRoutes.relSlSelf) { + return ctx.request.origin + '/' + type + '/' + record[PK] + '/relationships/' + relation.alias; + } + + return undefined; + }, + related: function (record) { + if (record.hasOwnProperty(PK) && availableRoutes.relSlRelated) { + return ctx.request.origin + '/' + type + '/' + record[PK]; + } + + return undefined; + } + }, + includedLinks: { + self: function (data, record) { + if (record.hasOwnProperty(PK) && availableRoutes.incSelf) { + return ctx.request.origin + '/' + relation.collection + '/' + record[PK]; + } + + return undefined; + } + } + }; + break; + default: + } + }); + } + }, + + /** + * Fetch and format value + */ + + fetchValue: function (ctx, object) { + const data = _.isFunction(_.get(ctx.body, 'toJSON')) ? ctx.body.toJSON() : ctx.body; + + switch (object) { + case 'collection': + if ((_.isArray(data) && _.size(data) > 1) || _.isObject(data)) { + return data; + } else if (_.isArray(data) && (_.size(data) === 1 || _.size(data) === 0)) { + return _.isObject(_.first(data)) ? _.first(data[0]) : []; + } + + return null; + case 'ressource': + if (_.isObject(data)) { + return data; + } + + return null; + case 'related': + case 'relationships': + if (_.isObject(data) || _.isArray(data) && data.hasOwnProperty(ctx.params.relation)) { + if (_.isArray(data[ctx.params.relation]) && _.size(data[ctx.params.relation]) > 1) { + return data[ctx.params.relation]; + } + + return data[ctx.params.relation] || _.first(data[ctx.params.relation]); + } + + return null; + default: + return 'collection'; + } + } +}; diff --git a/lib/configuration/hooks/jsonapi/index.js b/lib/configuration/hooks/jsonapi/index.js new file mode 100644 index 0000000000..30d445fc85 --- /dev/null +++ b/lib/configuration/hooks/jsonapi/index.js @@ -0,0 +1,100 @@ +'use strict'; + +/** + * Module dependencies + */ + +// Public node modules. +const _ = require('lodash'); + +// Local Strapi dependencies. +const request = require('./helpers/request'); +const response = require('./helpers/response'); +const utils = require('./utils/'); + +/** + * JSON API hook + */ + +module.exports = function (strapi) { + const hook = { + + /** + * Initialize the hook + */ + + initialize: function (cb) { + function * _interceptor(next) { + // Wait for downstream middleware/handlers to execute to build the response + yield next; + + // Exclude administration routes + if (this.request.url.indexOf('admin') === -1) { + if (this.request.type === 'application/vnd.api+json' && _.startsWith(this.status, '2')) { + // Set required response header + this.response.type = 'application/vnd.api+json'; + + // Intercept success requests + + // Detect route + const matchedRoute = utils.matchedRoute(this); + + if (!_.isUndefined(matchedRoute)) { + // Handlers set the response body + const actionRoute = strapi.config.routes[this.request.method.toUpperCase() + ' ' + matchedRoute.path]; + + if (!_.isUndefined(actionRoute)) { + yield response.set(this, matchedRoute, actionRoute); + } + } + } else if (this.request.type === 'application/vnd.api+json') { + // Set required response header + this.response.type = 'application/vnd.api+json'; + + // Intercept error requests + this.body = { + errors: this.body + }; + } else if (this.request.type.indexOf('application/vnd.api+json') !== -1) { + // Right header detected but there are others header too. + this.status = 406; + this.body = ''; + } + } + } + + if ((_.isPlainObject(strapi.config.jsonapi) && strapi.config.jsonapi.enabled === true) || (_.isBoolean(strapi.config.jsonapi) && strapi.config.jsonapi === true)) { + strapi.app.use(_interceptor); + } + + cb(); + }, + + /** + * Parse request and attributes + */ + + parse: function () { + return function * (next) { + // Verify Content-Type header + if (this.request.type === 'application/vnd.api+json') { + // Only one and right header detected. + try { + yield request.parse(this); + yield next; + } catch (err) { + _.assign(this.response, err); + } + } else if (this.request.type.indexOf('application/vnd.api+json') !== -1) { + // Right header detected but there are others header too. + this.response.status = 406; + this.response.body = ''; + } else { + yield next; + } + }; + } + }; + + return hook; +}; diff --git a/lib/configuration/hooks/jsonapi/utils/index.js b/lib/configuration/hooks/jsonapi/utils/index.js new file mode 100644 index 0000000000..a9e841170d --- /dev/null +++ b/lib/configuration/hooks/jsonapi/utils/index.js @@ -0,0 +1,91 @@ +'use strict'; + +/** + * Module dependencies + */ + +// Public node modules. +const _ = require('lodash'); + +/** + * JSON API utils + */ + +module.exports = { + + /** + * Verify ressource object + */ + + isRessourceObject: function (object) { + return _.isObject(object) && object.hasOwnProperty('id') && object.hasOwnProperty('type'); + }, + + /** + * Verify if the route exists + */ + + isRoute: function (link) { + return strapi.config.routes.hasOwnProperty(link); + }, + + /** + * Find data object + */ + + getObject: function (matchedRoute) { + // TODO: + // - Improve way to detect collection/ressource/relationships/related + switch (_.size(matchedRoute.regexp.keys)) { + case 0: + return 'collection'; + case 1: + return 'ressource'; + case 2: + return (matchedRoute.path.indexOf('relationships') !== -1) ? 'relationships' : 'related'; + default: + return 'collection'; + } + }, + + /** + * Find data type + */ + + getType: function (ctx, supposedType) { + // Relationships or related ressource + if (strapi.models.hasOwnProperty(supposedType.toLowerCase()) && ctx.params.hasOwnProperty('relation') && ctx.method === 'GET') { + return _.first(_.reject(_.map(strapi.models[supposedType.toLowerCase()].associations, function (relation) { + return (ctx.params.hasOwnProperty('relation') && ctx.params.relation === relation.alias) ? relation.model || relation.collection : undefined; + }), _.isUndefined)); + } else if (strapi.models.hasOwnProperty(supposedType.toLowerCase())) { + return supposedType.toLowerCase(); + } else if (!strapi.models.hasOwnProperty(supposedType.toLowerCase())) { + // Deep search based on the relation alias + const tryFindType = _.reject(_.flattenDeep(_.map(strapi.models, function (model) { + return _.map(model.associations, function (relation) { + return (supposedType.toLowerCase() === relation.alias) ? relation.model || relation.collection : undefined; + }); + })), _.isUndefined); + + if (!_.isUndefined(tryFindType)) { + return _.first(tryFindType); + } + } + + return undefined; + }, + + /** + * Find router object for matched route + */ + + matchedRoute: function (ctx) { + return _.find(strapi.router.stack, function (stack) { + if (new RegExp(stack.regexp).test(ctx.request.url.replace(ctx.request.search, '')) && _.includes(stack.methods, ctx.request.method.toUpperCase())) { + return stack; + } + }); + } + +}; diff --git a/lib/configuration/hooks/router/index.js b/lib/configuration/hooks/router/index.js index 032304aaf8..2cf60d9ee9 100644 --- a/lib/configuration/hooks/router/index.js +++ b/lib/configuration/hooks/router/index.js @@ -12,6 +12,7 @@ const _ = require('lodash'); // Local utilities. const regex = require('../../../../util/regex'); +const JSONAPI = require('../jsonapi')(); /** * Router hook @@ -26,7 +27,13 @@ module.exports = function (strapi) { defaults: { prefix: '', - routes: {} + routes: {}, + graphql: { + enabled: true, + route: '/graphql', + graphiql: false, + pretty: true + } }, /** @@ -83,6 +90,11 @@ module.exports = function (strapi) { // Add the `globalPolicy`. policies.push(globalPolicy(endpoint, value, route)); + // Enabled JSON API support + if ((_.isPlainObject(strapi.config.jsonapi) && strapi.config.jsonapi.enabled === true) || (_.isBoolean(strapi.config.jsonapi) && strapi.config.jsonapi === true)) { + policies.push(JSONAPI.parse(strapi)); + } + if (_.isArray(value.policies) && !_.isEmpty(value.policies)) { _.forEach(value.policies, function (policy) { if (strapi.policies[policy]) { @@ -99,6 +111,24 @@ module.exports = function (strapi) { } }); + // Override default configuration for GraphQL + _.assign(this.defaults.graphql, strapi.config.graphql); + + // Define GraphQL route to GraphQL schema + // or disable the global variable + if (this.defaults.graphql.enabled === true) { + strapi.app.use(strapi.middlewares.mount(this.defaults.graphql.route, strapi.middlewares.graphql((request, context) => ({ + schema: strapi.schemas, + pretty: this.defaults.graphql.pretty, + rootValue: { + context: context + }, + graphiql: this.defaults.graphql.graphiql + })))); + } else { + global.graphql = undefined; + } + // Let the router use our routes and allowed methods. strapi.app.use(strapi.router.routes()); strapi.app.use(strapi.router.allowedMethods()); diff --git a/lib/configuration/index.js b/lib/configuration/index.js index fc2d02c224..590962243b 100755 --- a/lib/configuration/index.js +++ b/lib/configuration/index.js @@ -39,6 +39,8 @@ module.exports = function (strapi) { // Set up config defaults. return { + collections: {}, + // Core (default) hooks. hooks: _.reduce(DEFAULT_HOOKS, function (memo, hookBundled, hookIdentity) { memo[hookIdentity] = require('./hooks/' + hookIdentity); diff --git a/package.json b/package.json index 9aa4d64f29..c6c865e120 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "auth", "framework", "grant", + "graphql", "koa", "koajs", "lusca", @@ -35,11 +36,13 @@ "herd": "~1.0.0", "include-all": "~0.1.6", "json-stringify-safe": "~5.0.1", + "jsonapi-serializer": "seyz/jsonapi-serializer", "koa": "~1.1.2", "koa-bodyparser": "~2.0.1", "koa-compose": "~2.3.0", "koa-cors": "~0.0.16", "koa-favicon": "~1.2.0", + "koa-graphql": "^0.4.4", "koa-gzip": "~0.1.0", "koa-i18n": "~1.2.0", "koa-ip": "~0.1.0",