UNPKG

14.1 kBJavaScriptView Raw
1//
2
3
4
5
6
7const _ = require('lodash');
8const jsonpath = require('jsonpath');
9
10/**
11 * @classdesc
12 * In charge of replacing tokenized subrequests in as many requests as needed.
13 *
14 * Each subrequest can generate NxM copies of itself. N if the token refers to a
15 * subrequest that generated many responses previously. M if the replacement
16 * token resolves to a multivalue field that leads to many replacements.
17 *
18 * @class JsonPathReplacer
19 */
20module.exports = class JsonPathReplacer {
21 /**
22 * Searches for JSONPath tokens in the requests and replaces them with the
23 * values from previous responses.
24 *
25 * @param {Subrequest[]} batch
26 * The list of requests that can contain tokens.
27 * @param {Response[]} pool
28 * The pool of responses that can content the values to replace.
29 *
30 * @returns {Subrequest[]}
31 * The new list of requests. Note that if a JSONPath token yields many
32 * values then several replaced subrequests will be generated from that
33 * single subrequest.
34 */
35 static replaceBatch(batch , pool ) {
36 // Apply replacements to each one of the items.
37 return batch.reduce(
38 (carry , subrequest ) =>
39 [...carry, ...(this.replaceItem(subrequest, pool))],
40 []
41 );
42 }
43
44 /**
45 * Searches for JSONPath tokens in the request and replaces it with the values
46 * from previous responses.
47 *
48 * @param {Subrequest} subrequest
49 * The list of requests that can contain tokens.
50 * @param {Response[]} pool
51 * The pool of responses that can content the values to replace.
52 *
53 * @returns {Subrequest[]}
54 * The new list of requests. Note that if a JSONPath token yields many
55 * values then several replaced subrequests will be generated from the input
56 * subrequest.
57 */
58 static replaceItem(subrequest , pool ) {
59 const tokenReplacements = {
60 uri: this._extractTokenReplacements(subrequest, 'uri', pool),
61 body: this._extractTokenReplacements(subrequest, 'body', pool),
62 };
63 if (Object.keys(tokenReplacements.uri).length !== 0) {
64 return this.replaceBatch(
65 this._doReplaceTokensInLocation(tokenReplacements, subrequest, 'uri'),
66 pool
67 );
68 }
69 if (Object.keys(tokenReplacements.body).length !== 0) {
70 return this.replaceBatch(
71 this._doReplaceTokensInLocation(tokenReplacements, subrequest, 'body'),
72 pool
73 );
74 }
75 // If there are no replacements necessary, then just return the initial
76 // request.
77 return [Object.assign(subrequest, { _resolved: true })];
78 }
79
80 /**
81 * Creates replacements for either the body or the URI.
82 *
83 * @param {Object<string, TokenReplacements>} tokenReplacements
84 * Holds the info to replace text.
85 * @param {Subrequest} tokenizedSubrequest
86 * The original copy of the subrequest.
87 * @param {string} tokenLocation
88 * Either 'body' or 'uri'.
89 *
90 * @returns {Subrequest[]}
91 * The replaced subrequests.
92 *
93 * @private
94 */
95 static _doReplaceTokensInLocation(
96 tokenReplacements ,
97 tokenizedSubrequest ,
98 tokenLocation
99 ) {
100 const replacements = [];
101 const tokensPerContentId = tokenReplacements[tokenLocation];
102 let index = 0;
103 // First figure out the different token resolutions and their token.
104 const groupedByToken = [];
105 Object.keys(tokensPerContentId).forEach((contentId) => {
106 const resolutionsPerToken = tokensPerContentId[contentId];
107 Object.keys(resolutionsPerToken).forEach((token) => {
108 const resolutions = resolutionsPerToken[token];
109 groupedByToken.push(resolutions.map(value => ({ token, value })));
110 });
111 });
112 // Then calculate the points.
113 const points = this._getPoints(groupedByToken);
114 points.forEach((point) => {
115 // Clone the subrequest.
116 const cloned = _.cloneDeep(tokenizedSubrequest);
117 cloned.requestId = `${tokenizedSubrequest.requestId}#${tokenLocation}{${index}}`;
118 index += 1;
119 // Now replace all the tokens in the request member.
120 let tokenSubject = this._serializeMember(tokenLocation, cloned[tokenLocation]);
121 point.forEach((replacement) => {
122 // Do all the different replacements on the same subject.
123 tokenSubject = this._replaceTokenSubject(
124 replacement.token,
125 replacement.value,
126 tokenSubject
127 );
128 });
129 cloned[tokenLocation] = this._deserializeMember(tokenLocation, tokenSubject);
130 replacements.push(cloned);
131 });
132 return replacements;
133 }
134
135 /**
136 * Does the replacement on the token subject.
137 *
138 * @param {string} token
139 * The thing to replace.
140 * @param {string} value
141 * The thing to replace it with.
142 * @param {int} tokenSubject
143 * The thing to replace it on.
144 *
145 * @returns {string}
146 * The replaced string.
147 *
148 * @private
149 */
150 static _replaceTokenSubject(
151 token ,
152 value ,
153 tokenSubject
154 ) {
155 // Escape regular expression.
156 const regexp = new RegExp(token.replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g');
157 return tokenSubject.replace(regexp, value);
158 }
159
160 /**
161 * Generates a list of sets of coordinates for the token replacements.
162 *
163 * Each point (coordinates set) end up creating a new clone of the tokenized
164 * subrequest.
165 *
166 * @param {Array<Array<Object>>} groupedByToken
167 * Array of replacements keyed by token.
168 *
169 * @return {Array<Point>}
170 * The coordinates sets.
171 */
172 static _getPoints(groupedByToken
173
174
175 ) {
176 const currentGroup = groupedByToken[0];
177 // If this is not the last group, then call recursively.
178 if (groupedByToken.length === 1) {
179 return currentGroup.map(item => [item]);
180 }
181 const remaining = groupedByToken.slice(1);
182 const points = [];
183 currentGroup.forEach((resolutionInfo) => {
184 // Get all the combinations for the next groups.
185 const nextPoints = this._getPoints(remaining);
186 nextPoints.forEach((nextPoint) => {
187 // Prepend the current resolution for each point.
188 points.push([resolutionInfo].concat(nextPoint));
189 });
190 });
191 return points;
192 }
193
194 /**
195 * Makes sure that the subject for replacement is a string.
196 *
197 * This is an abstraction to be able to treat 'uri' and 'body' replacements
198 * the same way.
199 *
200 * @param {string} memberName
201 * Either 'body' or 'uri'.
202 * @param {*} value
203 * The contents of the URI or the subrequest body.
204 *
205 * @returns {string}
206 * The serialized member.
207 *
208 * @private
209 */
210 static _serializeMember(memberName , value ) {
211 return memberName === 'body'
212 // The body is an Object, to replace on it we serialize it first.
213 ? JSON.stringify(value)
214 : value;
215 }
216
217 /**
218 * Undoes the serialization that happened in _serializeMember.
219 *
220 * This is an abstraction to be able to treat 'uri' and 'body' replacements
221 * the same way.
222 *
223 * @param {string} memberName
224 * Either 'body' or 'uri'.
225 * @param {string} serialized
226 * The contents of the serialized URI or the serialized subrequest body.
227 *
228 * @returns {*}
229 * The unserialized member.
230 *
231 * @private
232 */
233 static _deserializeMember(memberName , serialized ) {
234 return memberName === 'body'
235 // Deserialize the body to store it back.
236 ? JSON.parse(serialized)
237 : serialized;
238 }
239
240 /**
241 * Extracts the token replacements for a given subrequest.
242 *
243 * Given a subrequest there can be N tokens to be replaced. Each token can
244 * result in an list of values to be replaced. Each token may refer to many
245 * subjects, if the subrequest referenced in the token ended up spawning
246 * multiple responses. This function detects the tokens and finds the
247 * replacements for each token. Then returns a data structure that contains a
248 * list of replacements. Each item contains all the replacement needed to get
249 * a response for the initial request, given a particular subject for a
250 * particular JSONPath replacement.
251 *
252 * @param {Subrequest} subrequest
253 * The subrequest that contains the tokens.
254 * @param {string} tokenLocation
255 * Indicates if we are dealing with body or URI replacements.
256 * @param {Response[]} pool
257 * The collection of prior responses available for use with JSONPath.
258 *
259 * @returns {TokenReplacements}
260 * The structure containing a list of replacements for a subject response
261 * and a replacement candidate.
262 *
263 * @private
264 */
265 static _extractTokenReplacements(
266 subrequest ,
267 tokenLocation ,
268 pool
269 ) {
270 // Turn the subject into a string.
271 const regexpSubject = tokenLocation === 'body'
272 ? JSON.stringify(subrequest[tokenLocation])
273 : subrequest[tokenLocation];
274 // First find all the replacements to do. Use a regular expression to detect
275 // cases like "…{{req1.body@$.data.attributes.seasons..id}}…"
276 return _.uniqBy(this._findTokens(regexpSubject), '0')
277 // Then calculate the replacements we will need to return.
278 .reduce((tokenReplacements , match ) => {
279 // Remove the .body part at the end since we only support the body
280 // replacement at this moment.
281 const providedId = match[1].replace(/\.body$/, '');
282 // Calculate what are the subjects to execute the JSONPath against.
283 const subjects = pool.filter((response) => {
284 const contentId = this._getContentId(response).replace(/#.*/, '');
285 // The response is considered a subject if it matches the content ID
286 // or it is a generated copy based of that content ID.
287 return contentId === providedId;
288 });
289 if (subjects.length === 0) {
290 const candidates = pool.map(r => this._getContentId(r).replace(/#.*/, ''));
291 throw new Error(`Unable to find specified request for a replacement ${providedId}. Candidates are [${candidates.join(', ')}].`);
292 }
293 // Find the replacements for this match given a subject.
294 subjects.forEach(subject => this._addReplacementsForSubject(
295 match,
296 subject,
297 providedId,
298 tokenReplacements
299 ));
300
301 return tokenReplacements;
302 }, {});
303 }
304
305 /**
306 * Fill replacement values for a subrequest a subject and an structured token.
307 *
308 * @param {[string, string, string]} match
309 * The structured replacement token.
310 * @param {Response} subject
311 * The response object the token refers to.
312 * @param {string} providedId
313 * The Content ID without the # variations.
314 * @param {TokenReplacements} tokenReplacements
315 * The accumulated replacements.
316 *
317 * @return {void}
318 *
319 * @private
320 */
321 static _addReplacementsForSubject(
322 match ,
323 subject ,
324 providedId ,
325 tokenReplacements
326 ) {
327 // jsonpath.query always returns an array of matches.
328 const toReplace = jsonpath.query(JSON.parse(subject.body), match[2]);
329 const token = match[0];
330 // The replacements need to be strings. If not, then the replacement
331 // is not valid.
332 this._validateJsonPathReplacements(toReplace);
333 tokenReplacements[providedId] = tokenReplacements[providedId] || {};
334 tokenReplacements[providedId][token] = tokenReplacements[providedId][token] || [];
335 tokenReplacements[providedId][token] = tokenReplacements[providedId][token].concat(toReplace);
336 }
337
338 /**
339 * Finds and parses all the tokens in a given string.
340 *
341 * @param {string} subject
342 * The tokenized string. This is usually the URI or the serialized body.
343 *
344 * @returns {Array}
345 * A list of all the matches. Each match contains the token, the subject to
346 * search replacements in and the JSONPath query to execute.
347 *
348 * @private
349 */
350 static _findTokens(subject ) {
351 const regexp = new RegExp('\\{\\{\([^\\{\\{@]*\)@\([^\\{\\{]*\)\\}\\}', 'gmu'); // eslint-disable-line no-useless-escape
352 const matches = [];
353 let match = regexp.exec(subject);
354 while (match) {
355 // We only care about the first three items: full match, subject ID and
356 // JSONPath query.
357 matches.push(match.slice(0, 3));
358 match = regexp.exec(subject);
359 }
360 return matches;
361 }
362
363 /**
364 * Validates tha the JSONPath query yields a string or an array of strings.
365 *
366 * @param {Array} toReplace
367 * The replacement candidates.
368 *
369 * @throws
370 * When the replacements are not valid.
371 *
372 * @returns {void}
373 *
374 * @private
375 */
376 static _validateJsonPathReplacements(toReplace ) {
377 // Check that all the elements in the array are strings.
378 const isValid = Array.isArray(toReplace)
379 && toReplace.reduce((valid, item) =>
380 valid && (
381 typeof item === 'string' ||
382 item instanceof String ||
383 typeof item === 'number' ||
384 item instanceof Number
385 ), true);
386 if (!isValid) {
387 throw new Error(`The replacement token did not a list of strings. Instead it found ${JSON.stringify(toReplace)}.`);
388 }
389 }
390
391 /**
392 * Gets the clean Content ID for a response.
393 *
394 * Removes all the derived indicators and the surrounding angles.
395 *
396 * @param {Response} response
397 * The response to extract the Content ID from.
398 *
399 * @returns {string}
400 * The content ID.
401 *
402 * @private
403 */
404 static _getContentId(response ) {
405 return (response.headers.get('Content-ID') || '').slice(1, -1);
406 }
407};