summaryrefslogtreecommitdiff
path: root/node_modules/@apple-media-services/media-api/src/network.ts
blob: 48f1ccf277806b0ca9d270ef429062d56f05fb66 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
/**
 * Created by ls on 9/7/2018.
 *
 * This `network.ts` is the NON-MEDIA API arm of network fetch requests.
 * It is built on `Network` object and provides standard functionality, such as:
 *     1. Parsing the body into specific format.
 *     2. Adding timing metrics onto blob.
 *
 * This should *only* be used for objects that should have timing metrics, i.e. requests to Non-MediaAPI endpoints
 * that will ultimately render some whole page. Otherwise, use `objectGraph.network.fetch` directly.
 *
 * @see `src/media/network.ts` for fetching from Media API endpoints
 */

import { FetchRequest, FetchResponse, HTTPTimingMetrics } from "@jet/environment/types/globals/net";
import { FetchTimingMetrics, MetricsFields } from "@jet/environment/types/metrics";
import { FetchTimingMetricsBuilder } from "@jet/environment/metrics";
import { isSome, Opt } from "@jet/environment/types/optional";
import * as serverData from "./models/server-data";
import { ParsedNetworkResponse } from "./models/data-structure";
import { Request } from "./models/request";
import * as urls from "./models/urls";
import * as urlBuilder from "./url-builder";
import { MediaConfigurationType, MediaTokenService } from "./models/mediapi-configuration";
import { HTTPMethod, HTTPCachePolicy, HTTPSigningStyle, HTTPHeaders } from "@jet/environment";

/** @public */
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ResponseMetadata {
    export const requestedUrl = "_jet-internal:metricsHelpers_requestedUrl";

    /**
     * Symbol used to place timing metrics values onto fetch responses
     * without interfering with the data returned by the server.
     */
    export const timingValues = "_jet-internal:metricsHelpers_timingValues";

    /**
     * Key used to access the page information gathered from a response's headers
     */
    export const pageInformation = "_jet-internal:metricsHelpers_pageInformation";

    /**
     * Key used to access the content max-age gathered from a response's headers.
     */
    export const contentMaxAge = "_jet-internal:responseMetadata_contentMaxAge";
}

/**
 * Module's private fetch implementation built off `net` global.
 *
 * @param {FetchRequest} request describes fetch request.
 * @param {(value: string) => Type} parser Some function parsing response body `string` into specific type.
 * @returns {Promise<Type>} Promise resolving to specific object.
 * @throws {Error} Throws error if status code of request is not 200.
 *
 * @note Similar to `fetchWithToken` in `media` module, but excludes media token specific functionality.
 * Top level data fetches to endpoints that don't do redirects, and can benefit from metrics should
 * call methods that build off of this instead of calling `objectGraph.network.fetch(...)` directly.
 */
export async function fetch<Type>(
    configuration: MediaConfigurationType,
    request: FetchRequest,
    parser: (value: Opt<string>) => Type,
): Promise<Type & ParsedNetworkResponse> {
    const response = await configuration.network.fetch(request);
    if (!response.ok) {
        throw Error(`Bad Status code ${response.status} for ${request.url}`);
    }
    const parseStartTime = Date.now();
    const result = parser(response.body) as Type & ParsedNetworkResponse;
    const parseEndTime = Date.now();

    // Build full network timing metrics.
    const completeTimingMetrics = networkTimingMetricsWithParseTime(response.metrics, parseStartTime, parseEndTime);
    if (serverData.isDefinedNonNull(completeTimingMetrics)) {
        result[ResponseMetadata.timingValues] = completeTimingMetrics;
    }
    result[ResponseMetadata.requestedUrl] = request.url.toString();
    return result;
}

/**
 * Fetch from an endpoint with JSON response body.
 *
 * @param {FetchRequest} request to fetch from endpoint with JSON response..
 * @returns {Promise<Type>} Promise resolving to body of response parsed as `Type`.
 * @throws {Error} Throws error if status code of request is not 200.
 */
