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