/** * Created by joel on 20/2/2018. * * This `network.ts` is the Media API arm of the netwok fetch requests. * It is built on `Network` object and provides standard functionality specific for MAPI. * * @see `src/network.ts` for fetching from non-Media API endpoints */ import { isNothing, isSome } from "@jet/environment/types/optional"; import * as serverData from "../json-parsing/server-data"; import { MetricsIdentifierType } from "../metrics/metrics-identifiers-cache"; import { ResponseMetadata } from "../network/network"; import * as urls from "../network/urls"; import { extractMetaAssociationFromData } from "./data-fetching"; import * as urlBuilder from "./url-builder"; const secondaryUserIdHeaderName = "X-Apple-AppStore-UserId-Secondary"; /** * Implements the MAPI fetch, building URL from MAPI Request and opaquely managing initial token request and refreshes. * * @param {Request} request MAPI Request to fetch with. * @param {FetchOptions} options? FetchOptions for the MAPI request. * @returns {Promise} Promise resolving to some type for given MAPI request. */ export async function fetchData(objectGraph, request, options) { var _a; const startTime = Date.now(); const token = await objectGraph.mediaToken.refreshToken(); const fetchTimingMetricsBuilder = objectGraph.fetchTimingMetricsBuilder; const requestOptions = options !== null && options !== void 0 ? options : {}; const personalizationIdentifier = (_a = objectGraph.personalizationMetricsIdentifiersCache) === null || _a === void 0 ? void 0 : _a.getMetricsIdForType(MetricsIdentifierType.user); if (isSome(personalizationIdentifier)) { if (isSome(requestOptions === null || requestOptions === void 0 ? void 0 : requestOptions.headers)) { requestOptions.headers[secondaryUserIdHeaderName] = personalizationIdentifier; } else { requestOptions.headers = { [secondaryUserIdHeaderName]: personalizationIdentifier, }; } } const response = await fetchWithToken(objectGraph, request, token, requestOptions, false, fetchTimingMetricsBuilder); const endTime = Date.now(); if (request.canonicalUrl) { response[ResponseMetadata.requestedUrl] = request.canonicalUrl; } const roundTripTimeIncludingWaiting = endTime - startTime; if (roundTripTimeIncludingWaiting > 500) { const longFetchUrl = urlBuilder.buildURLFromRequest(objectGraph, request).toString(); objectGraph.console.warn("Fetch took too long (" + roundTripTimeIncludingWaiting.toString() + "ms) " + longFetchUrl); } return response; } export function redirectParametersInUrl(objectGraph, url) { const redirectURLParams = objectGraph.bag.redirectUrlWhitelistedQueryParams; return redirectURLParams.filter((param) => { var _a; return serverData.isDefinedNonNull((_a = url.query) === null || _a === void 0 ? void 0 : _a[param]); }); } /** * 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} request The original request used to build this url. * @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} Promise resolving to some type for given MAPI request. */ async function fetchWithToken(objectGraph, request, token, options = {}, isRetry = false, fetchTimingMetricsBuilder) { var _a, _b; const originalUrl = urlBuilder.buildURLFromRequest(objectGraph, request).toString(); // Removes all affiliate/redirect params for caching (https://connectme.apple.com/docs/DOC-577671) const filteredURL = new urls.URL(originalUrl); const redirectParameters = redirectParametersInUrl(objectGraph, filteredURL); for (const param of redirectParameters) { filteredURL.removeParam(param); } const filteredUrlString = filteredURL.toString(); let headers = options.headers; if (!headers) { headers = {}; } headers["Authorization"] = "Bearer " + token; const response = await objectGraph.network.fetch({ url: filteredUrlString, headers: headers, method: options.method, body: options.requestBodyString, timeout: options.timeout, }); try { if (response.status === 401 || response.status === 403) { if (isRetry) { throw Error("We refreshed the token but we still get 401 from the API"); } objectGraph.mediaToken.resetToken(); return await objectGraph.mediaToken.refreshToken().then(async (newToken) => { // Explicitly re-fetch with the original request so logging and metrics are correct return await fetchWithToken(objectGraph, request, 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 error = new NetworkError(`Bad Status code ${response.status} for ${filteredUrlString}, original ${originalUrl}`); error.statusCode = response.status; throw error; } const parser = (resp) => { var _a; const parseStartTime = Date.now(); let result; 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 = {}; result = emptyData; } else { throw noContentError(); } } else { try { result = JSON.parse(resp.body); } catch (e) { let errorMessage = e.message; if (["debug", "internal"].includes(objectGraph.client.buildType)) { errorMessage = `${e.message}, body: ${resp.body}`; } throw new JSONParseError(errorMessage); } } const parseEndTime = Date.now(); if (result) { result[ResponseMetadata.pageInformation] = serverData.asJSONData(getPageInformationFromResponse(objectGraph, resp)); if (resp.metrics.length > 0) { const metrics = { ...resp.metrics[0], parseStartTime: parseStartTime, parseEndTime: parseEndTime, }; result[ResponseMetadata.timingValues] = metrics; } else { const fallbackMetrics = { pageURL: resp.url, parseStartTime, parseEndTime, }; result[ResponseMetadata.timingValues] = fallbackMetrics; } result[ResponseMetadata.contentMaxAge] = getContentTimeToLiveFromResponse(objectGraph, 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(); } if (Array.isArray(result.data) && request.originalOrdering.length > 1) { result.data = buildHydratedDataListResponse(objectGraph, request.originalOrdering, (_a = result.data) !== null && _a !== void 0 ? _a : [], request.supplementaryMetadataAssociations); } result[ResponseMetadata.requestedUrl] = originalUrl; } return result; }; if (isSome(fetchTimingMetricsBuilder)) { return fetchTimingMetricsBuilder.measureParsing(response, parser); } else { return parser(response); } } catch (e) { if (e instanceof NetworkError) { throw e; } const correlationKey = (_a = response.headers["x-apple-jingle-correlation-key"]) !== null && _a !== void 0 ? _a : (_b = response.metrics[0]) === null || _b === void 0 ? void 0 : _b.clientCorrelationKey; throw new Error(`Error Fetching - filtered: ${filteredUrlString}, original: ${originalUrl}, correlationKey: ${correlationKey !== null && correlationKey !== void 0 ? correlationKey : "N/A"}, ${e.name}, ${e.message}`); } } export class NetworkError extends Error { } class JSONParseError extends Error { } export function noContentError() { const error = new NetworkError(`No content`); error.statusCode = 204; return error; } export function notFoundError() { const error = new NetworkError("Not found"); error.statusCode = 404; return error; } const serverInstanceHeader = "x-apple-application-instance"; const environmentDataCenterHeader = "x-apple-application-site"; function getPageInformationFromResponse(objectGraph, response) { const storeFrontHeader = objectGraph.client.storefrontIdentifier; let storeFront = null; if ((storeFrontHeader === null || storeFrontHeader === void 0 ? void 0 : storeFrontHeader.length) > 0) { const storeFrontHeaderComponents = storeFrontHeader.split("-"); if (serverData.isDefinedNonNullNonEmpty(storeFrontHeaderComponents)) { storeFront = storeFrontHeaderComponents[0]; } } return { serverInstance: response.headers[serverInstanceHeader], storeFrontHeader: storeFrontHeader, language: objectGraph.bag.language, storeFront: storeFront, environmentDataCenter: response.headers[environmentDataCenterHeader], }; } function getContentTimeToLiveFromResponse(objectGraph, response) { 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]); } /** * Builds the hydrated list of shelf items, based on the data in the response, and the original unhydated items. */ function buildHydratedDataListResponse(objectGraph, unhydratedData, hydratedDataCollection, associations = []) { // Create a map of all the hydrated data items from the response, using the resource type and ID as the key const hydratedDataMap = {}; for (const dataItem of hydratedDataCollection) { const key = dataMapKey(objectGraph, dataItem.type, dataItem.id); hydratedDataMap[key] = dataItem; } // Next iterate through the unhyrated items in their original order, and swap in the hydrated item. const hydratedItems = []; for (const unhydratedItem of unhydratedData) { const key = dataMapKey(objectGraph, unhydratedItem.type, unhydratedItem.id); const hydratedItem = hydratedDataMap[key]; if (isSome(hydratedItem)) { /// Start with a base of what was there already, this is to ensure we don't lose any existing meta data /// if some of the requested associations are not present in the response. if (serverData.isDefinedNonNullNonEmpty(associations)) { hydratedItem.meta = { ...unhydratedItem.meta, }; for (const association of associations) { hydrateMetaAssociationIfNecessary(objectGraph, association, hydratedItem, unhydratedItem, hydratedDataMap); } } hydratedItems.push(hydratedItem); } } return hydratedItems; } /** * Creates a key to use in a mapping of data items. * @param objectGraph Current object graph * @param data The data item * @returns A string composed from the data item ID and resource type */ function dataMapKey(objectGraph, resourceType, id) { return `${resourceType}_${id}`; } /** * @param objectGraph The current object graph * @param association The association we'd like to hydrate in the meta object * @param unhydratedItem The original MAPI data item to look for editorial card, and fill in with a fetched card if there is one * @param hydratedDataMap The data map of all the fetched items keyed by id / type */ function hydrateMetaAssociationIfNecessary(objectGraph, association, hydratedItem, unhydratedItem, hydratedDataMap) { var _a; if (isNothing(hydratedItem.meta)) { hydratedItem.meta = { associations: {}, }; } else if (isNothing(hydratedItem.meta.associations)) { hydratedItem.meta.associations = {}; } const unhydratedAssociationsData = extractMetaAssociationFromData(association, unhydratedItem); if (serverData.isDefinedNonNullNonEmpty(unhydratedAssociationsData)) { const hydratedAssociationData = []; for (const unhydratedAssociation of unhydratedAssociationsData) { const associationDataKey = dataMapKey(objectGraph, unhydratedAssociation.type, unhydratedAssociation.id); const associationData = hydratedDataMap[associationDataKey]; if (isSome(associationData)) { hydratedAssociationData.push(associationData); } } const hydratedAssociations = (_a = serverData.asDictionary(hydratedItem.meta.associations)) !== null && _a !== void 0 ? _a : {}; hydratedAssociations[association] = { data: hydratedAssociationData, }; } } //# sourceMappingURL=network.js.map