Skip to content

Commit c76566f

Browse files
committed
Merge pull request #75 from digitalsadhu/feature/request-includes
Feature/request includes
2 parents ca57f40 + 97f7b76 commit c76566f

File tree

11 files changed

+444
-71
lines changed

11 files changed

+444
-71
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ JSONAPI support for loopback.
1212
[http://jsonapi.org/](http://jsonapi.org/)
1313

1414
## Status
15-
This project is a work in progress. Consider it beta software. For ember users, this should be
16-
basically working except for side loading which should be coming pretty soon.
17-
The functionality that is present is pretty well tested. 100 integration tests and counting!
15+
This project is a work in progress. Consider it beta software. For ember users, this should be basically.
16+
The functionality that is present is pretty well tested. 100+ integration tests and counting!
1817

1918
Currently supported:
2019
- Find all records via GET
@@ -27,15 +26,16 @@ Currently supported:
2726
- Find relationships via GET eg. /posts/1/relationships/author (belongsTo, hasMany, hasOne)
2827
- Creating resource relationship linkages during a resource create
2928
- Updating/deleting resource relationship linkages during a resource update
29+
- [Side loading data](http://jsonapi.org/format/#fetching-includes) via `include` param
3030

3131
Not yet properly supported:
32-
- Side loading data is in the works but is not yet supported
3332
- manipulating relationships directly via:
3433
- POST /:resource/relationships/:relatedResource
3534
- PATCH /:resource/relationships/:relatedResource
3635
- DELETE /:resource/relationships/:relatedResource
3736

3837
## Requirements
38+
- JSON API v1.0
3939
- loopback ^v2.0.0
4040
- strong-remoting ^v2.22.0
4141

lib/deserialize.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
'use strict';
1+
/* global require,module */
22
var deserializer = require('./deserializer');
3-
3+
var RelUtils = require('./utilities/relationship-utils');
44
var utils = require('./utils');
55

66
module.exports = function (app) {
@@ -49,6 +49,31 @@ module.exports = function (app) {
4949
//TODO: Rewrite to normal search model by type and FK
5050
}
5151

52+
/**
53+
* Handle include relationship requests (aka sideloading)
54+
*/
55+
if (RelUtils.isRequestingIncludes(ctx)) {
56+
ctx.res.set({'Content-Type': 'application/vnd.api+json'});
57+
58+
ctx.req.isSideloadingRelationships = true;
59+
60+
if (RelUtils.isLoopbackInclude(ctx)) {
61+
return next();
62+
}
63+
64+
if (!RelUtils.shouldIncludeRelationships(ctx.req.method)) {
65+
return next(RelUtils.getInvalidIncludesError());
66+
}
67+
68+
var include = RelUtils.getIncludesArray(ctx.req.query);
69+
include = include.length === 1 ? include[0] : include;
70+
71+
ctx.args = ctx.args || {};
72+
ctx.args.filter = ctx.args.filter || {};
73+
ctx.args.filter.include = include;
74+
75+
}
76+
5277
next();
5378
});
5479
};

lib/errors.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,16 @@ function JSONAPIErrorHandler (err, req, res, next) {
6060
err.code = 'presence';
6161
err.name = 'ValidationError';
6262
}
63-
errors.push(buildErrorResponse(statusCode, err.message, err.code, err.name));
6463

