UNPKG

@botonic/core

Version:
406 lines (385 loc) 13.3 kB
import { NOT_FOUND_PATH } from '../constants' import { RouteInspector } from '../debug/inspector' import { Input, MatchedValue, Matcher, MatchingProp, Nullable, ProcessInputResult, Route, RouteParams, RoutePath, RoutingState, Session, } from '../models' import { cloneObject } from '../utils' import { getEmptyAction, getNotFoundAction, getPathParamsFromPathPayload, isPathPayload, pathParamsToParams, } from './router-utils' export class Router { routes: Route[] routeInspector: RouteInspector constructor( routes: Route[], routeInspector: RouteInspector | undefined = 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: Input, session: Session, lastRoutePath: RoutePath = null ): ProcessInputResult { session.__retries = session?.__retries ?? 0 // 1. Getting the current routing state. const { currentRoute, matchedRoute, params, isFlowBroken } = this.getRoutingState(input, session, lastRoutePath) const currentRoutePath = currentRoute?.path ?? null const matchedRoutePath = matchedRoute?.path ?? 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: currentRoute.action ?? 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: matchedRoute.action ?? 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: Input | Partial<Input>, routes: Route[], session: Session, lastRoutePath: RoutePath ): RouteParams | null { 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 as MatchingProp, value as Matcher, input as 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 = { ...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: RoutePath, routeList: Route[] = this.routes ): Nullable<Route> { 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: Route, prop: MatchingProp, matcher: Matcher, input: Input, session: Session, lastRoutePath: RoutePath ): MatchedValue { let value: any = 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: Matcher, value: any): MatchedValue { 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: Input, session: Session, lastRoutePath: RoutePath ): RoutingState { 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: Nullable<Route>, input: Input, session: Session ): RoutingState { // 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: { ...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, currentRoute?.path ?? null ) const isFlowBroken = Boolean(currentRoute?.path) if (routeParams?.route) { return { currentRoute, matchedRoute: { ...routeParams.route, path: routeParams.route?.path ?? 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: Nullable<Route>, pathPayload: string ): RoutingState { const { path, params } = getPathParamsFromPathPayload(pathPayload) /** * shorthand function to update the matching information given a path */ const getRoutingStateFromPath = (seekPath: string): RoutingState => { const matchedRoute = this.getRouteByPath(seekPath) if (!matchedRoute) { return { currentRoute, matchedRoute: null, params: {}, isFlowBroken: true, } } return { currentRoute, matchedRoute: { ...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?.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 as string) } }