export async function fetchJSON<Type>(configuration: MediaConfigurationType, request: FetchRequest): Promise<Type> {
    return await fetch(configuration, request, (body) => {
        if (isSome(body)) {
            return JSON.parse(body) as Type;
        } else {
            // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
            return {} as Type;
        }
    });
}

/**
 * Fetch from an endpoint with XML response body.
 *
 * @param {FetchRequest} request to fetch from endpoint with XML response.
 * @returns {Promise<Type>} Promise resolving to body of response parsed as `Type`.
 * @throws {Error} Throws error if status code of request is not 200.
 */
export async function fetchPlist<Type>(configuration: MediaConfigurationType, request: FetchRequest): Promise<Type> {
    return await fetch(configuration, request, (body) => {
        if (isSome(body)) {
            return configuration.plist.parse(body) as Type;
        } else {
            throw new Error(`Could not fetch Plist, response body was not defined for ${request.url}`);
        }
    });
}

/**
 * With network requests now being created and parsed in JS, different timing metrics are measured in both Native and JS.
 * This function populates the missing values from `HTTPTimingMetrics`'s native counterpart, `JSNetworkPerformanceMetrics`.
 *
 * @param {HTTPTimingMetrics[] | null} responseMetrics Array of response metrics provided by native.
 * @param {number} parseStartTime Time at which response body string parse began in JS.
 * @param {number} parseEndTime Time at which response body string parse ended in JS.
 * @returns {HTTPTimingMetrics | null} Fully populated timing metrics, or `null` if native response provided no metrics events to build off of.
 */
function networkTimingMetricsWithParseTime(
    responseMetrics: HTTPTimingMetrics[] | null,
    parseStartTime: number,
    parseEndTime: number,
): FetchTimingMetrics | null {
    // No metrics events to build from.
    if (serverData.isNull(responseMetrics) || responseMetrics.length === 0) {
        return null;
    }

    // Append parse times to first partial timing metrics from native.
    const firstPartialTimingMetrics: FetchTimingMetrics = {
        ...responseMetrics[0],
        parseStartTime: parseStartTime,
        parseEndTime: parseEndTime,
    };
    // Timing metrics with all properties populated.
    return firstPartialTimingMetrics;
}

export type FetchOptions = {
    headers?: { [key: string]: string };
    method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH";
    requestBodyString?: string;
    timeout?: number; // in seconds. Check for feature 'supportsRequestTimeoutOption'.
    /// When true the fetch wont throw if we dont get any data back for given request.
    allowEmptyDataResponse?: boolean;
    excludeIdentifierHeadersForAccount?: boolean; // Defaults to false
    alwaysIncludeAuthKitHeaders?: boolean; // Defaults to true
    alwaysIncludeMMeClientInfoAndDeviceHeaders?: boolean; // Defaults to true
};

/**
 * Implements the MAPI fetch, building URL from MAPI Request and opaquely managing initial token request and refreshes.
 *
 * @param {MediaConfigurationType} configuration Base media API configuration.
 * @param {Request} request MAPI Request to fetch with.
 * @param {FetchOptions} [options] FetchOptions for the MAPI request.
 * @returns {Promise<Type>} Promise resolving to some type for given MAPI request.
 */
export async function fetchData<Type>(
    configuration: MediaConfigurationType,
    mediaToken: MediaTokenService,
    request: Request,
    options?: FetchOptions,
): Promise<Type & ParsedNetworkResponse> {
    const url = urlBuilder.buildURLFromRequest(configuration, request).toString();
    const startTime = Date.now();
    const token = await mediaToken.refreshToken();
    const response = await fetchWithToken<Type>(
        configuration,
        mediaToken,
        url,
        token,
        options,
        false,
        configuration.fetchTimingMetricsBuilder,
    );
    const endTime = Date.now();
    if (request.canonicalUrl) {
        response[ResponseMetadata.requestedUrl] = request.canonicalUrl;
    }
    const roundTripTimeIncludingWaiting = endTime - startTime;
    if (roundTripTimeIncludingWaiting > 500) {
        console.warn("Fetch took too long (" + roundTripTimeIncludingWaiting.toString() + "ms) " + url);
    }
    return response;
}

