this repo has no description
at main 403 lines 17 kB view raw
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}