// // 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): Opt { 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(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(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(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(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(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(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 { 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 { 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 { 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( object: JSONValue, path?: ObjectPath, defaultValue?: MapLike, ): MapLike | 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; } 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( 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