export function redirectParametersInUrl(configuration: MediaConfigurationType, url: urls.URL): string[] {
    const redirectURLParams = configuration.redirectUrlWhitelistedQueryParams;
    return redirectURLParams.filter((param) => serverData.isDefinedNonNull(url.query?.[param]));
}

export type MediaAPIFetchRequest = {
    url: string;
    excludeIdentifierHeadersForAccount?: boolean;
    alwaysIncludeAuthKitHeaders?: boolean;
    alwaysIncludeMMeClientInfoAndDeviceHeaders?: boolean;
    method?: Opt<HTTPMethod>;
    cache?: Opt<HTTPCachePolicy>;
    signingStyle?: Opt<HTTPSigningStyle>;
    headers?: Opt<HTTPHeaders>;
    timeout?: Opt<number>;
    body?: Opt<string>;
};

/**
 * Given a built URL, token, and options, calls into native networking APIs to fetch content.
 *
 * @param {string} url URL to fetch data from.
 * @param {string} token MAPI token key.
 * @param {FetchOptions} options Fetch options for MAPI requests.
 * @param {boolean} isRetry flag indicating whether this is a fetch retry following a 401 request, and media token was refreshed.
 * @returns {Promise<Type>} Promise resolving to some type for given MAPI request.
 */
async function fetchWithToken<Type>(
    configuration: MediaConfigurationType,
    mediaToken: MediaTokenService,
    url: string,
    token: string,
    options: FetchOptions = {},
    isRetry = false,
    fetchTimingMetricsBuilder: Opt<FetchTimingMetricsBuilder>,
): Promise<Type & ParsedNetworkResponse> {
    // Removes all affiliate/redirect params for caching (https://connectme.apple.com/docs/DOC-577671)
    const filteredURL = new urls.URL(url);
    const redirectParameters = redirectParametersInUrl(configuration, filteredURL);
    for (const param of redirectParameters) {
        filteredURL.removeParam(param);
    }
    const filteredUrlString = filteredURL.toString();

    let headers = options.headers;
    if (headers == null) {
        headers = {};
    }
    headers["Authorization"] = "Bearer " + token;

    const fetchRequest: MediaAPIFetchRequest = {
        url: filteredUrlString,
        excludeIdentifierHeadersForAccount: options.excludeIdentifierHeadersForAccount ?? false,
        alwaysIncludeAuthKitHeaders: options.alwaysIncludeAuthKitHeaders ?? true,
        alwaysIncludeMMeClientInfoAndDeviceHeaders: options.alwaysIncludeMMeClientInfoAndDeviceHeaders ?? true,
        headers: headers,
        method: options.method,
        body: options.requestBodyString,
        timeout: options.timeout,
    };

    const response = await configuration.network.fetch(fetchRequest);

    try {
        if (response.status === 401 || response.status === 403) {
            if (isRetry) {
                throw Error("We refreshed the token but we still get 401 from the API");
            }
            mediaToken.resetToken();
            return await mediaToken.refreshToken().then(async (newToken) => {
                // Explicitly re-fetch with the original request so logging and metrics are correct
                return await fetchWithToken<Type>(
                    configuration,
                    mediaToken,
                    url,
                    newToken,
                    options,
                    true,
                    fetchTimingMetricsBuilder,
                );
            });
        } else if (response.status === 404) {
            // item is not available in this storefront or perhaps not at all
            throw noContentError();
        } else if (!response.ok) {
            const correlationKey = response.headers["x-apple-jingle-correlation-key"] ?? "N/A";
            const error = new NetworkError(
                `Bad Status code ${response.status} (correlationKey: ${correlationKey}) for ${filteredUrlString}, original ${url}`,
            );
            error.statusCode = response.status;
            throw error;
        }

        const parser = (resp: FetchResponse) => {
            const parseStartTime = Date.now();
            let result: Type & ParsedNetworkResponse;
            if (serverData.isNull(resp.body) || resp.body === "") {
                if (resp.status === 204) {
                    // 204 indicates a success, but the response will typically be empty
                    // Create a fake result so that we don't throw an error when JSON parsing
                    const emptyData: ParsedNetworkResponse = {};
                    result = emptyData as Type & ParsedNetworkResponse;
                } else {
                    throw noContentError();
                }
            } else {
                result = JSON.parse(resp.body) as Type & ParsedNetworkResponse;
            }
            const parseEndTime = Date.now();

            result[ResponseMetadata.pageInformation] = serverData.asJSONData(
                getPageInformationFromResponse(configuration, resp),
            );
            if (resp.metrics.length > 0) {
                const metrics: FetchTimingMetrics = {
                    ...resp.metrics[0],
                    parseStartTime: parseStartTime,
                    parseEndTime: parseEndTime,
                };
                result[ResponseMetadata.timingValues] = metrics;
            } else {
                const fallbackMetrics: FetchTimingMetrics = {
                    pageURL: resp.url,
                    parseStartTime,
                    parseEndTime,
                };
                result[ResponseMetadata.timingValues] = fallbackMetrics;
            }
            result[ResponseMetadata.contentMaxAge] = getContentTimeToLiveFromResponse(resp);

            // If we have an empty data object, throw a 204 (No Content).
            if (
                Array.isArray(result.data) &&
                serverData.isArrayDefinedNonNullAndEmpty(result.data) &&
                !serverData.asBooleanOrFalse(options.allowEmptyDataResponse)
            ) {
                throw noContentError();
            }

            result[ResponseMetadata.requestedUrl] = url;
            return result;
        };
        if (isSome(fetchTimingMetricsBuilder)) {
            return fetchTimingMetricsBuilder.measureParsing(response, parser);
        } else {
            return parser(response);
        }
    } catch (e) {
        if (e instanceof NetworkError) {
            throw e;
        }
        throw new Error(`Error Fetching - filtered: ${filteredUrlString}, original: ${url}, ${e.name}, ${e.message}`);
    }
}

