mirror of
https://github.com/rxliuli/apps.apple.com.git
synced 2025-11-09 20:50:33 +00:00
init commit
This commit is contained in:
289
node_modules/@apple-media-services/media-api/src/models/attributes.ts
generated
vendored
Normal file
289
node_modules/@apple-media-services/media-api/src/models/attributes.ts
generated
vendored
Normal file
@@ -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
|
||||
476
node_modules/@apple-media-services/media-api/src/models/server-data.ts
generated
vendored
Normal file
476
node_modules/@apple-media-services/media-api/src/models/server-data.ts
generated
vendored
Normal file
@@ -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
|
||||
469
node_modules/@apple-media-services/media-api/src/models/urls.ts
generated
vendored
Normal file
469
node_modules/@apple-media-services/media-api/src/models/urls.ts
generated
vendored
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
403
node_modules/@apple-media-services/media-api/src/network.ts
generated
vendored
Normal file
403
node_modules/@apple-media-services/media-api/src/network.ts
generated
vendored
Normal file
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user