Skip to content

Commit 1cd1d52

Browse files
committed
added relationships & pagination
1 parent fceb50a commit 1cd1d52

File tree

1 file changed

+242
-11
lines changed

1 file changed

+242
-11
lines changed

index.js

Lines changed: 242 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,260 @@
11
'use strict';
22
const JSONAPISerializer = require('jsonapi-serializer').Serializer;
33

4-
function jsonapify(data, model, selfUrl) {
5-
const idAttribute = Object.keys(model.attributes).filter(function(attribute) {
4+
/**
5+
* Creates a filter helper to check if a given attribute name must be excluded or not.
6+
*
7+
* @private
8+
* @function byExcluded
9+
* @param {String[]} excluded
10+
* @returns {Function}
11+
*/
12+
function byExcluded(excluded) {
13+
return function(attribute) {
14+
return excluded.indexOf(attribute) === -1;
15+
};
16+
}
17+
18+
/**
19+
* Creates a filter helper to detect primary keys in a model.
20+
*
21+
* @private
22+
* @function byPrimaryKey
23+
* @param {Model} model
24+
* @returns {Function}
25+
*/
26+
function byPrimaryKey(model) {
27+
return function(attribute) {
628
return model.attributes[attribute].primaryKey;
7-
})[0];
29+
};
30+
}
831

9-
const attributes = Object.keys(model.attributes).filter(function(attribute) { return attribute !== idAttribute; });
32+
/**
33+
* Creates an object describing the relationship per JSON API specs.
34+
* A relationship object has the form `{ "type": "<model>", "id": "<id>" }`.
35+
*
36+
* @private
37+
* @function createRelationshipObject
38+
* @param {Association} include - The association description
39+
* @param {Object} item - The parent record.
40+
* @return {Object}
41+
*/
42+
function createRelationshipObject(include, item) {
43+
const relatedItem = {};
44+
const relatedModelIdAttribute = Object.keys(include.model.attributes).filter(byPrimaryKey(include.model))[0];
45+
const foreignKeyValue = item[include.association.options.foreignKey];
46+
relatedItem.type = include.model.name;
47+
relatedItem[relatedModelIdAttribute] = foreignKeyValue;
48+
return foreignKeyValue !== null ? relatedItem : null;
49+
}
50+
51+
/**
52+
* Returns a helper function that converts an embedded record to a related record per JSON API specs.
53+
*
54+
* @param {Object} data
55+
* @param {Object[]} includedData
56+
* @return {Function}
57+
*/
58+
function parseRelationships(data, includedData) {
59+
return function(include) {
60+
const relationship = {};
61+
const relationshipName = include.association.options.underscored ? include.as.replace(/_/g, '-') : include.as;
62+
relationship[relationshipName] = { data: createRelationshipObject(include, data) };
63+
if (data[include.as] !== null) {
64+
const serializedRelationship = jsonapify(data[include.as], include.model, include.model.name + '/' + data[include.as].id, { include: [] });
65+
includedData.push(Object.assign({}, serializedRelationship.document, { links: serializedRelationship.links }));
66+
delete data[include.as][include.association.options.foreignKey];
67+
}
68+
delete data[include.association.options.foreignKey];
69+
delete data[include.as];
70+
if (relationship[relationshipName].data !== null) {
71+
data.relationships = Object.assign({}, data.relationships, relationship);
72+
}
73+
};
74+
}
1075

11-
const idParameter = !Array.isArray(data) ? ('/' + data[idAttribute]) : '';
76+
/**
77+
* Clears any array of records of duplicates by checking its `id`.
78+
*
79+
* @private
80+
* @function
81+
* @param {Object[]} collection
82+
* @return {Object[]}
83+
*/
84+
function removeDuplicateRecords(collection) {
85+
const newCollection = [];
86+
const uniqueRecords = {};
1287

13-
return new JSONAPISerializer(model.name, data, {
88+
collection.forEach(function(item) {
89+
uniqueRecords[item.id] = item;
90+
});
91+
92+
Object.keys(uniqueRecords).forEach(function(uniqueKey) {
93+
newCollection.push(uniqueRecords[uniqueKey]);
94+
});
95+
96+
return newCollection;
97+
}
98+
99+
/**
100+
* Creates a JSON API document derived from the REST provider response.
101+
*
102+
* @private
103+
* @function jsonapify
104+
* @param {Object|Object[]} data - The original response.
105+
* @param {Model} model - The Sequelize model to work with.
106+
* @param {String} selfUrl - The hook's path to create the `self` link.
107+
* @param {Object} context - The contents of `result.$options`.
108+
* @return {Object}
109+
*/
110+
function jsonapify(data, model, selfUrl, context) {
111+
const includedData = [];
112+
const idAttribute = Object.keys(model.attributes).filter(byPrimaryKey(model))[0];
113+
const excluded = [idAttribute];
114+
115+
if (context.include && context.include.length) {
116+
context.include.forEach(parseRelationships(data, includedData, model, selfUrl));
117+
}
118+
119+
const attributes = Object.keys(model.attributes).filter(byExcluded(excluded)).concat(['relationships']);
120+
121+
const json = new JSONAPISerializer(model.name, data, {
14122
topLevelLinks: {
15-
self: '/' + selfUrl + idParameter
123+
self: '/' + selfUrl
16124
},
17125
attributes: attributes
18126
});
127+
128+
if (json.data.attributes.relationships) {
129+
json.data.relationships = json.data.attributes.relationships;
130+
delete json.data.attributes.relationships;
131+
}
132+
133+
const result = { document: json.data, links: json.links };
134+
135+
if (includedData.length) {
136+
result.related = includedData;
137+
}
138+
139+
return result;
140+
}
141+
142+
/**
143+
* Moves any non-JSON-API top-level key as metadata.
144+
*
145+
* @private
146+
* @method createMetadata
147+
* @param {Hook} hook
148+
*/
149+
function createMetadata(hook) {
150+
const metaKeys = Object.keys(hook.result).filter(function(key) {
151+
return ['data', 'included', 'meta', 'links'].indexOf(key) === -1;
152+
});
153+
if (metaKeys.length) {
154+
const meta = {};
155+
metaKeys.forEach(function(key) {
156+
meta[key] = hook.result[key];
157+
delete hook.result[key];
158+
});
159+
hook.result.meta = meta;
160+
}
161+
}
162+
163+
/**
164+
* Creates links to follow the pagination context included by the REST provider.
165+
*
166+
* @private
167+
* @method createPagination
168+
* @param {Hook} hook
169+
*/
170+
function createPagination(hook) {
171+
if (hook.result.skip !== undefined && hook.result.total !== undefined && hook.result.limit !== undefined) {
172+
hook.result.links = {};
173+
if (hook.result.skip >= hook.result.limit) {
174+
hook.result.links.first = '/' + hook.path;
175+
}
176+
if (hook.result.skip + hook.result.limit < hook.result.total) {
177+
hook.result.links.next = '/' + hook.path + '?$skip=' + (hook.result.skip + hook.result.limit);
178+
}
179+
if (hook.result.skip + hook.result.limit > hook.result.limit) {
180+
hook.result.links.prev = '/' + hook.path + '?$skip=' + (hook.result.skip - hook.result.limit);
181+
}
182+
if (hook.result.skip + hook.result.limit < hook.result.total) {
183+
hook.result.links.last = '/' + hook.path + '?$skip=' + (Math.floor(hook.result.total / hook.result.limit) * hook.result.limit);
184+
}
185+
}
186+
}
187+
188+
/**
189+
* Creates a JSON API document with multiple records.
190+
*
191+
* @private
192+
* @method jsonapifyViaFind
193+
* @param {Hook} hook
194+
*/
195+
function jsonapifyViaFind(hook) {
196+
let serialized = {};
197+
hook.result.included = [];
198+
hook.result.data.forEach(function(data, index) {
199+
const jsonItem = data.toJSON();
200+
serialized = jsonapify(jsonItem, hook.service.Model, hook.path + '/' + jsonItem.id, data.$options);
201+
hook.result.data[index] = serialized.document;
202+
if (serialized.related) {
203+
hook.result.included = hook.result.included.concat(serialized.related);
204+
}
205+
if (serialized.links) {
206+
hook.result.data[index].links = serialized.links;
207+
createPagination(hook);
208+
}
209+
});
210+
if (!hook.result.included.length) {
211+
delete hook.result.included;
212+
} else {
213+
hook.result.included = removeDuplicateRecords(hook.result.included);
214+
}
215+
createMetadata(hook);
19216
}
20217

218+
/**
219+
* Creates a JSON API document for a single record.
220+
*
221+
* @private
222+
* @method jsonapifyViaGet
223+
* @param {Hook} hook
224+
*/
225+
function jsonapifyViaGet(hook) {
226+
let serialized = {};
227+
const jsonItem = hook.result.toJSON();
228+
serialized = jsonapify(jsonItem, hook.service.Model, hook.path + '/' + jsonItem.id, hook.result.$options);
229+
hook.result = { data: serialized.document, included: serialized.related };
230+
if (hook.result.included && !hook.result.included.length) {
231+
delete hook.result.included;
232+
}
233+
if (serialized.links) {
234+
hook.result.data.links = serialized.links;
235+
hook.result.data.links.parent = '/' + hook.service.Model.name;
236+
}
237+
createMetadata(hook);
238+
}
239+
240+
/**
241+
* Maps hook methods to jsonapify functions.
242+
*
243+
* @private
244+
* @constant entrypoints
245+
*/
246+
const entrypoints = { find: jsonapifyViaFind, get: jsonapifyViaGet };
247+
248+
/**
249+
* This hook allows to serialize the REST provider response as a JSON API response.
250+
* It is used as an `after` hook. Bindable with `find` and `get` hooks.
251+
*
252+
* @function jsonapify
253+
*/
21254
module.exports = function (options = {}) { // eslint-disable-line no-unused-vars
22255
return function (hook) {
23-
if (hook.method === 'find') {
24-
hook.result = jsonapify(hook.result.data, hook.service.Model, hook.path);
25-
} else if (hook.method === 'get') {
26-
hook.result = jsonapify(hook.result.toJSON(), hook.service.Model, hook.path);
256+
if (hook.method === 'find' || hook.method === 'get') {
257+
entrypoints[hook.method](hook);
27258
}
28259
return Promise.resolve(hook);
29260
};

0 commit comments

Comments
 (0)