export class NetworkError extends Error {
    statusCode?: number;
}

function noContentError(): NetworkError {
    const error = new NetworkError(`No content`);
    error.statusCode = 204;
    return error;
}

const serverInstanceHeader = "x-apple-application-instance";

const environmentDataCenterHeader = "x-apple-application-site";

function getPageInformationFromResponse(
    configuration: MediaConfigurationType,
    response: FetchResponse,
): MetricsFields | null {
    const storeFrontHeader: string = configuration.storefrontIdentifier;

    let storeFront: Opt<string> = null;
    if (serverData.isDefinedNonNullNonEmpty(storeFrontHeader)) {
        const storeFrontHeaderComponents: string[] = storeFrontHeader.split("-");
        if (serverData.isDefinedNonNullNonEmpty(storeFrontHeaderComponents)) {
            storeFront = storeFrontHeaderComponents[0];
        }
    }

    return {
        serverInstance: response.headers[serverInstanceHeader],
        storeFrontHeader: storeFrontHeader,
        language: configuration.bagLanguage,
        storeFront: storeFront,
        environmentDataCenter: response.headers[environmentDataCenterHeader],
    };
}

function getContentTimeToLiveFromResponse(response: FetchResponse): Opt<number> {
    const cacheControlHeaderKey = Object.keys(response.headers).find((key) => key.toLowerCase() === "cache-control");
    if (serverData.isNull(cacheControlHeaderKey) || cacheControlHeaderKey === "") {
        return null;
    }

    const headerValue = response.headers[cacheControlHeaderKey];
    if (serverData.isNullOrEmpty(headerValue)) {
        return null;
    }
    const matches = headerValue.match(/max-age=(\d+)/);
    if (serverData.isNull(matches) || matches.length < 2) {
        return null;
    }
    return serverData.asNumber(matches[1]);
}