server-data.ts
  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
 10  import * as validation from "@jet/environment/json/validation";
 11  import { Nothing, Opt, isNothing } from "@jet/environment/types/optional";
 12  import { 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   */
 19  export 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   */
 26  export 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  
 36  const 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   */
 50  export 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   */
101  export 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   */
110  export 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   */
120  export 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   */
129  export 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   */
140  export 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   */
160  export 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   */
175  export 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   */
197  export 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  
215  export 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   */
225  export 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   */
266  export 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   */
282  export 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   */
322  export 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   */
353  export 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   */
371  export 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   */
432  export 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   */
462  export 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