64+
debug('Handling invalid relationship specified in url');
65+
if (/Relation (.*) is not defined for (.*) model/.test(err.message)) {
66+
statusCode = 400;
67+
err.message = 'Bad Request';
68+
err.code = 'INVALID_INCLUDE_TARGET';
69+
err.name = 'BadRequest';
70+
}
71+
72+
errors.push(buildErrorResponse(statusCode, err.message, err.code, err.name));
6573
} else {
6674
debug('Unable to determin error type. Treating error as a general 500 server error.');
6775
//catch all server 500 error if we were unable to understand the error.

lib/serialize.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
'use strict';
2-
1+
/* global module,require */
32
var serializer = require('./serializer');
43
var utils = require('./utils');
54
var _ = require('lodash');
@@ -24,7 +23,8 @@ module.exports = function (app, defaults) {
2423
relatedModelPlural,
2524
relations,
2625
res,
27-
model;
26+
model,
27+
requestedIncludes;
2828

2929
var matches = regexs.filter(function (regex) {
3030
return ctx.methodString.match(regex);
@@ -77,8 +77,13 @@ module.exports = function (app, defaults) {
7777
}
7878
}
7979

80+
// If we're sideloading, we need to add the includes
81+
if (ctx.req.isSideloadingRelationships) {
82+
requestedIncludes = ctx.req.remotingContext.args.filter.include;
83+
}
8084
options = {
8185
primaryKeyField: primaryKeyField,
86+
requestedIncludes: requestedIncludes,
8287
host: utils.hostFromContext(ctx),
8388
topLevelLinks: {
8489
self: utils.urlFromContext(ctx)
@@ -104,7 +109,11 @@ module.exports = function (app, defaults) {
104109
relations = utils.getRelationsFromContext(ctx, app);
105110

106111
// Serialize our request
107-
res = serializer(type, data, relations, options);
112+
try {
113+
res = serializer(type, data, relations, options);
114+
} catch (e) {
115+
next(e);
116+
}
108117

109118
ctx.result = res;
110119

lib/serializer.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
var _ = require('lodash');
2+
var RelUtils = require('./utilities/relationship-utils');
23
var utils = require('./utils');
34

45
module.exports = function serializer (type, data, relations, options) {
@@ -25,6 +26,13 @@ module.exports = function serializer (type, data, relations, options) {
2526
resultData.links = makeLinks(options.topLevelLinks);
2627
}
2728

29+
/**
30+
* If we're requesting to sideload relationships...
31+
*/
32+
if (options.requestedIncludes) {
33+
handleIncludes(resultData, options.requestedIncludes, relations);
34+
}
35+
2836
return resultData;
2937
};
3038

@@ -231,3 +239,101 @@ function makeLinks (links, item) {
231239

232240
return retLinks;
233241
}
242+
243+
/**
244+
* Handles serializing the requested includes to a seperate included property
245+
* per JSON API spec.
246+
* @private
247+
* @memberOf {Serializer}
248+
* @param {Object} resp
249+
* @param {Array<String>|String}
250+
* @throws {Error}
251+
* @return {undefined}
252+
*/
253+
function handleIncludes (resp, includes, relations) {
254+
var resources = _.isArray(resp.data) ? resp.data : [resp.data];
255+
256+
if (typeof includes === 'string') {
257+
includes = [ includes ];
258+
}
259+
260+
if (!_.isArray(includes)) {
261+
throw RelUtils.getInvalidIncludesError('JSON API unable to detect valid includes');
262+
}
263+
264+
var embedded = resources.map(function subsituteEmbeddedForIds (resource) {
265+
return includes.map(function (include) {
266+
var relation = relations[include];
267+
var propertyKey = relation.keyFrom;
268+
var plural = utils.pluralForModel(relation.modelTo);
269+
var embeds = [];
270+
271+
// If relation is belongsTo then pk and fk are the other way around
272+
if (relation.type === 'belongsTo' || relation.type === 'referencesMany') {
273+
propertyKey = relation.keyTo;
274+
}
275+
276+
if (!relation) {
277+
throw RelUtils.getInvalidIncludesError('Can\'t locate relationship "' + include + '" to include');
278+
}
279+
280+
resource.relationships[include] = resource.relationships[include] || {};
281+
282+
if (resource.relationships[include] && resource.attributes[include]) {
283+
if (_.isArray(resource.attributes[include])) {
284+
embeds = resource.attributes[include].map(function (rel) {
285+
rel = utils.clone(rel);
286+
return createCompoundIncludes(rel, propertyKey, relation.keyTo, plural);
287+
});
288+
embeds = _.compact(embeds);
289+
290+
resource.relationships[include].data = resource.attributes[include].map(function (relData) {
291+
return {
292+
id: String(relData[propertyKey]),
293+
type: plural
294+
};
295+
});
296+
} else {
297+
var rel = utils.clone(resource.attributes[include]);
298+
var compoundIncludes = createCompoundIncludes(rel, propertyKey, relation.keyFrom, plural);
299+
300+
resource.relationships[include].data = {
301+
id: String(resource.attributes[include][propertyKey]),
302+
type: plural
303+
};
304+
embeds.push(compoundIncludes);
305+
}
306+
delete resource.attributes[relation.keyFrom];
307+
delete resource.attributes[relation.keyTo];
308+
delete resource.attributes[include];
309+
}
310+
return embeds;
311+
});
312+
});
313+
314+
if (embedded.length) {
315+
resp.included = _.flattenDeep(embedded);
316+
}
317+
}
318+
319+
/**
320+
* Creates a compound include object.
321+
* @private
322+
* @memberOf {Serializer}
323+
* @param {Object} relationship
324+
* @param {String} key
325+
* @param {String} type
326+
* @return {Object}
327+
*/
328+
function createCompoundIncludes (relationship, key, fk, type) {
329+
var compoundInclude = makeRelation(type, String(relationship[key]));
330+
331+
// remove the id key since its part of the base compound document, not part of attributes
332+
delete relationship[key];
333+
delete relationship[fk];
334+
335+
// The rest of the data goes in the attributes
336+
compoundInclude.attributes = relationship;
337+
338+
return compoundInclude;
339+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
var _ = require('lodash');
2+
3+
/* global module */
4+
module.exports = {
5+
getIncludesArray: getIncludesArray,
6+
getInvalidIncludesError: getInvalidIncludesError,
7+
isRequestingIncludes: isRequestingIncludes,
8+
shouldIncludeRelationships: shouldIncludeRelationships,
9+
isLoopbackInclude: isLoopbackInclude
10+
};
11+
12+
/**
13+
* Get the invalid includes error.
14+
* @public
15+
* @memberOf {RelationshipUtils}
16+
* @param {String} message
17+
* @return {Error}
18+
*/
19+
function getInvalidIncludesError (message) {
20+
var error = new Error(message || 'JSON API resource does not support `include`');
21+
error.statusCode = 400;
22+
error.code = 400;
23+
error.status = 400;
24+
25+
return error;
26+
}
27+
28+
function isLoopbackInclude (ctx) {
29+
return ctx.args && ctx.args.filter;
30+
}
31+
32+
function isJSONAPIInclude (req) {
33+
return _.isPlainObject(req.query) && req.query.hasOwnProperty('include') && req.query.include.length > 0;
34+
}
35+
36+
/**
37+
* Is the user requesting to sideload relationships?>
38+
* @public
39+
* @MemberOf {RelationshipUtils}
40+
* @return {Boolean}
41+
*/
42+
function isRequestingIncludes (ctx) {
43+
return isLoopbackInclude(ctx) || isJSONAPIInclude(ctx.req);
44+
}
45+
46+
/**
47+
* Returns an array of relationships to include. Per JSON specification, they will
48+
* be in a comma-separated pattern.
49+
* @public
50+
* @memberOf {RelationshipUtils}
51+
* @param {Object} query
52+
* @return {Array}
53+
*/
54+
function getIncludesArray (query) {
55+
var relationships = query.include.split(',');
56+
57+
return relationships.map(function (val) {
58+
return val.trim();
59+
});
60+
}
61+
62+
/**
63+
* We should only include relationships if they are using GET
64+
* @public
65+
* @memberOf {RelationshipUtils}
66+
* @return {Boolean}
67+
*/
68+
function shouldIncludeRelationships (method) {
69+
return method.toLowerCase() === 'get';
70+
}

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"description": "JSONAPI support for loopback",
55
"main": "lib/index.js",
66
"scripts": {
7-
"test": "npm run lint && mocha --reporter=spec ./test/**/*.test.js",
7+
"inspect": "jsinspect .",
8+
"test": "npm run lint && mocha --reporter=spec ./test/**/*.test.js && npm run inspect",
89
"tester": "mocha --reporter=spec ./test/**/*.test.js",
910
"lint": "eslint .",
1011
"version:major": "xyz -i major",
@@ -39,6 +40,7 @@
3940
"babel-eslint": "^4.1.3",
4041
"chai": "^3.3.0",
4142
"eslint": "^1.6.0",
43+
"jsinspect": "^0.7.2",
4244
"loopback": "^2.22.2",
4345
"loopback-datasource-juggler": "^2.40.1",
4446
"mocha": "^2.3.3",

0 commit comments

Comments
 (0)