From a682ce2f93f4dd58f60a48b9356dd406f6b27120 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 26 Sep 2018 14:12:32 -0700 Subject: [PATCH] feat(rest): switch to trie based routing - validate and normalize paths for openapi - add benchmark for routing - make the router pluggable - simplify app routing to plain functions - optimize simple route mapping and use regexp as default - add `rest.router` binding to allow customization - add docs for routing requests --- benchmark/README.md | 8 +- benchmark/package.json | 6 +- benchmark/src/rest-routing/README.md | 39 +++ benchmark/src/rest-routing/routing-table.ts | 111 +++++++++ docs/site/Routes.md | 3 +- docs/site/Routing-requests.md | 92 +++++++ docs/site/sidebars/lb4_sidebar.yml | 4 + packages/rest/src/http-handler.ts | 6 +- packages/rest/src/index.ts | 15 +- packages/rest/src/keys.ts | 7 + packages/rest/src/rest.application.ts | 26 +- packages/rest/src/rest.server.ts | 9 +- packages/rest/src/router/index.ts | 11 + packages/rest/src/router/openapi-path.ts | 66 +++++ packages/rest/src/router/regexp-router.ts | 88 +++++++ packages/rest/src/router/route-sort.ts | 84 +++++++ packages/rest/src/router/router-base.ts | 78 ++++++ packages/rest/src/router/routing-table.ts | 95 +++---- packages/rest/src/router/trie-router.ts | 45 ++++ packages/rest/src/router/trie.ts | 231 ++++++++++++++++++ .../acceptance/routing/routing.acceptance.ts | 38 +++ .../test/unit/router/controller-route.unit.ts | 2 +- .../test/unit/router/openapi-path.unit.ts | 85 +++++++ .../rest/test/unit/router/route-sort.unit.ts | 74 ++++++ .../test/unit/router/routing-table.unit.ts | 95 ++++++- .../rest/test/unit/router/trie-router.unit.ts | 97 ++++++++ packages/rest/test/unit/router/trie.unit.ts | 156 ++++++++++++ 27 files changed, 1483 insertions(+), 88 deletions(-) create mode 100644 benchmark/src/rest-routing/README.md create mode 100644 benchmark/src/rest-routing/routing-table.ts create mode 100644 docs/site/Routing-requests.md create mode 100644 packages/rest/src/router/index.ts create mode 100644 packages/rest/src/router/openapi-path.ts create mode 100644 packages/rest/src/router/regexp-router.ts create mode 100644 packages/rest/src/router/route-sort.ts create mode 100644 packages/rest/src/router/router-base.ts create mode 100644 packages/rest/src/router/trie-router.ts create mode 100644 packages/rest/src/router/trie.ts create mode 100644 packages/rest/test/unit/router/openapi-path.unit.ts create mode 100644 packages/rest/test/unit/router/route-sort.unit.ts create mode 100644 packages/rest/test/unit/router/trie-router.unit.ts create mode 100644 packages/rest/test/unit/router/trie.unit.ts diff --git a/benchmark/README.md b/benchmark/README.md index 996bf32eba2b..1d35382b617c 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -12,8 +12,8 @@ _Average number of requests handled every second._ | scenario | rps | | ----------------- | ---: | -| find all todos | 4569 | -| create a new todo | 348 | +| find all todos | 6230 | +| create a new todo | 377 | ### Latency @@ -21,8 +21,8 @@ _Average time to handle a request in milliseconds._ | scenario | latency | | ----------------- | ------: | -| find all todos | 1.68 | -| create a new todo | 28.27 | +| find all todos | 1.11 | +| create a new todo | 26.03 | ## Basic use diff --git a/benchmark/package.json b/benchmark/package.json index 23f20d6feec0..c3c4e1a8bf31 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -18,6 +18,7 @@ "pretest": "npm run clean && npm run build", "test": "lb-mocha \"dist/test\"", "prestart": "npm run build", + "benchmark:routing": "node ./dist/src/rest-routing/routing-table", "start": "node ." }, "repository": { @@ -35,13 +36,16 @@ ], "dependencies": { "@loopback/example-todo": "^0.21.2", + "@loopback/openapi-spec-builder": "^0.9.5", + "@loopback/rest": "^0.25.4", "@types/byline": "^4.2.31", - "@types/debug": "0.0.30", + "@types/debug": "0.0.31", "@types/p-event": "^1.3.0", "@types/request-promise-native": "^1.0.15", "autocannon": "^3.0.0", "byline": "^5.0.0", "debug": "^4.0.1", + "path-to-regexp": "^2.4.0", "request": "^2.88.0", "request-promise-native": "^1.0.5" }, diff --git a/benchmark/src/rest-routing/README.md b/benchmark/src/rest-routing/README.md new file mode 100644 index 000000000000..5cfb5971ce5b --- /dev/null +++ b/benchmark/src/rest-routing/README.md @@ -0,0 +1,39 @@ +# REST routing benchmark + +This directory contains a simple benchmarking to measure the performance of two +router implementations for REST APIs. See +https://loopback.io/doc/en/lb4/Routing-requests.html for more information. + +- [TrieRouter](https://github.com/strongloop/loopback-next/tree/master/packages/rest/src/router/trie-router.ts) +- [RegExpRouter](https://github.com/strongloop/loopback-next/tree/master/packages/rest/src/router/regexp-router.ts) + +## Basic use + +```sh +npm run -s benchmark:routing // default to 1000 routes +npm run -s benchmark:routing -- +``` + +## Base lines + +``` +name duration count found missed +TrieRouter 0,1453883 10 8 2 +RegExpRouter 0,1220030 10 8 2 + +name duration count found missed +TrieRouter 0,2109957 40 35 5 +RegExpRouter 0,8762936 40 35 5 + +name duration count found missed +TrieRouter 0,4895252 160 140 20 +RegExpRouter 0,52156699 160 140 20 + +name duration count found missed +TrieRouter 0,16065852 640 560 80 +RegExpRouter 0,304921026 640 560 80 + +name duration count found missed +TrieRouter 0,60165877 2560 2240 320 +RegExpRouter 4,592089555 2560 2240 320 +``` diff --git a/benchmark/src/rest-routing/routing-table.ts b/benchmark/src/rest-routing/routing-table.ts new file mode 100644 index 000000000000..6019bde94727 --- /dev/null +++ b/benchmark/src/rest-routing/routing-table.ts @@ -0,0 +1,111 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; +import { + RoutingTable, + TrieRouter, + RestRouter, + RegExpRouter, + OpenApiSpec, +} from '@loopback/rest'; + +function runBenchmark(count = 1000) { + const spec = givenNumberOfRoutes('/hello', count); + + const trieTest = givenRouter(new TrieRouter(), spec, count); + const regexpTest = givenRouter(new RegExpRouter(), spec, count); + + const result1 = trieTest(); + const result2 = regexpTest(); + + console.log( + '%s %s %s %s %s', + 'name'.padEnd(12), + 'duration'.padStart(16), + 'count'.padStart(8), + 'found'.padStart(8), + 'missed'.padStart(8), + ); + for (const r of [result1, result2]) { + console.log( + '%s %s %s %s %s', + `${r.name}`.padEnd(12), + `${r.duration}`.padStart(16), + `${r.count}`.padStart(8), + `${r.found}`.padStart(8), + `${r.missed}`.padStart(8), + ); + } +} + +function givenNumberOfRoutes(base: string, num: number) { + const spec = anOpenApiSpec(); + let i = 0; + while (i < num) { + // Add 1/4 paths with vars + if (i % 4 === 0) { + spec.withOperationReturningString( + 'get', + `${base}/group${i}/{version}`, + `greet${i}`, + ); + } else { + spec.withOperationReturningString( + 'get', + `${base}/group${i}/version_${i}`, + `greet${i}`, + ); + } + i++; + } + const result = spec.build(); + result.basePath = '/my'; + return result; +} + +function givenRouter(router: RestRouter, spec: OpenApiSpec, count: number) { + const name = router.constructor.name; + class TestController {} + + return (log?: (...args: unknown[]) => void) => { + log = log || (() => {}); + log('Creating %s, %d', name, count); + let start = process.hrtime(); + + const table = new RoutingTable(router); + table.registerController(spec, TestController); + router.list(); // Force sorting + log('Created %s %s', name, process.hrtime(start)); + + log('Starting %s %d', name, count); + let found = 0, + missed = 0; + start = process.hrtime(); + for (let i = 0; i < count; i++) { + let group = `group${i}`; + if (i % 8 === 0) { + // Make it not found + group = 'groupX'; + } + // tslint:disable-next-line:no-any + const request: any = { + method: 'get', + path: `/my/hello/${group}/version_${i}`, + }; + + try { + table.find(request); + found++; + } catch (e) { + missed++; + } + } + log('Done %s', name); + return {name, duration: process.hrtime(start), count, found, missed}; + }; +} + +runBenchmark(+process.argv[2] || 1000); diff --git a/docs/site/Routes.md b/docs/site/Routes.md index bca31f69b970..0185ece27fc6 100644 --- a/docs/site/Routes.md +++ b/docs/site/Routes.md @@ -130,8 +130,7 @@ function greet(name: string) { } const app = new RestApplication(); -const route = new Route('get', '/', spec, greet); -app.route(route); // attaches route to RestServer +app.route('get', '/', spec, greet); // attaches route to RestServer app.start(); ``` diff --git a/docs/site/Routing-requests.md b/docs/site/Routing-requests.md new file mode 100644 index 000000000000..22330e7bad9e --- /dev/null +++ b/docs/site/Routing-requests.md @@ -0,0 +1,92 @@ +--- +lang: en +title: 'Routing requests' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Routing-requests.html +--- + +## Routing Requests + +This is an action in the default HTTP sequence. Its responsibility is to find a +route that can handle a given http request. By default, the `FindRoute` action +uses the `RoutingTable` from `@loopback/rest` to match requests against +registered routes including controller methods using `request.method` and +`request.path`. For example: + +- GET /orders => OrderController.getOrders (`@get('/orders')`) +- GET /orders/123 => OrderController.getOrderById (`@get('/orders/{id}')`) +- GET /orders/count => OrderController.getOrderCount (`@get('/orders/count')`) +- POST /orders => OrderController.createOrder (`@post('/orders')`) + +## Customize the `FindRoute` action + +The `FindRoute` action is bound to `SequenceActions.FIND_ROUTE` +('rest.sequence.actions.findRoute') and injected into the default sequence. + +To create your own `FindRoute` action, bind your implementation as follows: + +```ts +const yourFindRoute: FindRoute = ...; +app.bind(SequenceActions.FIND_ROUTE).to(yourFindRoute); +``` + +## Customize the REST Router + +Instead of rewriting `FindRoute` action completely, LoopBack 4 also allows you +to simply replace the `RestRouter` implementation. + +The `@loopback/rest` module ships two built-in routers: + +- TrieRouter: it keeps routes as a `trie` tree and uses traversal to match + `request` to routes based on the hierarchy of the path +- RegExpRouter: it keeps routes as an array and uses `path-to-regexp` to match + `request` to routes based on the path pattern + +For both routers, routes without variables are optimized in a map so that any +requests matching to a fixed path can be resolved quickly. + +By default, `@loopback/rest` uses `TrieRouter` as it performs better than +`RegExpRouter`. There is a simple benchmarking for `RegExpRouter` and +`TrieRouter` at +https://githhub.com/strongloop/loopback-next/benchmark/src/rest-routing/routing-table.ts. + +To change the router for REST routing, we can bind the router class as follows: + +```ts +import {RestBindings, RegExpRouter} from '@loopback/rest'; +app.bind(RestBindings.ROUTER).toClass(RegExpRouter); +``` + +It's also possible to have your own implementation of `RestRouter` interface +below: + +```ts +/** + * Interface for router implementation + */ +export interface RestRouter { + /** + * Add a route to the router + * @param route A route entry + */ + add(route: RouteEntry): boolean; + + /** + * Find a matching route for the given http request + * @param request Http request + * @returns The resolved route, if not found, `undefined` is returned + */ + find(request: Request): ResolvedRoute | undefined; + + /** + * List all routes + */ + list(): RouteEntry[]; +} +``` + +See examples at: + +- [TrieRouter](https://github.com/strongloop/loopback-next/tree/master/packages/rest/src/router/trie-router.ts) +- [RegExpRouter](https://github.com/strongloop/loopback-next/tree/master/packages/rest/src/router/regexp-router.ts) diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index d6a12948d077..4b9e20cf6ac9 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -140,6 +140,10 @@ children: output: 'web, pdf' children: + - title: 'Routing requests' + url: Routing-requests.html + output: 'web, pdf' + - title: 'Parsing requests' url: Parsing-requests.html output: 'web, pdf' diff --git a/packages/rest/src/http-handler.ts b/packages/rest/src/http-handler.ts index 51233ea5f137..7fe5d9d5dbdc 100644 --- a/packages/rest/src/http-handler.ts +++ b/packages/rest/src/http-handler.ts @@ -21,12 +21,14 @@ import {RestBindings} from './keys'; import {RequestContext} from './request-context'; export class HttpHandler { - protected _routes: RoutingTable = new RoutingTable(); protected _apiDefinitions: SchemasObject; public handleRequest: (request: Request, response: Response) => Promise; - constructor(protected _rootContext: Context) { + constructor( + protected _rootContext: Context, + protected _routes = new RoutingTable(), + ) { this.handleRequest = (req, res) => this._handleRequest(req, res); } diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index e7bdf8771117..f517befdb0a8 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -3,20 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -export { - RouteEntry, - RoutingTable, - Route, - ControllerRoute, - ResolvedRoute, - createResolvedRoute, - ControllerClass, - ControllerInstance, - ControllerFactory, - createControllerFactoryForBinding, - createControllerFactoryForClass, - createControllerFactoryForInstance, -} from './router/routing-table'; +export * from './router'; export * from './providers'; diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts index b2d85b34a3af..e5389b339e1b 100644 --- a/packages/rest/src/keys.ts +++ b/packages/rest/src/keys.ts @@ -30,6 +30,7 @@ import { import {HttpProtocol} from '@loopback/http-server'; import * as https from 'https'; import {ErrorWriterOptions} from 'strong-error-handler'; +import {RestRouter} from './router'; /** * RestServer-specific bindings @@ -65,6 +66,12 @@ export namespace RestBindings { * Internal binding key for http-handler */ export const HANDLER = BindingKey.create('rest.handler'); + + /** + * Internal binding key for rest router + */ + export const ROUTER = BindingKey.create('rest.router'); + /** * Binding key for setting and injecting Reject action's error handling * options. diff --git a/packages/rest/src/rest.application.ts b/packages/rest/src/rest.application.ts index 92d063ab2151..2ef2baa71e9d 100644 --- a/packages/rest/src/rest.application.ts +++ b/packages/rest/src/rest.application.ts @@ -14,6 +14,7 @@ import { RouteEntry, ControllerClass, ControllerFactory, + Route, } from './router/routing-table'; import {OperationObject, OpenApiSpec} from '@loopback/openapi-v3-types'; import {ServeStaticOptions} from 'serve-static'; @@ -141,23 +142,44 @@ export class RestApplication extends Application implements HttpServerLike { */ route(route: RouteEntry): Binding; + /** + * Register a new route. + * + * ```ts + * function greet(name: string) { + * return `hello ${name}`; + * } + * app.route('get', '/', operationSpec, greet); + * ``` + */ + route( + verb: string, + path: string, + spec: OperationObject, + handler: Function, + ): Binding; + route( routeOrVerb: RouteEntry | string, path?: string, spec?: OperationObject, - controllerCtor?: ControllerClass, + controllerCtorOrHandler?: ControllerClass | Function, controllerFactory?: ControllerFactory, methodName?: string, ): Binding { const server = this.restServer; if (typeof routeOrVerb === 'object') { return server.route(routeOrVerb); + } else if (arguments.length === 4) { + return server.route( + new Route(routeOrVerb, path!, spec!, controllerCtorOrHandler!), + ); } else { return server.route( routeOrVerb, path!, spec!, - controllerCtor!, + controllerCtorOrHandler as ControllerClass, controllerFactory!, methodName!, ); diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 73e3e18d36fd..b4d4ec44b152 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -32,6 +32,7 @@ import { createControllerFactoryForBinding, Route, RouteEntry, + RoutingTable, } from './router/routing-table'; import {DefaultSequence, SequenceFunction, SequenceHandler} from './sequence'; @@ -281,7 +282,13 @@ export class RestServer extends Context implements Server, HttpServerLike { // See https://github.com/strongloop/loopback-next/issues/433 if (this._httpHandler) return; - this._httpHandler = new HttpHandler(this); + /** + * Check if there is custom router in the context + */ + const router = this.getSync(RestBindings.ROUTER, {optional: true}); + const routingTable = new RoutingTable(router); + + this._httpHandler = new HttpHandler(this, routingTable); for (const b of this.find('controllers.*')) { const controllerName = b.key.replace(/^controllers\./, ''); const ctor = b.valueConstructor; diff --git a/packages/rest/src/router/index.ts b/packages/rest/src/router/index.ts new file mode 100644 index 000000000000..4da2f5a98bd3 --- /dev/null +++ b/packages/rest/src/router/index.ts @@ -0,0 +1,11 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './routing-table'; +export * from './openapi-path'; +export * from './trie'; +export * from './trie-router'; +export * from './regexp-router'; +export * from './route-sort'; diff --git a/packages/rest/src/router/openapi-path.ts b/packages/rest/src/router/openapi-path.ts new file mode 100644 index 000000000000..e894ee9a63c9 --- /dev/null +++ b/packages/rest/src/router/openapi-path.ts @@ -0,0 +1,66 @@ +// Copyright IBM Corp. 2017, 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import pathToRegExp = require('path-to-regexp'); + +/** + * OpenAPI spec 3.x does not specify the valid forms of path templates. + * + * Other ones such as [URI Template](https://tools.ietf.org/html/rfc6570#section-2.3) + * or [path-to-regexp](https://github.com/pillarjs/path-to-regexp#named-parameters) + * allows `[A-Za-z0-9_]` + */ +const POSSIBLE_VARNAME_PATTERN = /\{([^\}]+)\}/g; +const INVALID_VARNAME_PATTERN = /\{([^\}]*[^\w\}][^\}]*)\}/; + +/** + * Validate the path to be compatible with OpenAPI path template. No parameter + * modifier, custom pattern, or unnamed parameter is allowed. + */ +export function validateApiPath(path: string = '/') { + let tokens = pathToRegExp.parse(path); + if (tokens.some(t => typeof t === 'object')) { + throw new Error( + `Invalid path template: '${path}'. Please use {param} instead of ':param'`, + ); + } + + const invalid = path.match(INVALID_VARNAME_PATTERN); + if (invalid) { + throw new Error( + `Invalid parameter name '${invalid[1]}' found in path '${path}'`, + ); + } + + const regexpPath = path.replace(POSSIBLE_VARNAME_PATTERN, ':$1'); + tokens = pathToRegExp.parse(regexpPath); + for (const token of tokens) { + if (typeof token === 'string') continue; + if (typeof token.name === 'number') { + // Such as /(.*) + throw new Error(`Unnamed parameter is not allowed in path '${path}'`); + } + if (token.optional || token.repeat || token.pattern !== '[^\\/]+?') { + // Such as /:foo*, /:foo+, /:foo?, or /:foo(\\d+) + throw new Error(`Parameter modifier is not allowed in path '${path}'`); + } + } + return path; +} + +/** + * Get all path variables. For example, `/root/{foo}/bar` => `['foo']` + */ +export function getPathVariables(path: string) { + return path.match(POSSIBLE_VARNAME_PATTERN); +} + +/** + * Convert an OpenAPI path to Express (path-to-regexp) style + * @param path OpenAPI path with optional variables as `{var}` + */ +export function toExpressPath(path: string) { + return path.replace(POSSIBLE_VARNAME_PATTERN, ':$1'); +} diff --git a/packages/rest/src/router/regexp-router.ts b/packages/rest/src/router/regexp-router.ts new file mode 100644 index 000000000000..12f043b79c17 --- /dev/null +++ b/packages/rest/src/router/regexp-router.ts @@ -0,0 +1,88 @@ +// Copyright IBM Corp. 2017, 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {RouteEntry, createResolvedRoute, ResolvedRoute} from './routing-table'; +import {Request, PathParameterValues} from '../types'; +import {inspect} from 'util'; +import {compareRoute} from './route-sort'; +import pathToRegExp = require('path-to-regexp'); +import {BaseRouter} from './router-base'; +import {toExpressPath} from './openapi-path'; + +const debug = require('debug')('loopback:rest:router:regexp'); + +/** + * Route entry with path-to-regexp + */ +interface RegExpRouteEntry extends RouteEntry { + regexp: RegExp; + keys: pathToRegExp.Key[]; +} + +/** + * Router implementation based on regexp matching + */ +export class RegExpRouter extends BaseRouter { + private routes: RegExpRouteEntry[] = []; + + // Sort the routes based on their paths and variables + private _sorted: boolean; + private _sort() { + if (!this._sorted) { + this.routes.sort(compareRoute); + this._sorted = true; + } + } + + protected addRouteWithPathVars(route: RouteEntry) { + const path = toExpressPath(route.path); + const keys: pathToRegExp.Key[] = []; + const regexp = pathToRegExp(path, keys, {strict: false, end: true}); + const entry: RegExpRouteEntry = Object.assign(route, {keys, regexp}); + this.routes.push(entry); + this._sorted = false; + } + + protected findRouteWithPathVars(request: Request): ResolvedRoute | undefined { + this._sort(); + for (const r of this.routes) { + debug('trying endpoint %s', inspect(r, {depth: 5})); + if (r.verb !== request.method.toLowerCase()) { + debug(' -> verb mismatch'); + continue; + } + + const match = r.regexp.exec(request.path); + if (!match) { + debug(' -> path mismatch'); + continue; + } + + const pathParams = this._buildPathParams(r, match); + debug(' -> found with params: %j', pathParams); + + return createResolvedRoute(r, pathParams); + } + return undefined; + } + + protected listRoutesWithPathVars() { + this._sort(); + return this.routes; + } + + private _buildPathParams( + route: RegExpRouteEntry, + pathMatch: RegExpExecArray, + ): PathParameterValues { + const pathParams: PathParameterValues = {}; + for (const ix in route.keys) { + const key = route.keys[ix]; + const matchIndex = +ix + 1; + pathParams[key.name] = pathMatch[matchIndex]; + } + return pathParams; + } +} diff --git a/packages/rest/src/router/route-sort.ts b/packages/rest/src/router/route-sort.ts new file mode 100644 index 000000000000..13c098f4d034 --- /dev/null +++ b/packages/rest/src/router/route-sort.ts @@ -0,0 +1,84 @@ +import {RouteEntry} from './routing-table'; +import pathToRegExp = require('path-to-regexp'); + +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * Sorting order for http verbs + */ +const HTTP_VERBS: {[name: string]: number} = { + post: 1, + put: 2, + patch: 3, + get: 4, + head: 5, + delete: 6, + options: 7, +}; + +/** + * Compare two routes by verb/path for sorting + * @param route1 First route entry + * @param route2 Second route entry + */ +export function compareRoute( + route1: Pick, + route2: Pick, +): number { + // First check verb + const verb1 = HTTP_VERBS[route1.verb.toLowerCase()] || HTTP_VERBS.get; + const verb2 = HTTP_VERBS[route2.verb.toLowerCase()] || HTTP_VERBS.get; + if (verb1 !== verb2) return verb1 - verb2; + + // Then check the path tokens + const path1 = route1.path.replace(/{([^}]*)}(\/|$)/g, ':$1$2'); + const path2 = route2.path.replace(/{([^}]*)}(\/|$)/g, ':$1$2'); + const tokensForPath1: pathToRegExp.Token[] = parse(path1); + const tokensForPath2: pathToRegExp.Token[] = parse(path2); + + // Longer path comes before shorter path + if (tokensForPath1.length < tokensForPath2.length) { + return 1; + } else if (tokensForPath1.length > tokensForPath2.length) { + return -1; + } + + // Now check token by token + for (let i = 0; i < tokensForPath1.length; i++) { + const token1 = tokensForPath1[i]; + const token2 = tokensForPath2[i]; + if (typeof token1 === 'string' && typeof token2 === 'string') { + if (token1 < token2) return -1; + else if (token1 > token2) return 1; + } else if (typeof token1 === 'string' && typeof token2 === 'object') { + // token 1 is a literal while token 2 is a parameter + return -1; + } else if (typeof token1 === 'object' && typeof token2 === 'string') { + // token 1 is a parameter while token 2 is a literal + return 1; + } else { + // Both token are parameters. Treat as equal weight. + } + } + return 0; +} + +/** + * + * @param path Parse a path template into tokens + */ +function parse(path: string) { + const tokens: pathToRegExp.Token[] = []; + pathToRegExp.parse(path).forEach(p => { + if (typeof p === 'string') { + // The string can be /orders/count + tokens.push(...p.split('/').filter(Boolean)); + } else { + tokens.push(p); + } + }); + return tokens; +} diff --git a/packages/rest/src/router/router-base.ts b/packages/rest/src/router/router-base.ts new file mode 100644 index 000000000000..cc600c07be6a --- /dev/null +++ b/packages/rest/src/router/router-base.ts @@ -0,0 +1,78 @@ +// Copyright IBM Corp. 2017, 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + RestRouter, + RouteEntry, + createResolvedRoute, + ResolvedRoute, +} from './routing-table'; +import {Request} from '../types'; +import {getPathVariables} from './openapi-path'; +import {compareRoute} from './route-sort'; + +/** + * Base router implementation that only handles path without variables + */ +export abstract class BaseRouter implements RestRouter { + /** + * A map to optimize matching for routes without variables in the path + */ + protected routesWithoutPathVars: {[path: string]: RouteEntry} = {}; + + protected getKeyForRoute(route: RouteEntry) { + const path = route.path.startsWith('/') ? route.path : `/${route.path}`; + const verb = route.verb.toLowerCase() || 'get'; + return `/${verb}${path}`; + } + + add(route: RouteEntry) { + if (!getPathVariables(route.path)) { + const key = this.getKeyForRoute(route); + this.routesWithoutPathVars[key] = route; + } else { + this.addRouteWithPathVars(route); + } + } + + protected getKeyForRequest(request: Request) { + const method = request.method.toLowerCase(); + return `/${method}${request.path}`; + } + + find(request: Request) { + const path = this.getKeyForRequest(request); + let route = this.routesWithoutPathVars[path]; + if (route) return createResolvedRoute(route, {}); + else return this.findRouteWithPathVars(request); + } + + list() { + let routes = Object.values(this.routesWithoutPathVars); + routes = routes.concat(this.listRoutesWithPathVars()); + // Sort the routes so that they show up in OpenAPI spec in order + return routes.sort(compareRoute); + } + + // The following abstract methods need to be implemented by its subclasses + /** + * Add a route with path variables + * @param route + */ + protected abstract addRouteWithPathVars(route: RouteEntry): void; + + /** + * Find a route with path variables + * @param route + */ + protected abstract findRouteWithPathVars( + request: Request, + ): ResolvedRoute | undefined; + + /** + * List routes with path variables + */ + protected abstract listRoutesWithPathVars(): RouteEntry[]; +} diff --git a/packages/rest/src/router/routing-table.ts b/packages/rest/src/router/routing-table.ts index 8f94d39e4041..cbc09ec926c8 100644 --- a/packages/rest/src/router/routing-table.ts +++ b/packages/rest/src/router/routing-table.ts @@ -1,4 +1,4 @@ -// Copyright IBM Corp. 2017. All Rights Reserved. +// Copyright IBM Corp. 2017, 2018. All Rights Reserved. // Node module: @loopback/rest // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT @@ -32,11 +32,9 @@ import {ControllerSpec} from '@loopback/openapi-v3'; import * as assert from 'assert'; const debug = require('debug')('loopback:rest:routing-table'); -// TODO(bajtos) Refactor this code to use Trie-based lookup, -// e.g. via wayfarer/trie or find-my-way -// See https://github.com/strongloop/loopback-next/issues/98 -import * as pathToRegexp from 'path-to-regexp'; import {CoreBindings} from '@loopback/core'; +import {validateApiPath} from './openapi-path'; +import {TrieRouter} from './trie-router'; /** * A controller instance with open properties/methods @@ -57,11 +55,34 @@ export type ControllerFactory = ( */ export type ControllerClass = Constructor; +/** + * Interface for router implementation + */ +export interface RestRouter { + /** + * Add a route to the router + * @param route A route entry + */ + add(route: RouteEntry): void; + + /** + * Find a matching route for the given http request + * @param request Http request + * @returns The resolved route, if not found, `undefined` is returned + */ + find(request: Request): ResolvedRoute | undefined; + + /** + * List all routes + */ + list(): RouteEntry[]; +} + /** * Routing table */ export class RoutingTable { - private readonly _routes: RouteEntry[] = []; + constructor(private readonly _router: RestRouter = new TrieRouter()) {} /** * Register a controller as the route @@ -126,13 +147,15 @@ export class RoutingTable { describeOperationParameters(route.spec), ); } - this._routes.push(route); + + validateApiPath(route.path); + this._router.add(route); } describeApiPaths(): PathObject { const paths: PathObject = {}; - for (const route of this._routes) { + for (const route of this._router.list()) { if (!paths[route.path]) { paths[route.path] = {}; } @@ -148,11 +171,16 @@ export class RoutingTable { * @param request */ find(request: Request): ResolvedRoute { - for (const entry of this._routes) { - const match = entry.match(request); - if (match) return match; + debug('Finding route %s for %s %s', request.method, request.path); + + const found = this._router.find(request); + + if (found) { + debug('Route matched: %j', found); + return found; } + debug('No route found for %s %s', request.method, request.path); throw new HttpErrors.NotFound( `Endpoint "${request.method} ${request.path}" not found.`, ); @@ -176,12 +204,6 @@ export interface RouteEntry { */ readonly spec: OperationObject; - /** - * Map an http request to a route - * @param request - */ - match(request: Request): ResolvedRoute | undefined; - /** * Update bindings for the request context * @param requestContext @@ -221,8 +243,6 @@ export interface ResolvedRoute extends RouteEntry { */ export abstract class BaseRoute implements RouteEntry { public readonly verb: string; - private readonly _keys: pathToRegexp.Key[] = []; - private readonly _pathRegexp: RegExp; /** * Construct a new route @@ -236,33 +256,6 @@ export abstract class BaseRoute implements RouteEntry { public readonly spec: OperationObject, ) { this.verb = verb.toLowerCase(); - - // In Swagger, path parameters are wrapped in `{}`. - // In Express.js, path parameters are prefixed with `:` - path = path.replace(/{([^}]*)}(\/|$)/g, ':$1$2'); - this._pathRegexp = pathToRegexp(path, this._keys, { - strict: false, - end: true, - }); - } - - match(request: Request): ResolvedRoute | undefined { - debug('trying endpoint %s', inspect(this, {depth: 5})); - if (this.verb !== request.method!.toLowerCase()) { - debug(' -> verb mismatch'); - return undefined; - } - - const match = this._pathRegexp.exec(request.path); - if (!match) { - debug(' -> path mismatch'); - return undefined; - } - - const pathParams = this._buildPathParams(match); - debug(' -> found with params: %j', pathParams); - - return createResolvedRoute(this, pathParams); } abstract updateBindings(requestContext: Context): void; @@ -275,16 +268,6 @@ export abstract class BaseRoute implements RouteEntry { describe(): string { return `"${this.verb} ${this.path}"`; } - - private _buildPathParams(pathMatch: RegExpExecArray): PathParameterValues { - const pathParams = Object.create(null); - for (const ix in this._keys) { - const key = this._keys[ix]; - const matchIndex = +ix + 1; - pathParams[key.name] = pathMatch[matchIndex]; - } - return pathParams; - } } export function createResolvedRoute( diff --git a/packages/rest/src/router/trie-router.ts b/packages/rest/src/router/trie-router.ts new file mode 100644 index 000000000000..a34ea89e3fda --- /dev/null +++ b/packages/rest/src/router/trie-router.ts @@ -0,0 +1,45 @@ +// Copyright IBM Corp. 2017, 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {RouteEntry, createResolvedRoute} from './routing-table'; +import {Trie} from './trie'; +import {Request} from '../types'; +import {inspect} from 'util'; +import {BaseRouter} from './router-base'; + +const debug = require('debug')('loopback:rest:router:trie'); + +/** + * Router implementation based on trie + */ +export class TrieRouter extends BaseRouter { + private trie = new Trie(); + + protected addRouteWithPathVars(route: RouteEntry) { + // Add the route to the trie + const key = this.getKeyForRoute(route); + this.trie.create(key, route); + } + + protected findRouteWithPathVars(request: Request) { + const path = this.getKeyForRequest(request); + + const found = this.trie.match(path); + debug('Route matched: %j', found); + + if (found) { + const route = found.node.value!; + if (route) { + debug('Route found: %s', inspect(route, {depth: 5})); + return createResolvedRoute(route, found.params || {}); + } + } + return undefined; + } + + protected listRoutesWithPathVars() { + return this.trie.list().map(n => n.value); + } +} diff --git a/packages/rest/src/router/trie.ts b/packages/rest/src/router/trie.ts new file mode 100644 index 000000000000..5836b5841bfe --- /dev/null +++ b/packages/rest/src/router/trie.ts @@ -0,0 +1,231 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {PathParameterValues} from '../types'; + +/** + * A Node in the trie + */ +export interface Node { + /** + * Key of the node + */ + key: string; + /** + * Value of the node + */ + value?: T; + /** + * Children of the node + */ + readonly children: {[key: string]: Node}; + + /** + * Regular expression for the template + */ + regexp?: RegExp; + /** + * Names of the node if it contains named parameters + */ + names?: string[]; +} + +export type NodeWithValue = Node & {value: T}; + +export interface ResolvedNode { + node: Node; + // tslint:disable-next-line:no-any + params?: PathParameterValues; +} + +/** + * An implementation of trie for routes. The key hierarchy is built with parts + * of the route path delimited by `/` + */ +export class Trie { + readonly root: Node = {key: '', children: {}}; + + /** + * Create a node for a given path template + * @param pathTemplate The path template, + * @param value Value of the route + */ + create(routeTemplate: string, value: T) { + const keys = routeTemplate.split('/').filter(Boolean); + return createNode(keys, 0, value, this.root); + } + + /** + * Match a route path against the trie + * @param path The route path, such as `/customers/c01` + */ + match( + path: string, + ): (ResolvedNode & {node: NodeWithValue}) | undefined { + const keys = path.split('/').filter(Boolean); + const params = {}; + const resolved = search(keys, 0, params, this.root); + if (resolved == null || !isNodeWithValue(resolved.node)) return undefined; + return { + node: resolved.node, + params: resolved.params, + }; + } + + /** + * List all nodes with value of the trie + */ + list(): NodeWithValue[] { + const nodes: NodeWithValue[] = []; + traverse(this.root, node => { + nodes.push(node); + }); + return nodes; + } +} + +function isNodeWithValue(node: Node): node is NodeWithValue { + return node.value != null; +} + +/** + * Use depth-first preorder traversal to list all nodes with values + * @param root Root node + * @param visitor A function to process nodes with values + */ +function traverse(root: Node, visitor: (node: NodeWithValue) => void) { + if (isNodeWithValue(root)) visitor(root); + for (const k in root.children) { + traverse(root.children[k], visitor); + } +} + +/** + * Match the given key to a child of the parent node + * @param key Key + * @param parent Parent node + */ +function matchChild( + key: string, + parent: Node, +): ResolvedNode | undefined { + // Match key literal first + let child = parent.children[key]; + if (child) { + return { + node: child, + }; + } + + // Match templates + for (const k in parent.children) { + child = parent.children[k]; + if (!child.names || !child.regexp) continue; + const match = child.regexp.exec(key); + if (match) { + const resolved: ResolvedNode = {params: {}, node: child}; + let i = 0; + for (const n of child.names) { + const val = match[++i]; + resolved.params![n] = decodeURIComponent(val); + } + return resolved; + } + } +} + +/** + * Search a sub list of keys against the parent node + * @param keys An array of keys + * @param index Starting index of the key list + * @param params An object to receive resolved parameter values + * @param parent Parent node + */ +function search( + keys: string[], + index: number, + // tslint:disable-next-line:no-any + params: {[name: string]: any}, + parent: Node, +): ResolvedNode | undefined { + const key = keys[index]; + const resolved: ResolvedNode = {node: parent, params}; + if (key === undefined) return resolved; + + const child = matchChild(key, parent); + if (child) { + Object.assign(params, child.params); + return search(keys, index + 1, params, child.node); + } + // no matches found + return undefined; +} + +/** + * Create a node for a sub list of keys against the parent node + * @param keys An array of keys + * @param index Starting index of the key list + * @param value Value of the node + * @param parent Parent node + */ +function createNode( + keys: string[], + index: number, + value: T, + parent: Node, +): Node { + const key = keys[index]; + if (key === undefined) return parent; + + const isLast = keys.length - 1 === index; + let child = parent.children[key]; + if (child != null) { + // Found an existing node + if (isLast) { + if (child.value == null) { + child.value = value; + } else { + if (child.value !== value) { + throw new Error( + 'Duplicate key found with different value: ' + keys.join('/'), + ); + } + } + } + return createNode(keys, index + 1, value, child); + } + + /** + * Build a new node + */ + child = { + children: {}, + key: key, + }; + + if (isLast) { + child.value = value; + } + + // Check if the key has variables such as `{var}` + const pattern = /\{([^\{]*)\}/g; + const names: string[] = []; + let match; + while ((match = pattern.exec(key))) { + names.push(match[1]); + } + + if (names.length) { + child.names = names; + const re = '^' + key.replace(/\{([^\}]+)\}/g, '(.+)') + '$'; + child.regexp = new RegExp(re); + } + + // Add the node to the parent + parent.children[key] = child; + + // Create nodes for rest of the keys + return createNode(keys, index + 1, value, child); +} diff --git a/packages/rest/test/acceptance/routing/routing.acceptance.ts b/packages/rest/test/acceptance/routing/routing.acceptance.ts index 9116a4133108..ee1af228a096 100644 --- a/packages/rest/test/acceptance/routing/routing.acceptance.ts +++ b/packages/rest/test/acceptance/routing/routing.acceptance.ts @@ -34,6 +34,7 @@ import {anOpenApiSpec, anOperationSpec} from '@loopback/openapi-spec-builder'; import {inject, Context, BindingScope} from '@loopback/context'; import {createUnexpectedHttpErrorLogger} from '../../helpers'; +import {RegExpRouter} from '../../..'; /* # Feature: Routing * - In order to build REST APIs @@ -700,6 +701,43 @@ describe('Routing', () => { .get('/') .expect(200, 'hello'); }); + + it('allows pluggable router', async () => { + const app = new RestApplication(); + app.bind(RestBindings.ROUTER).toClass(RegExpRouter); + const server = await app.getServer(RestServer); + const handler = await server.get(RestBindings.HANDLER); + // Use a hack to verify the bound router is used by the handler + // tslint:disable-next-line:no-any + expect((handler as any)._routes._router).to.be.instanceof(RegExpRouter); + }); + + it('matches routes based on their specifics', async () => { + const app = new RestApplication(); + + app.route( + 'get', + '/greet/{name}', + anOperationSpec() + .withParameter({name: 'name', in: 'path', type: 'string'}) + .build(), + (name: string) => `hello ${name}`, + ); + + app.route( + 'get', + '/greet/world', + anOperationSpec().build(), + () => 'HELLO WORLD', + ); + + await whenIMakeRequestTo(app) + .get('/greet/john') + .expect(200, 'hello john'); + await whenIMakeRequestTo(app) + .get('/greet/world') + .expect(200, 'HELLO WORLD'); + }); }); /* ===== HELPERS ===== */ diff --git a/packages/rest/test/unit/router/controller-route.unit.ts b/packages/rest/test/unit/router/controller-route.unit.ts index c2316b2229d6..93fc08628117 100644 --- a/packages/rest/test/unit/router/controller-route.unit.ts +++ b/packages/rest/test/unit/router/controller-route.unit.ts @@ -7,10 +7,10 @@ import { ControllerRoute, createControllerFactoryForClass, createControllerFactoryForBinding, + ControllerFactory, } from '../../..'; import {expect} from '@loopback/testlab'; import {anOperationSpec} from '@loopback/openapi-spec-builder'; -import {ControllerFactory} from '../../..'; describe('ControllerRoute', () => { it('rejects routes with no methodName', () => { diff --git a/packages/rest/test/unit/router/openapi-path.unit.ts b/packages/rest/test/unit/router/openapi-path.unit.ts new file mode 100644 index 000000000000..2b5d1e0a2dd5 --- /dev/null +++ b/packages/rest/test/unit/router/openapi-path.unit.ts @@ -0,0 +1,85 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/@loopback/rest. +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; + +import {validateApiPath} from '../../..'; + +describe('validateApiPath', () => { + const INVALID_PARAM = /Invalid path template: '.+'. Please use \{param\} instead of '\:param'/; + const NO_PARAM_MODIFIER = /Parameter modifier is not allowed in path/; + const INVALID_PARAM_NAME = /Invalid parameter name '.+' found in path/; + + it('allows /{foo}/bar', () => { + const path = validateApiPath('/{foo}/bar'); + expect(path).to.eql('/{foo}/bar'); + }); + + it('allows /{foo}/{bar}', () => { + const path = validateApiPath('/{foo}/{bar}'); + expect(path).to.eql('/{foo}/{bar}'); + }); + + it('allows /{_foo}/{bar}', () => { + const path = validateApiPath('/{_foo}/{bar}'); + expect(path).to.eql('/{_foo}/{bar}'); + }); + + it('disallows /:foo/bar', () => { + disallows('/:foo/bar'); + }); + + it('disallows /:foo/:bar', () => { + disallows('/:foo/:bar'); + }); + + it('disallows /:foo+', () => { + disallows('/:foo+'); + }); + + it('disallows /:foo?', () => { + disallows('/:foo?'); + }); + + it('disallows /:foo*', () => { + disallows('/:foo*'); + }); + + it('disallows /:foo(\\d+)', () => { + disallows('/:foo(\\d+)'); + }); + + it('disallows /foo/(.*)', () => { + disallows('/foo/(.*)'); + }); + + it('disallows /{foo}+', () => { + disallows('/{foo}+', NO_PARAM_MODIFIER); + }); + + it('disallows /{foo}?', () => { + disallows('/{foo}?', NO_PARAM_MODIFIER); + }); + + it('disallows /{foo}*', () => { + disallows('/{foo}*', NO_PARAM_MODIFIER); + }); + + it('disallows /{foo}(\\d+)', () => { + disallows('/{foo}(\\d+)'); + }); + + it('disallows /{$123}', () => { + disallows('/{$123}', INVALID_PARAM_NAME); + }); + + it('disallows /{%abc}', () => { + disallows('/{%abc}', INVALID_PARAM_NAME); + }); + + function disallows(path: string, pattern?: RegExp) { + expect(() => validateApiPath(path)).to.throw(pattern || INVALID_PARAM); + } +}); diff --git a/packages/rest/test/unit/router/route-sort.unit.ts b/packages/rest/test/unit/router/route-sort.unit.ts new file mode 100644 index 000000000000..cdec36428d46 --- /dev/null +++ b/packages/rest/test/unit/router/route-sort.unit.ts @@ -0,0 +1,74 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/@loopback/rest. +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {compareRoute} from '../../..'; + +describe('route sorter', () => { + it('sorts routes', () => { + const routes = givenRoutes(); + // Sort endpoints by their verb/path templates + const sortedEndpoints = Object.entries(routes).sort((a, b) => + compareRoute(a[1], b[1]), + ); + expect(sortedEndpoints).to.eql([ + ['create', {verb: 'post', path: '/orders'}], + ['replaceById', {verb: 'put', path: '/orders/{id}'}], + ['updateById', {verb: 'patch', path: '/orders/{id}'}], + ['updateAll', {verb: 'patch', path: '/orders'}], + ['exists', {verb: 'get', path: '/orders/{id}/exists'}], + ['count', {verb: 'get', path: '/orders/count'}], + ['findById', {verb: 'get', path: '/orders/{id}'}], + ['findAll', {verb: 'get', path: '/orders'}], + ['deleteById', {verb: 'delete', path: '/orders/{id}'}], + ['deleteAll', {verb: 'delete', path: '/orders'}], + ]); + }); + + function givenRoutes() { + return { + create: { + verb: 'post', + path: '/orders', + }, + findAll: { + verb: 'get', + path: '/orders', + }, + findById: { + verb: 'get', + path: '/orders/{id}', + }, + updateById: { + verb: 'patch', + path: '/orders/{id}', + }, + replaceById: { + verb: 'put', + path: '/orders/{id}', + }, + count: { + verb: 'get', + path: '/orders/count', + }, + exists: { + verb: 'get', + path: '/orders/{id}/exists', + }, + deleteById: { + verb: 'delete', + path: '/orders/{id}', + }, + deleteAll: { + verb: 'delete', + path: '/orders', + }, + updateAll: { + verb: 'patch', + path: '/orders', + }, + }; + } +}); diff --git a/packages/rest/test/unit/router/routing-table.unit.ts b/packages/rest/test/unit/router/routing-table.unit.ts index 2d38f331a2af..65cd54df66d7 100644 --- a/packages/rest/test/unit/router/routing-table.unit.ts +++ b/packages/rest/test/unit/router/routing-table.unit.ts @@ -10,7 +10,14 @@ import { expect, stubExpressContext, } from '@loopback/testlab'; -import {ControllerRoute, Request, RoutingTable} from '../../..'; +import { + ControllerRoute, + Request, + RoutingTable, + RestRouter, + RegExpRouter, + TrieRouter, +} from '../../..'; describe('RoutingTable', () => { it('joins basePath and path', () => { @@ -29,7 +36,17 @@ describe('RoutingTable', () => { '/root/x/a/b/c', ); }); +}); + +describe('RoutingTable with RegExpRouter', () => { + runTestsWithRouter(new RegExpRouter()); +}); + +describe('RoutingTable with TrieRouter', () => { + runTestsWithRouter(new TrieRouter()); +}); +function runTestsWithRouter(router: RestRouter) { it('does not fail if some of the parameters are not decorated', () => { class TestController { @get('/greet') @@ -38,7 +55,7 @@ describe('RoutingTable', () => { } } const spec = getControllerSpec(TestController); - const table = new RoutingTable(); + const table = givenRoutingTable(); table.registerController(spec, TestController); const paths = table.describeApiPaths(); const params = paths['/greet']['get'].parameters; @@ -57,7 +74,7 @@ describe('RoutingTable', () => { class TestController {} - const table = new RoutingTable(); + const table = givenRoutingTable(); table.registerController(spec, TestController); const request = givenRequest({ @@ -88,7 +105,7 @@ describe('RoutingTable', () => { class TestController {} - const table = new RoutingTable(); + const table = givenRoutingTable(); table.registerController(spec, TestController); const request = givenRequest({ @@ -106,7 +123,75 @@ describe('RoutingTable', () => { expect(route.describe()).to.equal('TestController.greet'); }); + it('finds simple "GET /hello/world" endpoint', () => { + const spec = anOpenApiSpec() + .withOperationReturningString('get', '/hello/{msg}', 'greet') + .withOperationReturningString('get', '/hello/world', 'greetWorld') + .build(); + + class TestController {} + + const table = givenRoutingTable(); + table.registerController(spec, TestController); + + const request = givenRequest({ + method: 'get', + url: '/hello/world', + }); + + const route = table.find(request); + expect(route) + .to.have.property('spec') + .containEql(spec.paths['/hello/world'].get); + expect(route).to.have.property('pathParams', {}); + expect(route.describe()).to.equal('TestController.greetWorld'); + }); + + it('finds simple "GET /add/{arg1}/{arg2}" endpoint', () => { + const spec = anOpenApiSpec() + .withOperationReturningString('get', '/add/{arg1}/{arg2}', 'add') + .withOperationReturningString( + 'get', + '/subtract/{arg1}/{arg2}', + 'subtract', + ) + .build(); + + // @jannyHou: please note ` anOpenApiSpec()` returns an openapi spec, + // not controller spec, should be FIXED + // the routing table test expects an empty spec for + // interface `ControllerSpec` + spec.basePath = '/my'; + + class TestController {} + + const table = givenRoutingTable(); + table.registerController(spec, TestController); + + let request = givenRequest({ + method: 'get', + url: '/my/add/1/2', + }); + + let route = table.find(request); + expect(route.path).to.eql('/my/add/{arg1}/{arg2}'); + expect(route.pathParams).to.containEql({arg1: '1', arg2: '2'}); + + request = givenRequest({ + method: 'get', + url: '/my/subtract/3/2', + }); + + route = table.find(request); + expect(route.path).to.eql('/my/subtract/{arg1}/{arg2}'); + expect(route.pathParams).to.containEql({arg1: '3', arg2: '2'}); + }); + function givenRequest(options?: ShotRequestOptions): Request { return stubExpressContext(options).request; } -}); + + function givenRoutingTable() { + return new RoutingTable(router); + } +} diff --git a/packages/rest/test/unit/router/trie-router.unit.ts b/packages/rest/test/unit/router/trie-router.unit.ts new file mode 100644 index 000000000000..f881654028e6 --- /dev/null +++ b/packages/rest/test/unit/router/trie-router.unit.ts @@ -0,0 +1,97 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/@loopback/rest. +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {TrieRouter, RouteEntry} from '../../..'; +import {anOperationSpec} from '@loopback/openapi-spec-builder'; + +describe('trie router', () => { + class TestTrieRouter extends TrieRouter { + get staticRoutes() { + return Object.values(this.routesWithoutPathVars); + } + } + + const getVerbAndPath = (r: RouteEntry) => ({verb: r.verb, path: r.path}); + + it('adds routes to routesWithoutPathVars', () => { + const router = givenTrieRouter(); + const staticRoutes = router.staticRoutes.map(getVerbAndPath); + + for (const r of [ + {verb: 'post', path: '/orders'}, + {verb: 'patch', path: '/orders'}, + {verb: 'get', path: '/orders/count'}, + {verb: 'get', path: '/orders'}, + {verb: 'delete', path: '/orders'}, + ]) { + expect(staticRoutes).to.containEql(r); + } + + for (const r of [ + {verb: 'put', path: '/orders/{id}'}, + {verb: 'patch', path: '/orders/{id}'}, + {verb: 'get', path: '/orders/{id}/exists'}, + {verb: 'get', path: '/orders/{id}'}, + {verb: 'delete', path: '/orders/{id}'}, + ]) { + expect(staticRoutes).to.not.containEql(r); + } + }); + + it('list routes by order', () => { + const router = givenTrieRouter(); + + expect(router.list().map(getVerbAndPath)).to.eql([ + {verb: 'post', path: '/orders'}, + {verb: 'put', path: '/orders/{id}'}, + {verb: 'patch', path: '/orders/{id}'}, + {verb: 'patch', path: '/orders'}, + {verb: 'get', path: '/orders/{id}/exists'}, + {verb: 'get', path: '/orders/count'}, + {verb: 'get', path: '/orders/{id}'}, + {verb: 'get', path: '/orders'}, + {verb: 'delete', path: '/orders/{id}'}, + {verb: 'delete', path: '/orders'}, + ]); + }); + + function givenTrieRouter() { + const router = new TestTrieRouter(); + for (const r of givenRoutes()) { + router.add(r); + } + return router; + } + + function givenRoutes() { + const routes: RouteEntry[] = []; + function addRoute(op: string, verb: string, path: string) { + routes.push({ + verb, + path, + spec: anOperationSpec() + .withOperationName(op) + .build(), + updateBindings: () => {}, + invokeHandler: async () => {}, + describe: () => op, + }); + } + + addRoute('create', 'post', '/orders'); + addRoute('findAll', 'get', '/orders'); + addRoute('findById', 'get', '/orders/{id}'); + addRoute('updateById', 'patch', '/orders/{id}'); + addRoute('replaceById', 'put', '/orders/{id}'); + addRoute('count', 'get', '/orders/count'); + addRoute('exists', 'get', '/orders/{id}/exists'); + addRoute('deleteById', 'delete', '/orders/{id}'); + addRoute('deleteAll', 'delete', '/orders'); + addRoute('updateAll', 'patch', '/orders'); + + return routes; + } +}); diff --git a/packages/rest/test/unit/router/trie.unit.ts b/packages/rest/test/unit/router/trie.unit.ts new file mode 100644 index 000000000000..06166f457f6c --- /dev/null +++ b/packages/rest/test/unit/router/trie.unit.ts @@ -0,0 +1,156 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Trie} from '../../..'; +import {expect} from '@loopback/testlab'; + +interface Route { + path: string; + verb: string; +} + +describe('Trie', () => { + it('creates nodes', () => { + const trie = givenTrie(); + const getOrders = givenRoute('get', '/orders'); + trie.create('get/orders', getOrders); + expect(trie.root).to.containEql({ + key: '', + children: { + get: { + key: 'get', + children: { + orders: { + key: 'orders', + value: getOrders, + children: {}, + }, + }, + }, + }, + }); + }); + + it('creates nodes with overlapping keys', () => { + const trie = givenTrie(); + const getOrders = givenRoute('get', '/orders'); + const getOrderById = givenRoute('get', '/orders/{id}'); + trie.create('get/orders', getOrders); + trie.create('get/orders/{id}', getOrderById); + expect(trie.root).to.containEql({ + key: '', + children: { + get: { + key: 'get', + children: { + orders: { + key: 'orders', + value: getOrders, + children: { + '{id}': { + key: '{id}', + value: getOrderById, + names: ['id'], + regexp: /^(.+)$/, + children: {}, + }, + }, + }, + }, + }, + }, + }); + }); + + it('reports error for conflicting nodes', () => { + const trie = givenTrie(); + const getOrders = givenRoute('get', '/orders'); + trie.create('get/orders', getOrders); + expect(() => + trie.create('get/orders', givenRoute('post', '/orders')), + ).to.throw(/Duplicate key found with different value/); + }); + + it('lists nodes with values', () => { + const trie = givenTrie(); + const getOrders = givenRoute('get', '/orders'); + const getOrderById = givenRoute('get', '/orders/{id}'); + trie.create('get/orders', getOrders); + trie.create('get/orders/{id}', getOrderById); + const nodes = trie.list(); + expect(nodes).to.containDeepOrdered([ + { + key: 'orders', + value: {verb: 'get', path: '/orders'}, + }, + { + key: '{id}', + value: {verb: 'get', path: '/orders/{id}'}, + names: ['id'], + regexp: /^(.+)$/, + }, + ]); + }); + + it('skips nodes without values', () => { + const trie = givenTrie(); + const getOrderById = givenRoute('get', '/orders/{id}'); + trie.create('get/orders/{id}', getOrderById); + const nodes = trie.list(); + expect(nodes).to.eql([ + { + key: '{id}', + value: {verb: 'get', path: '/orders/{id}'}, + names: ['id'], + regexp: /^(.+)$/, + children: {}, + }, + ]); + }); + + it('matches nodes by keys', () => { + const trie = givenTrie(); + const getOrders = givenRoute('get', '/orders'); + const createOrders = givenRoute('post', '/orders'); + const getOrderById = givenRoute('get', '/orders/{id}'); + const getOrderCount = givenRoute('get', '/orders/count'); + trie.create('get/orders', getOrders); + trie.create('get/orders/{id}', getOrderById); + trie.create('get/orders/count', getOrderCount); + trie.create('post/orders', createOrders); + expectMatch(trie, 'get/orders', getOrders); + expectMatch(trie, '/get/orders/123', getOrderById, {id: '123'}); + expectMatch(trie, 'get/orders/count', getOrderCount); + expectMatch(trie, 'post/orders', createOrders); + }); + + it('matches nodes by params', () => { + const trie = givenTrie(); + const getUsersByName = givenRoute('get', '/users/{username}'); + trie.create('get/users/{username}', getUsersByName); + expectMatch(trie, 'get/users/admin', getUsersByName, {username: 'admin'}); + }); + + function expectMatch( + trie: Trie, + route: string, + value: T, + params?: object, + ) { + const resolved = trie.match(route); + expect(resolved).not.is.undefined(); + expect(resolved!.node).to.containEql({value}); + params = params || {}; + expect(resolved!.params).to.eql(params); + } + + function givenTrie() { + return new Trie(); + } + + function givenRoute(verb: string, path: string) { + return {verb, path}; + } +});