1 | //
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 | const _ = require('lodash');
|
8 | const 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 | */
|
20 | module.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 | };
|