summaryrefslogtreecommitdiff
path: root/node_modules/@jet-app/app-store/tmp/src/common/builders/routing.js
blob: 1655d6dc458a2b43cd20821d64975bda987f38a0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
/**
 * Created by km on 11/17/16.
 */
/* This file DOES NOT target nodejs usage. */
import { isNothing, isSome } from "@jet/environment";
import { makeMetatype } from "@jet/environment/util/metatype";
import * as models from "../../api/models";
import * as serverData from "../../foundation/json-parsing/server-data";
import { Parameters } from "../../foundation/network/url-constants";
import * as routingComponents from "../../foundation/routing/routing-components";
import * as objects from "../../foundation/util/objects";
import { allOptional, tryAwait } from "../../foundation/util/promise-util";
import * as productPageCommon from "../product-page/product-page-common";
// region Routers
export const pageRouter = makeMetatype("app-store:page-router");
export class PageRouter {
    constructor() {
        this.registeredBuilders = new Set();
        this.pageRouter = new routingComponents.UrlRouter();
        this.shelfRouter = new routingComponents.UrlRouter();
        this.paginationRouter = new routingComponents.UrlRouter();
        // endregion
    }
    /**
     * Register a page builder containing routes and related handlers.
     * @param objectGraph
     * @param {Builder} pageBuilder to register.
     *
     * @note Although one router can serve the page, shelf, and pagination routes, we maintain
     * three instances dedicated to each instead to avoid iterating through each other's routes
     * during URL matching.
     */
    registerPageBuilder(objectGraph, pageBuilder) {
        if (this.registeredBuilders.has(pageBuilder.builderClass)) {
            throw new Error(`routing: Registering duplicate builderClass ${pageBuilder.builderClass}`);
        }
        this.registeredBuilders.add(pageBuilder.builderClass);
        this.pageRouter.associate(pageBuilder.pageRoute(objectGraph), pageBuilder);
        this.shelfRouter.associate(pageBuilder.shelfRoute(objectGraph), pageBuilder);
        this.paginationRouter.associate(pageBuilder.paginationRoute(objectGraph), pageBuilder);
    }
    /**
     * Register a shelf builder containing routes and related handlers.
     * @param objectGraph
     * @param {Builder} shelfBuilder to register.
     *
     * @note Although one router can serve the page, shelf, and pagination routes, we maintain
     * three instances dedicated to each instead to avoid iterating through each other's routes
     * during URL matching.
     */
    registerShelfBuilder(objectGraph, shelfBuilder) {
        if (this.registeredBuilders.has(shelfBuilder.builderClass)) {
            throw new Error(`routing: Registering duplicate builderClass ${shelfBuilder.builderClass}`);
        }
        this.registeredBuilders.add(shelfBuilder.builderClass);
        this.shelfRouter.associate(shelfBuilder.shelfRoute(objectGraph), shelfBuilder);
    }
    /**
     * Register a pagination builder containing routes and related handlers.
     * @param objectGraph
     * @param {Builder} paginationBuilder to register.
     *
     * @note Although one router can serve the page, shelf, and pagination routes, we maintain
     * three instances dedicated to each instead to avoid iterating through each other's routes
     * during URL matching.
     */
    registerPaginationBuilder(objectGraph, paginationBuilder) {
        if (this.registeredBuilders.has(paginationBuilder.builderClass)) {
            throw new Error(`routing: Registering duplicate builderClass ${paginationBuilder.builderClass}`);
        }
        this.registeredBuilders.add(paginationBuilder.builderClass);
        this.paginationRouter.associate(paginationBuilder.paginationRoute(objectGraph), paginationBuilder);
    }
    // endregion
    // region Exported API
    /**
     * Determine the type of destination for given `url`.
     * @param {string} url The url to fetch `flowPage` for.
     * @returns {FlowPage} The FlowPage for given `url`
     */
    fetchFlowPage(url) {
        const routerResult = this.pageRouter.routedObjectForUrl(url);
        if (!routerResult.object) {
            return `unknown`;
        }
        const builder = routerResult.object;
        // Product URLs can go to `writeReview` via deep links in iOS, so the page type may not necessary be `product`
        // So check that they are not routed to the reviews sections
        if (builder.builderClass === "ProductBuilder" &&
            routerResult.parameters[Parameters.action] !== productPageCommon.reviewsAction &&
            routerResult.parameters[Parameters.action] !== productPageCommon.writeReviewAction) {
            return "product";
        }
        return builder.pageType();
    }
    /**
     * Fetch the contents of a page.
     * @param objectGraph
     * @param url The URL to determine what kind of page to load.
     * @param pageType The meta type of the expected page, e.g. `models.ProductPage`.
     * This is used to perform a runtime type check to prevent hard to track bugs.
     * @returns A promise which will resolve into a page of `pageType`.
     */
    async fetchPage(objectGraph, url, pageType) {
        return await this.fetchAction(objectGraph, url, null, false).then(async (pageAction) => {
            return await new Promise((resolve, reject) => {
                if (!pageAction) {
                    throw new Error(`Promise resolved to null action for: ${url}`);
                }
                if (pageAction.actionClass === "FlowAction") {
                    const pageData = pageAction.pageData;
                    if (!objects.isTypeOf(pageData, pageType)) {
                        // As we have no data of the correct type, check if we have a redirect
                        const pageUrl = pageAction.pageUrl;
                        const isRedirectingToSelf = pageUrl === url;
                        if (pageUrl && !isRedirectingToSelf) {
                            // Re-process this fetch with the new URL.
                            this.fetchPage(objectGraph, pageUrl, pageType)
                                .then((page) => {
                                resolve(page);
                            })
                                .catch((error) => {
                                reject(error);
                            });
                            return;
                        }
                        reject(new Error(`pageData is not expected type ${pageType.name}, ${JSON.stringify(pageData)}`));
                        return;
                    }
                    resolve(pageData);
                    return;
                }
                else if (pageAction.actionClass === "TabChangeAction") {
                    const tabChangeAction = pageAction;
                    if (tabChangeAction.actions.length === 1 &&
                        tabChangeAction.actions[0].actionClass === "FlowAction") {
                        const pageData = tabChangeAction.actions[0].pageData;
                        if (!objects.isTypeOf(pageData, pageType)) {
                            reject(new Error(`pageData is not expected type ${pageType.name}, ${JSON.stringify(pageData)}`));
                            return;
                        }
                        resolve(pageData);
                        return;
                    }
                }
                reject(new Error("Action is not a flowAction or a tabChangeAction that contains a single flowAction."));
            });
        });
    }
    /**
     * Fetch an action for a given url, including page data if there is any to return.
     * @param url   The URL to determine which actions to take.
     * @param referrerData Optional incoming deep link referrer data.
     * @param isIncomingURL Whether the fetch is for deep link.
     * @param visitedUrls Optional set of URLs already visited in this redirect chain to prevent cycles.
     * @returns A promise that will resolve to an Action.
     */
    async fetchAction(objectGraph, url, referrerData, isIncomingURL, visitedUrls = new Set()) {
        var _a;
        const routerResult = this.pageRouter.routedObjectForUrl(url);
        if (!routerResult.object) {
            // Urls fed into `fetchAction` can redirect to supported routes, thus we attempt to
            // chain the redirect and pipe it back into `fetchAction`.
            return await this.redirectAndRefetchActionIfPossible(objectGraph, routerResult.normalizedUrl, visitedUrls);
        }
        const builder = routerResult.object;
        return await builder.handlePage(objectGraph, routerResult.normalizedUrl, (_a = routerResult.parameters) !== null && _a !== void 0 ? _a : {}, routerResult.matchedRuleIdentifier, referrerData, isIncomingURL);
    }
    /**
     * Fetch the next page of a page. The returned page will have its `shelves`, `nextPage` properties set.
     * @param pageToken The page token to use.
     * @returns A promise that will resolve to a page.
     */
    async fetchMoreOfPage(objectGraph, pageToken) {
        const url = pageToken.url;
        if (!url) {
            throw new Error("Page token missing required `url` property");
        }
        const routerResult = this.paginationRouter.routedObjectForUrl(url);
        if (!routerResult.object) {
            throw new Error(`fetchMoreOfPage: Unhandled pagination url: ${url}`);
        }
        const builder = routerResult.object;
        return await builder.handlePagination(objectGraph, routerResult.normalizedUrl, routerResult.parameters, routerResult.matchedRuleIdentifier, pageToken);
    }
    /**
     * Fetch the contents of multiple shelves, producing a shelf batch object.
     * @param requests  A hash of request keys to URL strings.
     * @returns A promise that will resolve into multiple shelves.
     */
    async fetchShelves(objectGraph, requests) {
        // eslint-disable-next-line promise/param-names
        const shelfRequestKeys = Object.keys(requests);
        const shelfUrls = shelfRequestKeys.map((key) => requests[key]);
        // Guaranteed never to throw an error
        const shelfForUrl = async (shelfUrl) => {
            if (isNothing(shelfUrl)) {
                throw new Error(`fetchShelves: Null shelf url`);
            }
            const routerResult = this.shelfRouter.routedObjectForUrl(shelfUrl);
            if (isNothing(routerResult.object)) {
                throw new Error(`fetchShelves: Unhandled shelf url: ${shelfUrl}`);
            }
            const builder = routerResult.object;
            const shelf = await builder.handleShelf(objectGraph, routerResult.normalizedUrl, routerResult.parameters, routerResult.matchedRuleIdentifier);
            return shelf;
        };
        // Map each URL to a promise and make them all optional
        const resolvedShelves = await allOptional(shelfUrls.map(shelfForUrl));
        const batch = {
            shelves: {},
            errors: {},
        };
        for (const [index, resolvedShelf] of resolvedShelves.entries()) {
            const requestKey = shelfRequestKeys[index];
            if (isNothing(requestKey)) {
                // This should never happen as we create a shelf request for each key
                continue;
            }
            if (resolvedShelf.success) {
                batch.shelves[requestKey] = resolvedShelf.value;
            }
            else {
                // For rejected promises, access the error property
                batch.errors[requestKey] = resolvedShelf.error;
            }
        }
        const hasResults = Object.keys(batch.shelves).length > 0;
        if (hasResults) {
            return batch;
        }
        else {
            const messages = Object.keys(batch.errors).map((key) => batch.errors[key].message);
            if (messages.length === 0) {
                throw new Error(`Could not load any shelves: ${JSON.stringify(requests)}`);
            }
            else {
                throw new Error(messages.join("\n"));
            }
        }
    }
    // endregion
    // region Redirect
    /**
     * Given an `url`, it will fire a GET request and then try to refetch the action for url if
     * it was redirected. This is used for urls that are unknown, but redirects to a supported url.
     *
     * @param objectGraph
     * @param {string} url to attempt to redirect with. This is expected to be normalized.
     * @param {PromiseResolveFunction<models.Action>} resolve function to resolve promise to an `Action`.
     * @param {PromiseRejectFunction} reject function to reject promise to `Error`.
     * @param {Set<string>} visitedUrls Set of URLs already visited in this redirect chain to prevent cycles.
     */
    async redirectAndRefetchActionIfPossible(objectGraph, url, visitedUrls = new Set()) {
        // Check for redirect cycle
        const urlString = url.toString();
        if (visitedUrls.has(urlString)) {
            throw new Error(`redirectAndRefetchActionIfPossible: Redirect cycle detected for URL: ${urlString}`);
        }
        // Add current URL to visited set
        visitedUrls.add(urlString);
        const fetchResult = await tryAwait(objectGraph.network.fetch({
            url: urlString,
            method: "GET",
        }));
        if (!fetchResult.success) {
            throw new Error(`redirectAndRefetchActionIfPossible: Failed to fetch page at url: ${url}`);
        }
        const response = fetchResult.value;
        if (this.hasGotoURLInResponse(objectGraph, response)) {
            // If the response body contains a goto url fail silently as we will natively try to load the URL.
            return new models.BlankAction();
        }
        else if (response.status === 200 && response.redirected && response.url) {
            // Only route if this was a redirect; otherwise we don't know how to handle this page
            return await this.fetchAction(objectGraph, response.url, null, false, visitedUrls);
        }
        else {
            throw new Error(`redirectAndRefetchActionIfPossible: Unhandled page url: ${url}`);
        }
    }
    /**
     * Given a `response`, it will check to see if the response body contains a goto URL
     * A goto URL is returned by the server in a plist as a way of navigating to a new page
     * AMSURLSession which is the native response handler looks for these URLs and tries to load them natively
     * @param objectGraph
     * @param {FetchResponse} the response body from the server
     */
    hasGotoURLInResponse(objectGraph, response) {
        if (serverData.isNullOrEmpty(response.body)) {
            return false;
        }
        try {
            const responseData = serverData.asJSONValue(objectGraph.plist.parse(response.body));
            const responseActionKind = serverData.asString(responseData, "action.kind");
            const responseActionUrl = serverData.asString(responseData, "action.url");
            if (responseActionKind === "Goto" && isSome(responseActionUrl)) {
                return true;
            }
            return false;
        }
        catch {
            return false;
        }
    }
    // endregion
    // region Testing
    /**
     * Fetch the builder for a given url.
     * @param url   The URL that the builder is associated with.
     * @returns A builder if any was associated, or null if no match was found.
     * @note FOR TESTING ONLY.
     */
    fetchBuilder(url) {
        const routerResult = this.pageRouter.routedObjectForUrl(url) ||
            this.shelfRouter.routedObjectForUrl(url) ||
            this.paginationRouter.routedObjectForUrl(url);
        return routerResult ? routerResult.object : null;
    }
}
//# sourceMappingURL=routing.js.map