this repo has no description
1/**
2 * Created by ls on 9/7/2018.
3 *
4 * This `network.ts` is the NON-MEDIA API arm of network fetch requests.
5 * It is built on `Network` object and provides standard functionality, such as:
6 * 1. Parsing the body into specific format.
7 * 2. Adding timing metrics onto blob.
8 *
9 * This should *only* be used for objects that should have timing metrics, i.e. requests to Non-MediaAPI endpoints
10 * that will ultimately render some whole page. Otherwise, use `objectGraph.network.fetch` directly.
11 *
12 * @see `src/media/network.ts` for fetching from Media API endpoints
13 */
14
15import { FetchRequest, FetchResponse, HTTPTimingMetrics } from "@jet/environment/types/globals/net";
16import { FetchTimingMetrics, MetricsFields } from "@jet/environment/types/metrics";
17import { FetchTimingMetricsBuilder } from "@jet/environment/metrics";
18import { isSome, Opt } from "@jet/environment/types/optional";
19import * as serverData from "./models/server-data";
20import { ParsedNetworkResponse } from "./models/data-structure";
21import { Request } from "./models/request";
22import * as urls from "./models/urls";
23import * as urlBuilder from "./url-builder";
24import { MediaConfigurationType, MediaTokenService } from "./models/mediapi-configuration";
25import { HTTPMethod, HTTPCachePolicy, HTTPSigningStyle, HTTPHeaders } from "@jet/environment";
26
27/** @public */
28// eslint-disable-next-line @typescript-eslint/no-namespace
29export namespace ResponseMetadata {
30 export const requestedUrl = "_jet-internal:metricsHelpers_requestedUrl";
31
32 /**
33 * Symbol used to place timing metrics values onto fetch responses
34 * without interfering with the data returned by the server.
35 */
36 export const timingValues = "_jet-internal:metricsHelpers_timingValues";
37
38 /**
39 * Key used to access the page information gathered from a response's headers
40 */
41 export const pageInformation = "_jet-internal:metricsHelpers_pageInformation";
42
43 /**
44 * Key used to access the content max-age gathered from a response's headers.
45 */
46 export const contentMaxAge = "_jet-internal:responseMetadata_contentMaxAge";
47}
48
49/**
50 * Module's private fetch implementation built off `net` global.
51 *
52 * @param {FetchRequest} request describes fetch request.
53 * @param {(value: string) => Type} parser Some function parsing response body `string` into specific type.
54 * @returns {Promise<Type>} Promise resolving to specific object.
55 * @throws {Error} Throws error if status code of request is not 200.
56 *
57 * @note Similar to `fetchWithToken` in `media` module, but excludes media token specific functionality.
58 * Top level data fetches to endpoints that don't do redirects, and can benefit from metrics should
59 * call methods that build off of this instead of calling `objectGraph.network.fetch(...)` directly.
60 */
61export async function fetch<Type>(
62 configuration: MediaConfigurationType,
63 request: FetchRequest,
64 parser: (value: Opt<string>) => Type,
65): Promise<Type & ParsedNetworkResponse> {
66 const response = await configuration.network.fetch(request);
67 if (!response.ok) {
68 throw Error(`Bad Status code ${response.status} for ${request.url}`);
69 }
70 const parseStartTime = Date.now();
71 const result = parser(response.body) as Type & ParsedNetworkResponse;
72 const parseEndTime = Date.now();
73
74 // Build full network timing metrics.
75 const completeTimingMetrics = networkTimingMetricsWithParseTime(response.metrics, parseStartTime, parseEndTime);
76 if (serverData.isDefinedNonNull(completeTimingMetrics)) {
77 result[ResponseMetadata.timingValues] = completeTimingMetrics;
78 }
79 result[ResponseMetadata.requestedUrl] = request.url.toString();
80 return result;
81}
82
83/**
84 * Fetch from an endpoint with JSON response body.
85 *
86 * @param {FetchRequest} request to fetch from endpoint with JSON response..
87 * @returns {Promise<Type>} Promise resolving to body of response parsed as `Type`.
88 * @throws {Error} Throws error if status code of request is not 200.
89 */
90export async function fetchJSON<Type>(configuration: MediaConfigurationType, request: FetchRequest): Promise<Type> {
91 return await fetch(configuration, request, (body) => {
92 if (isSome(body)) {
93 return JSON.parse(body) as Type;
94 } else {
95 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
96 return {} as Type;
97 }
98 });
99}
100
101/**
102 * Fetch from an endpoint with XML response body.
103 *
104 * @param {FetchRequest} request to fetch from endpoint with XML response.
105 * @returns {Promise<Type>} Promise resolving to body of response parsed as `Type`.
106 * @throws {Error} Throws error if status code of request is not 200.
107 */
108export async function fetchPlist<Type>(configuration: MediaConfigurationType, request: FetchRequest): Promise<Type> {
109 return await fetch(configuration, request, (body) => {
110 if (isSome(body)) {
111 return configuration.plist.parse(body) as Type;
112 } else {
113 throw new Error(`Could not fetch Plist, response body was not defined for ${request.url}`);
114 }
115 });
116}
117
118/**
119 * With network requests now being created and parsed in JS, different timing metrics are measured in both Native and JS.
120 * This function populates the missing values from `HTTPTimingMetrics`'s native counterpart, `JSNetworkPerformanceMetrics`.
121 *
122 * @param {HTTPTimingMetrics[] | null} responseMetrics Array of response metrics provided by native.
123 * @param {number} parseStartTime Time at which response body string parse began in JS.
124 * @param {number} parseEndTime Time at which response body string parse ended in JS.
125 * @returns {HTTPTimingMetrics | null} Fully populated timing metrics, or `null` if native response provided no metrics events to build off of.
126 */
127function networkTimingMetricsWithParseTime(
128 responseMetrics: HTTPTimingMetrics[] | null,
129 parseStartTime: number,
130 parseEndTime: number,
131): FetchTimingMetrics | null {
132 // No metrics events to build from.
133 if (serverData.isNull(responseMetrics) || responseMetrics.length === 0) {
134 return null;
135 }
136
137 // Append parse times to first partial timing metrics from native.
138 const firstPartialTimingMetrics: FetchTimingMetrics = {
139 ...responseMetrics[0],
140 parseStartTime: parseStartTime,
141 parseEndTime: parseEndTime,
142 };
143 // Timing metrics with all properties populated.
144 return firstPartialTimingMetrics;
145}
146
147export type FetchOptions = {
148 headers?: { [key: string]: string };
149 method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH";
150 requestBodyString?: string;
151 timeout?: number; // in seconds. Check for feature 'supportsRequestTimeoutOption'.
152 /// When true the fetch wont throw if we dont get any data back for given request.
153 allowEmptyDataResponse?: boolean;
154 excludeIdentifierHeadersForAccount?: boolean; // Defaults to false
155 alwaysIncludeAuthKitHeaders?: boolean; // Defaults to true
156 alwaysIncludeMMeClientInfoAndDeviceHeaders?: boolean; // Defaults to true
157};
158
159/**
160 * Implements the MAPI fetch, building URL from MAPI Request and opaquely managing initial token request and refreshes.
161 *
162 * @param {MediaConfigurationType} configuration Base media API configuration.
163 * @param {Request} request MAPI Request to fetch with.
164 * @param {FetchOptions} [options] FetchOptions for the MAPI request.
165 * @returns {Promise<Type>} Promise resolving to some type for given MAPI request.
166 */
167export async function fetchData<Type>(
168 configuration: MediaConfigurationType,
169 mediaToken: MediaTokenService,
170 request: Request,
171 options?: FetchOptions,
172): Promise<Type & ParsedNetworkResponse> {
173 const url = urlBuilder.buildURLFromRequest(configuration, request).toString();
174 const startTime = Date.now();
175 const token = await mediaToken.refreshToken();
176 const response = await fetchWithToken<Type>(
177 configuration,
178 mediaToken,
179 url,
180 token,
181 options,
182 false,
183 configuration.fetchTimingMetricsBuilder,
184 );
185 const endTime = Date.now();
186 if (request.canonicalUrl) {
187 response[ResponseMetadata.requestedUrl] = request.canonicalUrl;
188 }
189 const roundTripTimeIncludingWaiting = endTime - startTime;
190 if (roundTripTimeIncludingWaiting > 500) {
191 console.warn("Fetch took too long (" + roundTripTimeIncludingWaiting.toString() + "ms) " + url);
192 }
193 return response;
194}
195
196export function redirectParametersInUrl(configuration: MediaConfigurationType, url: urls.URL): string[] {
197 const redirectURLParams = configuration.redirectUrlWhitelistedQueryParams;
198 return redirectURLParams.filter((param) => serverData.isDefinedNonNull(url.query?.[param]));
199}
200
201export type MediaAPIFetchRequest = {
202 url: string;
203 excludeIdentifierHeadersForAccount?: boolean;
204 alwaysIncludeAuthKitHeaders?: boolean;
205 alwaysIncludeMMeClientInfoAndDeviceHeaders?: boolean;
206 method?: Opt<HTTPMethod>;
207 cache?: Opt<HTTPCachePolicy>;
208 signingStyle?: Opt<HTTPSigningStyle>;
209 headers?: Opt<HTTPHeaders>;
210 timeout?: Opt<number>;
211 body?: Opt<string>;
212};
213
214/**
215 * Given a built URL, token, and options, calls into native networking APIs to fetch content.
216 *
217 * @param {string} url URL to fetch data from.
218 * @param {string} token MAPI token key.
219 * @param {FetchOptions} options Fetch options for MAPI requests.
220 * @param {boolean} isRetry flag indicating whether this is a fetch retry following a 401 request, and media token was refreshed.
221 * @returns {Promise<Type>} Promise resolving to some type for given MAPI request.
222 */
223async function fetchWithToken<Type>(
224 configuration: MediaConfigurationType,
225 mediaToken: MediaTokenService,
226 url: string,
227 token: string,
228 options: FetchOptions = {},
229 isRetry = false,
230 fetchTimingMetricsBuilder: Opt<FetchTimingMetricsBuilder>,
231): Promise<Type & ParsedNetworkResponse> {
232 // Removes all affiliate/redirect params for caching (https://connectme.apple.com/docs/DOC-577671)
233 const filteredURL = new urls.URL(url);
234 const redirectParameters = redirectParametersInUrl(configuration, filteredURL);
235 for (const param of redirectParameters) {
236 filteredURL.removeParam(param);
237 }
238 const filteredUrlString = filteredURL.toString();
239
240 let headers = options.headers;
241 if (headers == null) {
242 headers = {};
243 }
244 headers["Authorization"] = "Bearer " + token;
245
246 const fetchRequest: MediaAPIFetchRequest = {
247 url: filteredUrlString,
248 excludeIdentifierHeadersForAccount: options.excludeIdentifierHeadersForAccount ?? false,
249 alwaysIncludeAuthKitHeaders: options.alwaysIncludeAuthKitHeaders ?? true,
250 alwaysIncludeMMeClientInfoAndDeviceHeaders: options.alwaysIncludeMMeClientInfoAndDeviceHeaders ?? true,
251 headers: headers,
252 method: options.method,
253 body: options.requestBodyString,
254 timeout: options.timeout,
255 };
256
257 const response = await configuration.network.fetch(fetchRequest);
258
259 try {
260 if (response.status === 401 || response.status === 403) {
261 if (isRetry) {
262 throw Error("We refreshed the token but we still get 401 from the API");
263 }
264 mediaToken.resetToken();
265 return await mediaToken.refreshToken().then(async (newToken) => {
266 // Explicitly re-fetch with the original request so logging and metrics are correct
267 return await fetchWithToken<Type>(
268 configuration,
269 mediaToken,
270 url,
271 newToken,
272 options,
273 true,
274 fetchTimingMetricsBuilder,
275 );
276 });
277 } else if (response.status === 404) {
278 // item is not available in this storefront or perhaps not at all
279 throw noContentError();
280 } else if (!response.ok) {
281 const correlationKey = response.headers["x-apple-jingle-correlation-key"] ?? "N/A";
282 const error = new NetworkError(
283 `Bad Status code ${response.status} (correlationKey: ${correlationKey}) for ${filteredUrlString}, original ${url}`,
284 );
285 error.statusCode = response.status;
286 throw error;
287 }
288
289 const parser = (resp: FetchResponse) => {
290 const parseStartTime = Date.now();
291 let result: Type & ParsedNetworkResponse;
292 if (serverData.isNull(resp.body) || resp.body === "") {
293 if (resp.status === 204) {
294 // 204 indicates a success, but the response will typically be empty
295 // Create a fake result so that we don't throw an error when JSON parsing
296 const emptyData: ParsedNetworkResponse = {};
297 result = emptyData as Type & ParsedNetworkResponse;
298 } else {
299 throw noContentError();
300 }
301 } else {
302 result = JSON.parse(resp.body) as Type & ParsedNetworkResponse;
303 }
304 const parseEndTime = Date.now();
305
306 result[ResponseMetadata.pageInformation] = serverData.asJSONData(
307 getPageInformationFromResponse(configuration, resp),
308 );
309 if (resp.metrics.length > 0) {
310 const metrics: FetchTimingMetrics = {
311 ...resp.metrics[0],
312 parseStartTime: parseStartTime,
313 parseEndTime: parseEndTime,
314 };
315 result[ResponseMetadata.timingValues] = metrics;
316 } else {
317 const fallbackMetrics: FetchTimingMetrics = {
318 pageURL: resp.url,
319 parseStartTime,
320 parseEndTime,
321 };
322 result[ResponseMetadata.timingValues] = fallbackMetrics;
323 }
324 result[ResponseMetadata.contentMaxAge] = getContentTimeToLiveFromResponse(resp);
325
326 // If we have an empty data object, throw a 204 (No Content).
327 if (
328 Array.isArray(result.data) &&
329 serverData.isArrayDefinedNonNullAndEmpty(result.data) &&
330 !serverData.asBooleanOrFalse(options.allowEmptyDataResponse)
331 ) {
332 throw noContentError();
333 }
334
335 result[ResponseMetadata.requestedUrl] = url;
336 return result;
337 };
338 if (isSome(fetchTimingMetricsBuilder)) {
339 return fetchTimingMetricsBuilder.measureParsing(response, parser);
340 } else {
341 return parser(response);
342 }
343 } catch (e) {
344 if (e instanceof NetworkError) {
345 throw e;
346 }
347 throw new Error(`Error Fetching - filtered: ${filteredUrlString}, original: ${url}, ${e.name}, ${e.message}`);
348 }
349}
350
351export class NetworkError extends Error {
352 statusCode?: number;
353}
354
355function noContentError(): NetworkError {
356 const error = new NetworkError(`No content`);
357 error.statusCode = 204;
358 return error;
359}
360
361const serverInstanceHeader = "x-apple-application-instance";
362
363const environmentDataCenterHeader = "x-apple-application-site";
364
365function getPageInformationFromResponse(
366 configuration: MediaConfigurationType,
367 response: FetchResponse,
368): MetricsFields | null {
369 const storeFrontHeader: string = configuration.storefrontIdentifier;
370
371 let storeFront: Opt<string> = null;
372 if (serverData.isDefinedNonNullNonEmpty(storeFrontHeader)) {
373 const storeFrontHeaderComponents: string[] = storeFrontHeader.split("-");
374 if (serverData.isDefinedNonNullNonEmpty(storeFrontHeaderComponents)) {
375 storeFront = storeFrontHeaderComponents[0];
376 }
377 }
378
379 return {
380 serverInstance: response.headers[serverInstanceHeader],
381 storeFrontHeader: storeFrontHeader,
382 language: configuration.bagLanguage,
383 storeFront: storeFront,
384 environmentDataCenter: response.headers[environmentDataCenterHeader],
385 };
386}
387
388function getContentTimeToLiveFromResponse(response: FetchResponse): Opt<number> {
389 const cacheControlHeaderKey = Object.keys(response.headers).find((key) => key.toLowerCase() === "cache-control");
390 if (serverData.isNull(cacheControlHeaderKey) || cacheControlHeaderKey === "") {
391 return null;
392 }
393
394 const headerValue = response.headers[cacheControlHeaderKey];
395 if (serverData.isNullOrEmpty(headerValue)) {
396 return null;
397 }
398 const matches = headerValue.match(/max-age=(\d+)/);
399 if (serverData.isNull(matches) || matches.length < 2) {
400 return null;
401 }
402 return serverData.asNumber(matches[1]);
403}