this repo has no description
at main 476 lines 16 kB view raw
1// 2// server-data.ts 3// AppStoreKit 4// 5// Created by Kevin MacWhinnie on 8/17/16. 6// Copyright (c) 2016 Apple Inc. All rights reserved. 7// 8 9// TODO: Replace this utility for JSON Parsing 10import * as validation from "@jet/environment/json/validation"; 11import { Nothing, Opt, isNothing } from "@jet/environment/types/optional"; 12import { JSONArray, JSONData, JSONValue, MapLike } from "./json-types"; 13 14// region Traversal 15 16/** 17 * Union type that describes the possible representations for an object traversal path. 18 */ 19export type ObjectPath = string | string[]; 20 21/** 22 * Returns the string representation of a given object path. 23 * @param path The object path to coerce to a string. 24 * @returns A string representation of `path`. 25 */ 26export function objectPathToString(path: Opt<ObjectPath>): Opt<string> { 27 if (isNull(path)) { 28 return null; 29 } else if (Array.isArray(path)) { 30 return path.join("."); 31 } else { 32 return path; 33 } 34} 35 36const PARSED_PATH_CACHE: { [key: string]: string[] } = {}; 37 38/** 39 * Traverse a nested JSON object structure, short-circuiting 40 * when finding `undefined` or `null` values. Usage: 41 * 42 * const object = {x: {y: {z: 42}}}; 43 * const meaningOfLife = serverData.traverse(object, 'x.y.z'); 44 * 45 * @param object The JSON object to traverse. 46 * @param path The path to search. If falsy, `object` will be returned without being traversed. 47 * @param defaultValue The object to return if the path search fails. 48 * @return The value at `path` if found; default value otherwise. 49 */ 50export function traverse(object: JSONValue, path?: ObjectPath, defaultValue?: JSONValue): JSONValue { 51 if (object === undefined || object === null) { 52 return defaultValue; 53 } 54 55 if (isNullOrEmpty(path)) { 56 return object; 57 } 58 59 let components: string[]; 60 if (typeof path === "string") { 61 components = PARSED_PATH_CACHE[path]; 62 if (isNullOrEmpty(components)) { 63 // Fast Path: If the path contains only a single component, we can skip 64 // all of the work below here and speed up storefronts that 65 // don't have JIT compilation enabled. 66 if (!path.includes(".")) { 67 const value = object[path]; 68 if (value !== undefined && value !== null) { 69 return value; 70 } else { 71 return defaultValue; 72 } 73 } 74 75 components = path.split("."); 76 PARSED_PATH_CACHE[path] = components; 77 } 78 } else { 79 components = path; 80 } 81 82 let current: JSONValue = object; 83 for (const component of components) { 84 current = current[component]; 85 if (current === undefined || current === null) { 86 return defaultValue; 87 } 88 } 89 return current; 90} 91 92// endregion 93 94// region Nullability 95 96/** 97 * Returns a bool indicating whether or not a given object null or undefined. 98 * @param object The object to test. 99 * @return true if the object is null or undefined; false otherwise. 100 */ 101export function isNull<Type>(object: Type | Nothing): object is Nothing { 102 return object === null || object === undefined; 103} 104 105/** 106 * Returns a bool indicating whether or not a given object is null or empty. 107 * @param object The object to test 108 * @return true if object is null or empty; false otherwise. 109 */ 110export function isNullOrEmpty<Type>(object: Type | Nothing): object is Nothing { 111 // eslint-disable-next-line @typescript-eslint/no-explicit-any 112 return isNull(object) || Object.keys(object as any).length === 0; 113} 114 115/** 116 * Returns a bool indicating whether or not a given object is non-null. 117 * @param object The object to test. 118 * @return true if the object is not null or undefined; false otherwise. 119 */ 120export function isDefinedNonNull<Type>(object: Type | null | undefined): object is Type { 121 return typeof object !== "undefined" && object !== null; 122} 123 124/** 125 * Returns a bool indicating whether or not a given object is non-null or empty. 126 * @param object The object to test. 127 * @return true if the object is not null or undefined and not empty; false otherwise. 128 */ 129export function isDefinedNonNullNonEmpty<Type>(object: Type | Nothing): object is Type { 130 // eslint-disable-next-line @typescript-eslint/no-explicit-any 131 return isDefinedNonNull(object) && Object.keys(object as any).length !== 0; 132} 133 134/** 135 * Checks if the passed string or number is a number 136 * 137 * @param value The value to check 138 * @return True if the value is an number, false if not 139 */ 140export function isNumber(value: number | string | null | undefined): value is number { 141 if (isNull(value)) { 142 return false; 143 } 144 145 let valueToCheck; 146 if (typeof value === "string") { 147 valueToCheck = parseInt(value); 148 } else { 149 valueToCheck = value; 150 } 151 152 return !Number.isNaN(valueToCheck); 153} 154 155/** 156 * Returns a bool indicating whether or not a given object is defined but empty. 157 * @param object The object to test. 158 * @return true if the object is not null and empty; false otherwise. 159 */ 160export function isArrayDefinedNonNullAndEmpty<Type extends JSONArray>(object: Type | null | undefined): object is Type { 161 return isDefinedNonNull(object) && object.length === 0; 162} 163 164// endregion 165 166// region Defaulting Casts 167 168/** 169 * Check that a given object is an array, substituting an empty array if not. 170 * @param object The object to coerce. 171 * @param path The path to traverse on `object` to find an array. 172 * Omit this parameter if `object` is itself an array. 173 * @returns An untyped array. 174 */ 175export function asArrayOrEmpty<T extends JSONValue>(object: JSONValue, path?: ObjectPath): T[] { 176 const target = traverse(object, path, null); 177 if (Array.isArray(target)) { 178 // Note: This is kind of a nasty cast, but I don't think we want to validate that everything is of type T 179 return target as T[]; 180 } else { 181 if (!isNull(target)) { 182 validation.context("asArrayOrEmpty", () => { 183 validation.unexpectedType("defaultValue", "array", target, objectPathToString(path)); 184 }); 185 } 186 return []; 187 } 188} 189 190/** 191 * Check that a given object is a boolean, substituting the value `false` if not. 192 * @param object The object to coerce. 193 * @param path The path to traverse on `object` to find a boolean. 194 * Omit this parameter if `object` is itself a boolean. 195 * @returns A boolean from `object`, or defaults to `false`. 196 */ 197export function asBooleanOrFalse(object: JSONValue, path?: ObjectPath): boolean { 198 const target = traverse(object, path, null); 199 if (typeof target === "boolean") { 200 return target; 201 } else { 202 if (!isNull(target)) { 203 validation.context("asBooleanOrFalse", () => { 204 validation.unexpectedType("defaultValue", "boolean", target, objectPathToString(path)); 205 }); 206 } 207 return false; 208 } 209} 210 211// endregion 212 213// region Coercing Casts 214 215export type ValidationPolicy = "strict" | "coercible" | "none"; 216 217/** 218 * Safely coerce an object into a string. 219 * @param object The object to coerce. 220 * @param path The path to traverse on `object` to find a string. 221 * Omit this parameter if `object` is itself a string. 222 * @param policy The validation policy to use when resolving this value 223 * @returns A string from `object`, or `null` if `object` is null. 224 */ 225export function asString(object: JSONValue, path?: ObjectPath, policy: ValidationPolicy = "coercible"): Opt<string> { 226 const target = traverse(object, path, null); 227 if (isNull(target)) { 228 return target; 229 } else if (typeof target === "string") { 230 return target; 231 } else { 232 // We don't consider arbitrary objects as convertable to strings even through they will result in some value 233 const coercedValue = typeof target === "object" ? null : String(target); 234 switch (policy) { 235 case "strict": { 236 validation.context("asString", () => { 237 validation.unexpectedType("coercedValue", "string", target, objectPathToString(path)); 238 }); 239 break; 240 } 241 case "coercible": { 242 if (isNull(coercedValue)) { 243 validation.context("asString", () => { 244 validation.unexpectedType("coercedValue", "string", target, objectPathToString(path)); 245 }); 246 } 247 break; 248 } 249 case "none": 250 default: { 251 break; 252 } 253 } 254 255 return coercedValue; 256 } 257} 258 259/** 260 * Safely coerce an object into a date. 261 * @param object The object to coerce. 262 * @param path The path to traverse on `object` to find a date. 263 * @param policy The validation policy to use when resolving this value 264 * @returns A date from `object`, or `null` if `object` is null. 265 */ 266export function asDate(object: JSONValue, path?: ObjectPath, policy: ValidationPolicy = "coercible"): Opt<Date> { 267 const dateString = asString(object, path, policy); 268 if (isNothing(dateString)) { 269 return null; 270 } 271 return new Date(dateString); 272} 273 274/** 275 * Safely coerce an object into a number. 276 * @param object The object to coerce. 277 * @param path The path to traverse on `object` to find a number. 278 * Omit this parameter if `object` is itself a number. 279 * @param policy The validation policy to use when resolving this value 280 * @returns A number from `object`, or `null` if `object` is null. 281 */ 282export function asNumber(object: JSONValue, path?: ObjectPath, policy: ValidationPolicy = "coercible"): Opt<number> { 283 const target = traverse(object, path, null); 284 if (isNull(target) || typeof target === "number") { 285 return target; 286 } else { 287 const coercedValue = Number(target); 288 switch (policy) { 289 case "strict": { 290 validation.context("asNumber", () => { 291 validation.unexpectedType("coercedValue", "number", target, objectPathToString(path)); 292 }); 293 break; 294 } 295 case "coercible": { 296 if (isNaN(coercedValue)) { 297 validation.context("asNumber", () => { 298 validation.unexpectedType("coercedValue", "number", target, objectPathToString(path)); 299 }); 300 return null; 301 } 302 break; 303 } 304 case "none": 305 default: { 306 break; 307 } 308 } 309 310 return coercedValue; 311 } 312} 313 314/** 315 * Safely coerce an object into a dictionary. 316 * @param object The object to coerce. 317 * @param path The path to traverse on `object` to find the dictionary. 318 * Omit this parameter if `object` is itself a dictionary. 319 * @param defaultValue The object to return if the path search fails. 320 * @returns A sub-dictionary from `object`, or `null` if `object` is null. 321 */ 322export function asDictionary<Type extends JSONValue>( 323 object: JSONValue, 324 path?: ObjectPath, 325 defaultValue?: MapLike<Type>, 326): MapLike<Type> | null { 327 const target = traverse(object, path, null); 328 if (target instanceof Object && !Array.isArray(target)) { 329 // Note: It's too expensive to actually validate this is a dictionary of { string : Type } at run time 330 return target as MapLike<Type>; 331 } else { 332 if (!isNull(target)) { 333 validation.context("asDictionary", () => { 334 validation.unexpectedType("defaultValue", "object", target, objectPathToString(path)); 335 }); 336 } 337 338 if (isDefinedNonNull(defaultValue)) { 339 return defaultValue; 340 } 341 return null; 342 } 343} 344 345/** 346 * Safely coerce an object into a given interface. 347 * @param object The object to coerce. 348 * @param path The path to traverse on `object` to find a string. 349 * Omit this parameter if `object` is itself a string. 350 * @param defaultValue The object to return if the path search fails. 351 * @returns A sub-dictionary from `object`, or `null` if `object` is null. 352 */ 353export function asInterface<Interface>( 354 object: JSONValue, 355 path?: ObjectPath, 356 defaultValue?: JSONData, 357): Interface | null { 358 return asDictionary(object, path, defaultValue) as unknown as Interface; 359} 360 361/** 362 * Coerce an object into a boolean. 363 * @param object The object to coerce. 364 * @param path The path to traverse on `object` to find a boolean. 365 * Omit this parameter if `object` is itself a boolean. 366 * @param policy The validation policy to use when resolving this value 367 * @returns A boolean from `object`, or `null` if `object` is null. 368 * @note This is distinct from `asBooleanOrFalse` in that it doesn't default to false, 369 * and it tries to convert string boolean values into actual boolean types 370 */ 371export function asBoolean( 372 object: JSONValue, 373 path?: ObjectPath, 374 policy: ValidationPolicy = "coercible", 375): boolean | null { 376 const target = traverse(object, path, null); 377 378 // Value was null 379 if (isNull(target)) { 380 return null; 381 } 382 383 // Value was boolean. 384 if (typeof target === "boolean") { 385 return target; 386 } 387 388 // Value was string. 389 if (typeof target === "string") { 390 if (target === "true") { 391 return true; 392 } else if (target === "false") { 393 return false; 394 } 395 } 396 397 // Else coerce. 398 const coercedValue = Boolean(target); 399 switch (policy) { 400 case "strict": { 401 validation.context("asBoolean", () => { 402 validation.unexpectedType("coercedValue", "number", target, objectPathToString(path)); 403 }); 404 break; 405 } 406 case "coercible": { 407 if (isNull(coercedValue)) { 408 validation.context("asBoolean", () => { 409 validation.unexpectedType("coercedValue", "number", target, objectPathToString(path)); 410 }); 411 return null; 412 } 413 break; 414 } 415 case "none": 416 default: { 417 break; 418 } 419 } 420 421 return coercedValue; 422} 423 424/** 425 * Attempts to coerce the passed value to a JSONValue 426 * 427 * Note: due to performance concerns this does not perform a deep inspection of Objects or Arrays. 428 * 429 * @param value The value to coerce 430 * @return A JSONValue or null if value is not a valid JSONValue type 431 */ 432export function asJSONValue(value: unknown): JSONValue | null { 433 if (value === null || value === undefined) { 434 return null; 435 } 436 switch (typeof value) { 437 case "string": 438 case "number": 439 case "boolean": 440 return value as JSONValue; 441 case "object": 442 // Note: It's too expensive to actually validate this is an array of JSONValues at run time 443 if (Array.isArray(value)) { 444 return value as JSONValue; 445 } 446 // Note: It's too expensive to actually validate this is a dictionary of { string : JSONValue } at run time 447 return value as JSONValue; 448 default: 449 validation.context("asJSONValue", () => { 450 validation.unexpectedType("defaultValue", "JSONValue", typeof value); 451 }); 452 return null; 453 } 454} 455 456/** 457 * Attempts to coerce the passed value to JSONData 458 * 459 * @param value The value to coerce 460 * @return A JSONData or null if the value is not a valid JSONData object 461 */ 462export function asJSONData(value: unknown): JSONData | null { 463 if (value === null || value === undefined) { 464 return null; 465 } 466 if (value instanceof Object && !Array.isArray(value)) { 467 // Note: It's too expensive to actually validate this is a dictionary of { string : Type } at run time 468 return value as JSONData; 469 } 470 validation.context("asJSONValue", () => { 471 validation.unexpectedType("defaultValue", "object", typeof value); 472 }); 473 return null; 474} 475 476// endregion