/** * 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} resolve function to resolve promise to an `Action`. * @param {PromiseRejectFunction} reject function to reject promise to `Error`. * @param {Set} 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