diff options
| author | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
|---|---|---|
| committer | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
| commit | bce557cc2dc767628bed6aac87301a1be7c5431b (patch) | |
| tree | b51a051228d01fe3306cd7626d4a96768aadb944 /node_modules/@apple-media-services/media-api/src | |
init commit
Diffstat (limited to 'node_modules/@apple-media-services/media-api/src')
4 files changed, 1637 insertions, 0 deletions
diff --git a/node_modules/@apple-media-services/media-api/src/models/attributes.ts b/node_modules/@apple-media-services/media-api/src/models/attributes.ts new file mode 100644 index 0000000..edb5fca --- /dev/null +++ b/node_modules/@apple-media-services/media-api/src/models/attributes.ts @@ -0,0 +1,289 @@ +import { Opt, isNothing } from "@jet/environment/types/optional"; +import * as serverData from "./server-data"; +import * as media from "./data-structure"; +import { JSONValue, MapLike, JSONData } from "./json-types"; +import * as errors from "./errors"; + +// region Generic Attribute retrieval + +// region Attribute retrieval + +/** + * Retrieve the specified attribute from the data, coercing it to a JSONData dictionary + * + * @param data The data from which to retrieve the attribute. + * @param attributePath The path of the attribute. + * @param defaultValue The object to return if the path search fails. + * @returns The dictionary of data + */ +export function attributeAsDictionary<Type extends JSONValue>( + data: media.Data, + attributePath?: serverData.ObjectPath, + defaultValue?: MapLike<Type>, +): MapLike<Type> | null { + if (serverData.isNull(data)) { + return null; + } + return serverData.asDictionary(data.attributes, attributePath, defaultValue); +} + +/** + * Retrieve the specified attribute from the data, coercing it to an Interface + * + * @param data The data from which to retrieve the attribute. + * @param attributePath The path of the attribute. + * @param defaultValue The object to return if the path search fails. + * @returns The dictionary of data as an interface + */ +export function attributeAsInterface<Interface>( + data: media.Data, + attributePath?: serverData.ObjectPath, + defaultValue?: JSONData, +): Interface | null { + return attributeAsDictionary(data, attributePath, defaultValue) as unknown as Interface; +} + +/** + * Retrieve the specified attribute from the data as an array, coercing to an empty array if the object is not an array. + * + * @param data The data from which to retrieve the attribute. + * @param attributePath The path of the attribute. + * @returns {any[]} The attribute value as an array. + */ +export function attributeAsArrayOrEmpty<T extends JSONValue>( + data: media.Data, + attributePath?: serverData.ObjectPath, +): T[] { + if (serverData.isNull(data)) { + return []; + } + return serverData.asArrayOrEmpty(data.attributes, attributePath); +} + +/** + * Retrieve the specified attribute from the data as a string. + * + * @param data The data from which to retrieve the attribute. + * @param attributePath The object path for the attribute. + * @param policy The validation policy to use when resolving this value. + * @returns {string} The attribute value as a string. + */ +export function attributeAsString( + data: media.Data, + attributePath?: serverData.ObjectPath, + policy: serverData.ValidationPolicy = "coercible", +): Opt<string> { + if (serverData.isNull(data)) { + return null; + } + return serverData.asString(data.attributes, attributePath, policy); +} + +/** + * Retrieve the specified meta from the data as a string. + * + * @param data The data from which to retrieve the attribute. + * @param metaPath The object path for the meta. + * @param policy The validation policy to use when resolving this value. + * @returns {string} The meta value as a string. + */ +export function metaAsString( + data: media.Data, + metaPath?: serverData.ObjectPath, + policy: serverData.ValidationPolicy = "coercible", +): Opt<string> { + if (serverData.isNull(data)) { + return null; + } + return serverData.asString(data.meta, metaPath, policy); +} + +/** + * Retrieve the specified attribute from the data as a date. + * + * @param data The data from which to retrieve the attribute. + * @param attributePath The object path for the attribute. + * @param policy The validation policy to use when resolving this value. + * @returns {Date} The attribute value as a date. + */ +export function attributeAsDate( + data: media.Data, + attributePath?: serverData.ObjectPath, + policy: serverData.ValidationPolicy = "coercible", +): Opt<Date> { + if (serverData.isNull(data)) { + return null; + } + const dateString = serverData.asString(data.attributes, attributePath, policy); + if (isNothing(dateString)) { + return null; + } + return new Date(dateString); +} + +/** + * Retrieve the specified attribute from the data as a boolean. + * + * @param data The data from which to retrieve the attribute. + * @param attributePath The path of the attribute. + * @param policy The validation policy to use when resolving this value. + * @returns {boolean} The attribute value as a boolean. + */ +export function attributeAsBoolean( + data: media.Data, + attributePath?: serverData.ObjectPath, + policy: serverData.ValidationPolicy = "coercible", +): boolean | null { + if (serverData.isNull(data)) { + return null; + } + return serverData.asBoolean(data.attributes, attributePath, policy); +} + +/** + * Retrieve the specified attribute from the data as a boolean, which will be `false` if the attribute does not exist. + * + * @param data The data from which to retrieve the attribute. + * @param attributePath The path of the attribute. + * @returns {boolean} The attribute value as a boolean, coercing to `false` if the value is not present.. + */ +export function attributeAsBooleanOrFalse(data: media.Data, attributePath?: serverData.ObjectPath): boolean { + if (serverData.isNull(data)) { + return false; + } + return serverData.asBooleanOrFalse(data.attributes, attributePath); +} + +/** + * Retrieve the specified attribute from the data as a number. + * + * @param data The data from which to retrieve the attribute. + * @param attributePath The path of the attribute. + * @param policy The validation policy to use when resolving this value. + * @returns {boolean} The attribute value as a number. + */ +export function attributeAsNumber( + data: media.Data, + attributePath?: serverData.ObjectPath, + policy: serverData.ValidationPolicy = "coercible", +): Opt<number> { + if (serverData.isNull(data)) { + return null; + } + return serverData.asNumber(data.attributes, attributePath, policy); +} + +export function hasAttributes(data: media.Data): boolean { + return !serverData.isNull(serverData.asDictionary(data, "attributes")); +} + +/** + * The canonical way to detect if an item from Media API is hydrated or not. + * + * @param data The data from which to retrieve the attributes. + */ +export function isNotHydrated(data: media.Data): boolean { + return !hasAttributes(data); +} + +// region Custom Attributes + +/** + * Performs conversion for a custom variant of given attribute, if any are available. + * @param attribute Attribute to get custom attribute key for, if any. + */ +export function attributeKeyAsCustomAttributeKey(attribute: string): string | undefined { + return customAttributeMapping[attribute]; +} + +/** + * Whether or not given custom attributes key allows fallback to default page with AB testing treatment within a nondefault page. + * This is to allow AB testing to affect only icons within custom product pages. + */ +export function attributeAllowsNonDefaultTreatmentInNonDefaultPage(customAttribute: string): boolean { + return customAttribute === "customArtwork" || customAttribute === "customIconArtwork"; // Only the icon artwork. +} + +/** + * Defines mapping of attribute to custom attribute. + */ +const customAttributeMapping: { [key: string]: string } = { + artwork: "customArtwork", + iconArtwork: "customIconArtwork", + screenshotsByType: "customScreenshotsByType", + promotionalText: "customPromotionalText", + videoPreviewsByType: "customVideoPreviewsByType", + customScreenshotsByTypeForAd: "customScreenshotsByTypeForAd", + customVideoPreviewsByTypeForAd: "customVideoPreviewsByTypeForAd", +}; + +export function requiredAttributeAsString(data: media.Data, attributePath: serverData.ObjectPath): string { + const value = attributeAsString(data, attributePath); + if (isNothing(value)) { + throw new errors.MissingFieldError(data, concatObjectPaths("attributes", attributePath)); + } + return value; +} + +export function requiredAttributeAsDate(data: media.Data, attributePath: serverData.ObjectPath): Date { + const value = attributeAsDate(data, attributePath); + if (isNothing(value)) { + throw new errors.MissingFieldError(data, concatObjectPaths("attributes", attributePath)); + } + return value; +} + +export function requiredAttributeAsDictionary<Type extends JSONValue>( + data: media.Data, + attributePath: serverData.ObjectPath, +): MapLike<Type> { + const value: MapLike<Type> | null = attributeAsDictionary(data, attributePath); + if (isNothing(value)) { + throw new errors.MissingFieldError(data, concatObjectPaths("attributes", attributePath)); + } + return value; +} + +export function requiredMeta(data: media.Data): MapLike<JSONValue> { + const value = serverData.asDictionary(data, "meta"); + if (isNothing(value)) { + throw new errors.MissingFieldError(data, "meta"); + } + return value; +} + +export function requiredMetaAttributeAsString(data: media.Data, attributePath: serverData.ObjectPath): string { + const meta = requiredMeta(data); + const value = serverData.asString(meta, attributePath); + if (isNothing(value)) { + throw new errors.MissingFieldError(data, concatObjectPaths("meta", attributePath)); + } + return value; +} + +export function requiredMetaAttributeAsNumber(data: media.Data, attributePath: serverData.ObjectPath): number { + const meta = requiredMeta(data); + const value = serverData.asNumber(meta, attributePath); + if (isNothing(value)) { + throw new errors.MissingFieldError(data, concatObjectPaths("meta", attributePath)); + } + return value; +} + +export function concatObjectPaths(prefix: serverData.ObjectPath, suffix: serverData.ObjectPath): serverData.ObjectPath { + let finalPath: string[]; + if (Array.isArray(prefix)) { + finalPath = prefix; + } else { + finalPath = [prefix]; + } + + if (Array.isArray(suffix)) { + finalPath.push(...suffix); + } else { + finalPath.push(suffix); + } + return finalPath; +} + +// endregion diff --git a/node_modules/@apple-media-services/media-api/src/models/server-data.ts b/node_modules/@apple-media-services/media-api/src/models/server-data.ts new file mode 100644 index 0000000..a1aca3f --- /dev/null +++ b/node_modules/@apple-media-services/media-api/src/models/server-data.ts @@ -0,0 +1,476 @@ +// +// server-data.ts +// AppStoreKit +// +// Created by Kevin MacWhinnie on 8/17/16. +// Copyright (c) 2016 Apple Inc. All rights reserved. +// + +// TODO: Replace this utility for JSON Parsing +import * as validation from "@jet/environment/json/validation"; +import { Nothing, Opt, isNothing } from "@jet/environment/types/optional"; +import { JSONArray, JSONData, JSONValue, MapLike } from "./json-types"; + +// region Traversal + +/** + * Union type that describes the possible representations for an object traversal path. + */ +export type ObjectPath = string | string[]; + +/** + * Returns the string representation of a given object path. + * @param path The object path to coerce to a string. + * @returns A string representation of `path`. + */ +export function objectPathToString(path: Opt<ObjectPath>): Opt<string> { + if (isNull(path)) { + return null; + } else if (Array.isArray(path)) { + return path.join("."); + } else { + return path; + } +} + +const PARSED_PATH_CACHE: { [key: string]: string[] } = {}; + +/** + * Traverse a nested JSON object structure, short-circuiting + * when finding `undefined` or `null` values. Usage: + * + * const object = {x: {y: {z: 42}}}; + * const meaningOfLife = serverData.traverse(object, 'x.y.z'); + * + * @param object The JSON object to traverse. + * @param path The path to search. If falsy, `object` will be returned without being traversed. + * @param defaultValue The object to return if the path search fails. + * @return The value at `path` if found; default value otherwise. + */ +export function traverse(object: JSONValue, path?: ObjectPath, defaultValue?: JSONValue): JSONValue { + if (object === undefined || object === null) { + return defaultValue; + } + + if (isNullOrEmpty(path)) { + return object; + } + + let components: string[]; + if (typeof path === "string") { + components = PARSED_PATH_CACHE[path]; + if (isNullOrEmpty(components)) { + // Fast Path: If the path contains only a single component, we can skip + // all of the work below here and speed up storefronts that + // don't have JIT compilation enabled. + if (!path.includes(".")) { + const value = object[path]; + if (value !== undefined && value !== null) { + return value; + } else { + return defaultValue; + } + } + + components = path.split("."); + PARSED_PATH_CACHE[path] = components; + } + } else { + components = path; + } + + let current: JSONValue = object; + for (const component of components) { + current = current[component]; + if (current === undefined || current === null) { + return defaultValue; + } + } + return current; +} + +// endregion + +// region Nullability + +/** + * Returns a bool indicating whether or not a given object null or undefined. + * @param object The object to test. + * @return true if the object is null or undefined; false otherwise. + */ +export function isNull<Type>(object: Type | Nothing): object is Nothing { + return object === null || object === undefined; +} + +/** + * Returns a bool indicating whether or not a given object is null or empty. + * @param object The object to test + * @return true if object is null or empty; false otherwise. + */ +export function isNullOrEmpty<Type>(object: Type | Nothing): object is Nothing { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return isNull(object) || Object.keys(object as any).length === 0; +} + +/** + * Returns a bool indicating whether or not a given object is non-null. + * @param object The object to test. + * @return true if the object is not null or undefined; false otherwise. + */ +export function isDefinedNonNull<Type>(object: Type | null | undefined): object is Type { + return typeof object !== "undefined" && object !== null; +} + +/** + * Returns a bool indicating whether or not a given object is non-null or empty. + * @param object The object to test. + * @return true if the object is not null or undefined and not empty; false otherwise. + */ +export function isDefinedNonNullNonEmpty<Type>(object: Type | Nothing): object is Type { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return isDefinedNonNull(object) && Object.keys(object as any).length !== 0; +} + +/** + * Checks if the passed string or number is a number + * + * @param value The value to check + * @return True if the value is an number, false if not + */ +export function isNumber(value: number | string | null | undefined): value is number { + if (isNull(value)) { + return false; + } + + let valueToCheck; + if (typeof value === "string") { + valueToCheck = parseInt(value); + } else { + valueToCheck = value; + } + + return !Number.isNaN(valueToCheck); +} + +/** + * Returns a bool indicating whether or not a given object is defined but empty. + * @param object The object to test. + * @return true if the object is not null and empty; false otherwise. + */ +export function isArrayDefinedNonNullAndEmpty<Type extends JSONArray>(object: Type | null | undefined): object is Type { + return isDefinedNonNull(object) && object.length === 0; +} + +// endregion + +// region Defaulting Casts + +/** + * Check that a given object is an array, substituting an empty array if not. + * @param object The object to coerce. + * @param path The path to traverse on `object` to find an array. + * Omit this parameter if `object` is itself an array. + * @returns An untyped array. + */ +export function asArrayOrEmpty<T extends JSONValue>(object: JSONValue, path?: ObjectPath): T[] { + const target = traverse(object, path, null); + if (Array.isArray(target)) { + // Note: This is kind of a nasty cast, but I don't think we want to validate that everything is of type T + return target as T[]; + } else { + if (!isNull(target)) { + validation.context("asArrayOrEmpty", () => { + validation.unexpectedType("defaultValue", "array", target, objectPathToString(path)); + }); + } + return []; + } +} + +/** + * Check that a given object is a boolean, substituting the value `false` if not. + * @param object The object to coerce. + * @param path The path to traverse on `object` to find a boolean. + * Omit this parameter if `object` is itself a boolean. + * @returns A boolean from `object`, or defaults to `false`. + */ +export function asBooleanOrFalse(object: JSONValue, path?: ObjectPath): boolean { + const target = traverse(object, path, null); + if (typeof target === "boolean") { + return target; + } else { + if (!isNull(target)) { + validation.context("asBooleanOrFalse", () => { + validation.unexpectedType("defaultValue", "boolean", target, objectPathToString(path)); + }); + } + return false; + } +} + +// endregion + +// region Coercing Casts + +export type ValidationPolicy = "strict" | "coercible" | "none"; + +/** + * Safely coerce an object into a string. + * @param object The object to coerce. + * @param path The path to traverse on `object` to find a string. + * Omit this parameter if `object` is itself a string. + * @param policy The validation policy to use when resolving this value + * @returns A string from `object`, or `null` if `object` is null. + */ +export function asString(object: JSONValue, path?: ObjectPath, policy: ValidationPolicy = "coercible"): Opt<string> { + const target = traverse(object, path, null); + if (isNull(target)) { + return target; + } else if (typeof target === "string") { + return target; + } else { + // We don't consider arbitrary objects as convertable to strings even through they will result in some value + const coercedValue = typeof target === "object" ? null : String(target); + switch (policy) { + case "strict": { + validation.context("asString", () => { + validation.unexpectedType("coercedValue", "string", target, objectPathToString(path)); + }); + break; + } + case "coercible": { + if (isNull(coercedValue)) { + validation.context("asString", () => { + validation.unexpectedType("coercedValue", "string", target, objectPathToString(path)); + }); + } + break; + } + case "none": + default: { + break; + } + } + + return coercedValue; + } +} + +/** + * Safely coerce an object into a date. + * @param object The object to coerce. + * @param path The path to traverse on `object` to find a date. + * @param policy The validation policy to use when resolving this value + * @returns A date from `object`, or `null` if `object` is null. + */ +export function asDate(object: JSONValue, path?: ObjectPath, policy: ValidationPolicy = "coercible"): Opt<Date> { + const dateString = asString(object, path, policy); + if (isNothing(dateString)) { + return null; + } + return new Date(dateString); +} + +/** + * Safely coerce an object into a number. + * @param object The object to coerce. + * @param path The path to traverse on `object` to find a number. + * Omit this parameter if `object` is itself a number. + * @param policy The validation policy to use when resolving this value + * @returns A number from `object`, or `null` if `object` is null. + */ +export function asNumber(object: JSONValue, path?: ObjectPath, policy: ValidationPolicy = "coercible"): Opt<number> { + const target = traverse(object, path, null); + if (isNull(target) || typeof target === "number") { + return target; + } else { + const coercedValue = Number(target); + switch (policy) { + case "strict": { + validation.context("asNumber", () => { + validation.unexpectedType("coercedValue", "number", target, objectPathToString(path)); + }); + break; + } + case "coercible": { + if (isNaN(coercedValue)) { + validation.context("asNumber", () => { + validation.unexpectedType("coercedValue", "number", target, objectPathToString(path)); + }); + return null; + } + break; + } + case "none": + default: { + break; + } + } + + return coercedValue; + } +} + +/** + * Safely coerce an object into a dictionary. + * @param object The object to coerce. + * @param path The path to traverse on `object` to find the dictionary. + * Omit this parameter if `object` is itself a dictionary. + * @param defaultValue The object to return if the path search fails. + * @returns A sub-dictionary from `object`, or `null` if `object` is null. + */ +export function asDictionary<Type extends JSONValue>( + object: JSONValue, + path?: ObjectPath, + defaultValue?: MapLike<Type>, +): MapLike<Type> | null { + const target = traverse(object, path, null); + if (target instanceof Object && !Array.isArray(target)) { + // Note: It's too expensive to actually validate this is a dictionary of { string : Type } at run time + return target as MapLike<Type>; + } else { + if (!isNull(target)) { + validation.context("asDictionary", () => { + validation.unexpectedType("defaultValue", "object", target, objectPathToString(path)); + }); + } + + if (isDefinedNonNull(defaultValue)) { + return defaultValue; + } + return null; + } +} + +/** + * Safely coerce an object into a given interface. + * @param object The object to coerce. + * @param path The path to traverse on `object` to find a string. + * Omit this parameter if `object` is itself a string. + * @param defaultValue The object to return if the path search fails. + * @returns A sub-dictionary from `object`, or `null` if `object` is null. + */ +export function asInterface<Interface>( + object: JSONValue, + path?: ObjectPath, + defaultValue?: JSONData, +): Interface | null { + return asDictionary(object, path, defaultValue) as unknown as Interface; +} + +/** + * Coerce an object into a boolean. + * @param object The object to coerce. + * @param path The path to traverse on `object` to find a boolean. + * Omit this parameter if `object` is itself a boolean. + * @param policy The validation policy to use when resolving this value + * @returns A boolean from `object`, or `null` if `object` is null. + * @note This is distinct from `asBooleanOrFalse` in that it doesn't default to false, + * and it tries to convert string boolean values into actual boolean types + */ +export function asBoolean( + object: JSONValue, + path?: ObjectPath, + policy: ValidationPolicy = "coercible", +): boolean | null { + const target = traverse(object, path, null); + + // Value was null + if (isNull(target)) { + return null; + } + + // Value was boolean. + if (typeof target === "boolean") { + return target; + } + + // Value was string. + if (typeof target === "string") { + if (target === "true") { + return true; + } else if (target === "false") { + return false; + } + } + + // Else coerce. + const coercedValue = Boolean(target); + switch (policy) { + case "strict": { + validation.context("asBoolean", () => { + validation.unexpectedType("coercedValue", "number", target, objectPathToString(path)); + }); + break; + } + case "coercible": { + if (isNull(coercedValue)) { + validation.context("asBoolean", () => { + validation.unexpectedType("coercedValue", "number", target, objectPathToString(path)); + }); + return null; + } + break; + } + case "none": + default: { + break; + } + } + + return coercedValue; +} + +/** + * Attempts to coerce the passed value to a JSONValue + * + * Note: due to performance concerns this does not perform a deep inspection of Objects or Arrays. + * + * @param value The value to coerce + * @return A JSONValue or null if value is not a valid JSONValue type + */ +export function asJSONValue(value: unknown): JSONValue | null { + if (value === null || value === undefined) { + return null; + } + switch (typeof value) { + case "string": + case "number": + case "boolean": + return value as JSONValue; + case "object": + // Note: It's too expensive to actually validate this is an array of JSONValues at run time + if (Array.isArray(value)) { + return value as JSONValue; + } + // Note: It's too expensive to actually validate this is a dictionary of { string : JSONValue } at run time + return value as JSONValue; + default: + validation.context("asJSONValue", () => { + validation.unexpectedType("defaultValue", "JSONValue", typeof value); + }); + return null; + } +} + +/** + * Attempts to coerce the passed value to JSONData + * + * @param value The value to coerce + * @return A JSONData or null if the value is not a valid JSONData object + */ +export function asJSONData(value: unknown): JSONData | null { + if (value === null || value === undefined) { + return null; + } + if (value instanceof Object && !Array.isArray(value)) { + // Note: It's too expensive to actually validate this is a dictionary of { string : Type } at run time + return value as JSONData; + } + validation.context("asJSONValue", () => { + validation.unexpectedType("defaultValue", "object", typeof value); + }); + return null; +} + +// endregion diff --git a/node_modules/@apple-media-services/media-api/src/models/urls.ts b/node_modules/@apple-media-services/media-api/src/models/urls.ts new file mode 100644 index 0000000..c1c09f1 --- /dev/null +++ b/node_modules/@apple-media-services/media-api/src/models/urls.ts @@ -0,0 +1,469 @@ +/** + * Created by keithpk on 12/2/16. + */ + +import { isNothing, Nothing, Opt } from "@jet/environment/types/optional"; +import { isDefinedNonNullNonEmpty, isNullOrEmpty } from "./server-data"; + +export type Query = { + [key: string]: string | undefined; +}; + +export type URLComponent = "protocol" | "username" | "password" | "host" | "port" | "pathname" | "query" | "hash"; + +const protocolRegex = /^([a-z][a-z0-9.+-]*:)(\/\/)?([\S\s]*)/i; +const queryParamRegex = /([^=?&]+)=?([^&]*)/g; +const componentOrder: URLComponent[] = ["hash", "query", "pathname", "host"]; + +type URLSplitStyle = "prefix" | "suffix"; + +type URLSplitResult = { + result?: string; + remainder: string; +}; + +function splitUrlComponent(input: string, marker: string, style: URLSplitStyle): URLSplitResult { + const index = input.indexOf(marker); + let result; + let remainder = input; + if (index !== -1) { + const prefix = input.slice(0, index); + const suffix = input.slice(index + marker.length, input.length); + + if (style === "prefix") { + result = prefix; + remainder = suffix; + } else { + result = suffix; + remainder = prefix; + } + } + + // log("Token: " + marker + " String: " + input, " Result: " + result + " Remainder: " + remainder) + + return { + result: result, + remainder: remainder, + }; +} + +export class URL { + protocol?: Opt<string>; + username: string; + password: string; + host?: Opt<string>; + port: string; + pathname?: Opt<string>; + query?: Query = {}; + hash?: string; + + constructor(url?: string) { + if (isNullOrEmpty(url)) { + return; + } + + // Split the protocol from the rest of the urls + let remainder = url; + const match = protocolRegex.exec(url); + if (match != null) { + // Pull out the protocol + let protocol = match[1]; + if (protocol) { + protocol = protocol.split(":")[0]; + } + + this.protocol = protocol; + + // Save the remainder + remainder = match[3]; + } + + // Then match each component in a specific order + let parse: URLSplitResult = { remainder: remainder, result: undefined }; + for (const component of componentOrder) { + if (!parse.remainder) { + break; + } + + switch (component) { + case "hash": { + parse = splitUrlComponent(parse.remainder, "#", "suffix"); + this.hash = parse.result; + break; + } + case "query": { + parse = splitUrlComponent(parse.remainder, "?", "suffix"); + if (isDefinedNonNullNonEmpty(parse.result)) { + this.query = URL.queryFromString(parse.result); + } + break; + } + case "pathname": { + parse = splitUrlComponent(parse.remainder, "/", "suffix"); + + if (isDefinedNonNullNonEmpty(parse.result)) { + // Replace the initial /, since paths require it + this.pathname = "/" + parse.result; + } + break; + } + case "host": { + if (parse.remainder) { + const authorityParse = splitUrlComponent(parse.remainder, "@", "prefix"); + const userInfo = authorityParse.result; + const hostPort = authorityParse.remainder; + if (isDefinedNonNullNonEmpty(userInfo)) { + const userInfoSplit = userInfo.split(":"); + this.username = decodeURIComponent(userInfoSplit[0]); + this.password = decodeURIComponent(userInfoSplit[1]); + } + + if (hostPort) { + const hostPortSplit = hostPort.split(":"); + this.host = hostPortSplit[0]; + this.port = hostPortSplit[1]; + } + } + break; + } + default: { + throw new Error("Unhandled case!"); + } + } + } + } + + set(component: URLComponent, value: string | Query): URL { + if (isNullOrEmpty(value)) { + return this; + } + + if (component === "query") { + if (typeof value === "string") { + value = URL.queryFromString(value); + } + } + + switch (component) { + // Exhaustive match to make sure TS property minifiers and other + // transformer plugins do not break this code. + case "protocol": + this.protocol = value as string; + break; + case "username": + this.username = value as string; + break; + case "password": + this.password = value as string; + break; + case "port": + this.port = value as string; + break; + case "pathname": + this.pathname = value as string; + break; + case "query": + this.query = value as Query; + break; + case "hash": + this.hash = value as string; + break; + default: + // The fallback for component which is not a property of URL object. + this[component] = value as string; + break; + } + return this; + } + + private get(component: URLComponent): string | Query | Nothing { + switch (component) { + // Exhaustive match to make sure TS property minifiers and other + // transformer plugins do not break this code. + case "protocol": + return this.protocol; + case "username": + return this.username; + case "password": + return this.password; + case "port": + return this.port; + case "pathname": + return this.pathname; + case "query": + return this.query; + case "hash": + return this.hash; + default: + // The fallback for component which is not a property of URL object. + return this[component]; + } + } + + append(component: URLComponent, value: string | Query): URL { + const existingValue = this.get(component); + let newValue; + + if (component === "query") { + if (typeof value === "string") { + value = URL.queryFromString(value); + } + + if (typeof existingValue === "string") { + newValue = { existingValue, ...value }; + } else { + newValue = { ...existingValue, ...value }; + } + } else { + let existingValueString = existingValue as string; + + if (!existingValueString) { + existingValueString = ""; + } + + let newValueString = existingValueString; + + if (component === "pathname") { + const pathLength = existingValueString.length; + if (!pathLength || existingValueString[pathLength - 1] !== "/") { + newValueString += "/"; + } + } + + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-base-to-string + newValueString += value; + newValue = newValueString; + } + + return this.set(component, newValue); + } + + param(key: string, value?: string): URL { + if (!key) { + return this; + } + if (this.query == null) { + this.query = {}; + } + this.query[key] = value; + return this; + } + + removeParam(key: string): URL { + if (!key || this.query == null) { + return this; + } + if (this.query[key] !== undefined) { + delete this.query[key]; + } + return this; + } + + /** + * Push a new string value onto the path for this url + * @returns URL this object with the updated path. + */ + path(value: string): URL { + return this.append("pathname", value); + } + + pathExtension(): Opt<string> { + // Extract path extension if one exists + if (isNothing(this.pathname)) { + return null; + } + + const lastFilenameComponents = this.pathname + .split("/") + .filter((item) => item.length > 0) // Remove any double or trailing slashes + .pop() + ?.split("."); + if (lastFilenameComponents === undefined) { + return null; + } + if ( + lastFilenameComponents.filter((part) => { + return part !== ""; + }).length < 2 // Remove any empty parts (e.g. .ssh_config -> ["ssh_config"]) + ) { + return null; + } + + return lastFilenameComponents.pop(); + } + + /** + * Returns the path components of the URL + * @returns An array of non-empty path components from `urls`. + */ + pathComponents(): string[] { + if (isNullOrEmpty(this.pathname)) { + return []; + } + + return this.pathname.split("/").filter((component) => component.length > 0); + } + + /** + * Returns the last path component from this url, updating the url to not include this path component + * @returns String the last path component from this url. + */ + popPathComponent(): string | null { + if (isNullOrEmpty(this.pathname)) { + return null; + } + + const lastPathComponent = this.pathname.slice(this.pathname.lastIndexOf("/") + 1); + + if (lastPathComponent.length === 0) { + return null; + } + + this.pathname = this.pathname.slice(0, this.pathname.lastIndexOf("/")); + + return lastPathComponent; + } + + /** + * Same as toString + * + * @returns {string} A string representation of the URL + */ + build(): string { + return this.toString(); + } + + /** + * Converts the URL to a string + * + * @returns {string} A string representation of the URL + */ + toString(): string { + let url = ""; + + if (isDefinedNonNullNonEmpty(this.protocol)) { + url += this.protocol + "://"; + } + + if (this.username) { + url += encodeURIComponent(this.username); + + if (this.password) { + url += ":" + encodeURIComponent(this.password); + } + + url += "@"; + } + + if (isDefinedNonNullNonEmpty(this.host)) { + url += this.host; + + if (this.port) { + url += ":" + this.port; + } + } + + if (isDefinedNonNullNonEmpty(this.pathname)) { + url += this.pathname; + /// Trim off trailing path separators when we have a valid path + /// We don't do this unless pathname has elements otherwise we will trim the `://` + if (url.endsWith("/") && this.pathname.length > 0) { + url = url.slice(0, -1); + } + } + + if (this.query != null && Object.keys(this.query).length > 0) { + url += "?" + URL.toQueryString(this.query); + } + + if (isDefinedNonNullNonEmpty(this.hash)) { + url += "#" + this.hash; + } + + return url; + } + + // ---------------- + // Static API + // ---------------- + + /** + * Converts a string into a query dictionary + * @param query The string to parse + * @returns The query dictionary containing the key-value pairs in the query string + */ + static queryFromString(query: string): Query { + const result = {}; + + let parseResult = queryParamRegex.exec(query); + while (parseResult != null) { + const key = decodeURIComponent(parseResult[1]); + const value = decodeURIComponent(parseResult[2]); + result[key] = value; + parseResult = queryParamRegex.exec(query); + } + + return result; + } + + /** + * Converts a query dictionary into a query string + * + * @param query The query dictionary + * @returns {string} The string representation of the query dictionary + */ + static toQueryString(query: Query) { + let queryString = ""; + + let first = true; + for (const key of Object.keys(query)) { + if (!first) { + queryString += "&"; + } + first = false; + + queryString += encodeURIComponent(key); + + const value = query[key]; + if (isDefinedNonNullNonEmpty(value) && value.length) { + queryString += "=" + encodeURIComponent(value); + } + } + + return queryString; + } + + /** + * Convenience method to instantiate a URL from a string + * @param url The URL string to parse + * @returns {URL} The new URL object representing the URL + */ + static from(url: string): URL { + return new URL(url); + } + + /** + * Convenience method to instantiate a URL from numerous (optional) components + * @param protocol The protocol type + * @param host The host name + * @param path The path + * @param query The query + * @param hash The hash + * @returns {URL} The new URL object representing the URL + */ + static fromComponents( + protocol?: Opt<string>, + host?: Opt<string>, + path?: Opt<string>, + query?: Query, + hash?: string, + ): URL { + const url = new URL(); + url.protocol = protocol; + url.host = host; + url.pathname = path; + url.query = query; + url.hash = hash; + return url; + } +} 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]); +} |
