Merge pull request #97 from wistityhq/feature/json

JSON API support
This commit is contained in:
Loïc Saint-Roch 2016-03-09 18:02:14 +01:00
commit f4d2ec43a9
9 changed files with 870 additions and 6 deletions

View File

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

View File

@ -26,5 +26,6 @@ module.exports = {
router: true,
static: true,
websockets: true,
studio: true
studio: true,
jsonapi: true
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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