UNPKG

@botonic/core

Version:
311 lines 14.7 kB
import { NOT_FOUND_PATH } from '../constants'; import { RouteInspector } from '../debug/inspector'; import { cloneObject } from '../utils'; import { getEmptyAction, getNotFoundAction, getPathParamsFromPathPayload, isPathPayload, } from './router-utils'; export class Router { constructor(routes, routeInspector = undefined) { this.routes = routes; this.routeInspector = routeInspector || new RouteInspector(); } /** * Processes an input and return a representation of the new bot state. * The algorithm is splitted in two main parts: * 1. Getting the current routing state. * 2. Given a routing state, resolve the different possible scenarios and return the new bot state. * The new bot state can return three type of actions: * - action: an action directly resolved from a matching route * - emptyAction: optional action that can exists or not only within childRoutes * - fallbackAction: any other action that acts as a fallback (404, ) */ // eslint-disable-next-line complexity processInput(input, session, lastRoutePath = null) { var _a, _b, _c, _d, _e; session.__retries = (_a = session === null || session === void 0 ? void 0 : session.__retries) !== null && _a !== void 0 ? _a : 0; // 1. Getting the current routing state. const { currentRoute, matchedRoute, params, isFlowBroken } = this.getRoutingState(input, session, lastRoutePath); const currentRoutePath = (_b = currentRoute === null || currentRoute === void 0 ? void 0 : currentRoute.path) !== null && _b !== void 0 ? _b : null; const matchedRoutePath = (_c = matchedRoute === null || matchedRoute === void 0 ? void 0 : matchedRoute.path) !== null && _c !== void 0 ? _c : null; // 2. Given a routing state, resolve the different possible scenarios and return the new bot state. /** * Redirect Scenario: * We have matched a redirect route with a given redirection path, so we try to obtain the redirectionRoute with getRouteByPath. * Independently of whether the redirectionRoute is found or not, the intent is to trigger a redirection which by definition breaks the flow, so retries are set to 0. * It has preference over ignoring retries. */ if (matchedRoute && matchedRoute.redirect) { session.__retries = 0; const redirectionRoute = this.getRouteByPath(matchedRoute.redirect); if (redirectionRoute) { return { action: redirectionRoute.action, emptyAction: getEmptyAction(redirectionRoute.childRoutes), fallbackAction: null, lastRoutePath: matchedRoute.redirect, params, }; } return { action: null, emptyAction: null, fallbackAction: getNotFoundAction(input, this.routes), lastRoutePath: null, params, }; } /** * Ignore Retry Scenario: * We have matched a route with an ignore retry, so we return directly the new bot state. The intent is to break the flow, so retries are set to 0. */ if (matchedRoute && matchedRoute.ignoreRetry) { session.__retries = 0; return { action: matchedRoute.action, emptyAction: getEmptyAction(matchedRoute.childRoutes), fallbackAction: null, lastRoutePath: matchedRoutePath, params, }; } /** * Retry Scenario: * We were in a route which had retries enabled, so we check if the number of retries is exceeded. * If we have not surpassed the limit of retries and we haven't matched an ignoreRetry route, update them, and then return the new bot state. */ if (isFlowBroken && currentRoute && currentRoute.retry && session.__retries < currentRoute.retry) { session.__retries = session.__retries !== 0 ? session.__retries + 1 : 1; if (matchedRoute && matchedRoutePath !== NOT_FOUND_PATH) { return { action: currentRoute.action, emptyAction: getEmptyAction(matchedRoute.childRoutes), fallbackAction: matchedRoute.action, lastRoutePath: currentRoutePath, params, }; } return { action: (_d = currentRoute.action) !== null && _d !== void 0 ? _d : null, emptyAction: getEmptyAction(currentRoute.childRoutes), fallbackAction: getNotFoundAction(input, this.routes), lastRoutePath: currentRoutePath, params, }; } /** * Default Scenario: * We have matched a route or not, but we don't need to execute retries logic, so retries stay to 0. */ session.__retries = 0; /** * Matching Route Scenario: * We have matched a route, so we return the new bot state. */ if (matchedRoute && matchedRoutePath !== NOT_FOUND_PATH) { return { action: (_e = matchedRoute.action) !== null && _e !== void 0 ? _e : null, emptyAction: getEmptyAction(matchedRoute.childRoutes), fallbackAction: null, lastRoutePath: matchedRoutePath, params, }; } /** * 404 Scenario (No Route Found): * We have not matched any route, so we return the new bot state. */ return { action: null, emptyAction: null, fallbackAction: getNotFoundAction(input, this.routes), params, lastRoutePath: currentRoutePath, }; } /** * Find the route that matches the given input, if it match with some of the entries, return the whole Route of the entry with optional params captured if matcher was a regex. * IMPORTANT: It returns a cloned route instead of the route itself to avoid modifying original routes and introduce side effects * */ getRoute(input, routes, session, lastRoutePath) { let params = {}; const route = routes.find(r => Object.entries(r) .filter(([key, _]) => key !== 'action' && key !== 'childRoutes' && key !== 'path') .some(([key, value]) => { const match = this.matchRoute(r, key, value, input, session, lastRoutePath); try { if (match !== null && typeof match !== 'boolean' && match.groups) { // Strip '[Object: null prototype]' from groups result: https://stackoverflow.com/a/62945609/6237608 params = Object.assign({}, match.groups); } } catch (e) { } return Boolean(match); })); if (route) return { route: cloneObject(route), params }; return null; } /** * Find the route that matches the given path. Path can include concatenations, e.g: 'Flow1/Subflow1.1'. * IMPORTANT: It returns a cloned route instead of the route itself to avoid modifying original routes and introduce side effects * */ getRouteByPath(path, routeList = this.routes) { if (!path) return null; const [currentPath, ...childPath] = path.split('/'); for (const route of routeList) { // iterate over all routeList if (route.path === currentPath) { if (route.childRoutes && route.childRoutes.length && childPath.length > 0) { // evaluate childroute over next actions const computedRoute = this.getRouteByPath(childPath.join('/'), route.childRoutes); // IMPORTANT: Returning a new object to avoid modifying dev routes and introduce side effects if (computedRoute) return cloneObject(computedRoute); } else if (childPath.length === 0) { return cloneObject(route); // last action and found route } } } return null; } /** * Returns the matched value for a specific route. * Matching Props: ('text' | 'payload' | 'intent' | 'type' | 'input' | 'session' | 'request' ...) * Matchers: (string: exact match | regex: regular expression match | function: return true) * input: user input object, e.g.: {type: 'text', data: 'Hi'} * */ matchRoute(route, prop, matcher, input, session, lastRoutePath) { let value = null; if (Object.keys(input).indexOf(prop) > -1) value = input[prop]; else if (prop === 'text') value = input.data; else if (prop === 'input') value = input; else if (prop === 'session') value = session; else if (prop === 'request') value = { input, session, lastRoutePath }; const matched = this.matchValue(matcher, value); if (matched) { this.routeInspector.routeMatched(route, prop, matcher, value); } else { this.routeInspector.routeNotMatched(route, prop, matcher, value); } return matched; } /** * Runs the matcher against the given value. * If there is a match, it will return a truthy value (true, RegExp result), o.w., it will return a falsy value. * */ matchValue(matcher, value) { if (typeof matcher === 'string') return value === matcher; if (matcher instanceof RegExp) { if (value === undefined || value === null) return false; return matcher.exec(value); } if (typeof matcher === 'function') return matcher(value); return false; } /** * It resolves the current state of navigation. Two scenarios: * 1. Given a path payload input (__PATH_PAYLOAD__somePath?someParam=someValue), we can resolve the routing state directly from it (using getRouteByPath). * 2. Given any other type of input, we resolve the routing state with normal resolution (using getRoute). * */ getRoutingState(input, session, lastRoutePath) { const currentRoute = this.getRouteByPath(lastRoutePath); if (currentRoute && lastRoutePath) currentRoute.path = lastRoutePath; if (typeof input.payload === 'string' && isPathPayload(input.payload)) { return this.getRoutingStateFromPathPayload(currentRoute, input.payload); } return this.getRoutingStateFromInput(currentRoute, input, session); } /** * Given a non path payload input, try to run it against the routes, update matching routes information in consequence and dictamine if the flow has been broken. * */ getRoutingStateFromInput(currentRoute, input, session) { var _a, _b, _c; // get route depending of current ChildRoutes if (currentRoute && currentRoute.childRoutes) { const routeParams = this.getRoute(input, currentRoute.childRoutes, session, currentRoute.path); if (routeParams) { return { currentRoute, matchedRoute: Object.assign(Object.assign({}, routeParams.route), { path: routeParams.route && currentRoute.path ? `${currentRoute.path}/${routeParams.route.path}` : currentRoute.path }), params: routeParams.params, isFlowBroken: false, }; } } /** * we couldn't find a route in the state of the currentRoute childRoutes, * so let's find in the general routes */ const routeParams = this.getRoute(input, this.routes, session, (_a = currentRoute === null || currentRoute === void 0 ? void 0 : currentRoute.path) !== null && _a !== void 0 ? _a : null); const isFlowBroken = Boolean(currentRoute === null || currentRoute === void 0 ? void 0 : currentRoute.path); if (routeParams === null || routeParams === void 0 ? void 0 : routeParams.route) { return { currentRoute, matchedRoute: Object.assign(Object.assign({}, routeParams.route), { path: (_c = (_b = routeParams.route) === null || _b === void 0 ? void 0 : _b.path) !== null && _c !== void 0 ? _c : null }), params: routeParams.params, isFlowBroken, }; } return { currentRoute, matchedRoute: null, params: {}, isFlowBroken, }; } /** * Given a path payload input, try to run the path against the routes, update matching routes information in consequence and dictamine if the flow has been broken. * */ getRoutingStateFromPathPayload(currentRoute, pathPayload) { const { path, params } = getPathParamsFromPathPayload(pathPayload); /** * shorthand function to update the matching information given a path */ const getRoutingStateFromPath = (seekPath) => { const matchedRoute = this.getRouteByPath(seekPath); if (!matchedRoute) { return { currentRoute, matchedRoute: null, params: {}, isFlowBroken: true, }; } return { currentRoute, matchedRoute: Object.assign(Object.assign({}, matchedRoute), { path: seekPath }), params, isFlowBroken: false, }; }; /** * Given a valid path: 'Flow1/Subflow1' we are in one of the two scenarios below. */ // 1. Received __PATH_PAYLOAD__Subflow2, so we need to first try to concatenate it with Flow1 (lastRoutePath) if (currentRoute === null || currentRoute === void 0 ? void 0 : currentRoute.path) { const routingState = getRoutingStateFromPath(`${currentRoute.path}/${path}`); if (routingState.matchedRoute) return routingState; } // 2. Received __PATH_PAYLOAD__Flow1/Subflow1, so we can resolve it directly return getRoutingStateFromPath(path); } } //# sourceMappingURL=router.js.map