477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
//
|
|
// 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
|