UNPKG

14.2 kBJavaScriptView Raw
1// @flow
2
3import type { Subrequest } from '../types/BlueprintManager';
4import type { TokenReplacements, Point } from '../types/JsonPathReplacer';
5import type { Response } from '../types/Responses';
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: 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};