this repo has no description
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