summaryrefslogtreecommitdiff
path: root/node_modules/@apple-media-services/media-api/src/network.ts
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /node_modules/@apple-media-services/media-api/src/network.ts
init commit
Diffstat (limited to 'node_modules/@apple-media-services/media-api/src/network.ts')
-rw-r--r--node_modules/@apple-media-services/media-api/src/network.ts403
1 files changed, 403 insertions, 0 deletions
diff --git a/node_modules/@apple-media-services/media-api/src/network.ts b/node_modules/@apple-media-services/media-api/src/network.ts
new file mode 100644
index 0000000..48f1ccf
--- /dev/null
+++ b/node_modules/@apple-media-services/media-api/src/network.ts
@@ -0,0 +1,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]);
+}