Skip to content

snytkine/holiday_router

Repository files navigation

Build Status codecov Known Vulnerabilities Maintainability License: MIT

Holiday Router

Advanced URI Routing library for matching URI to controller

  • Open Source under the MIT License

  • Written in Typescript. Strong typing and interface based design makes it very flexible for developers to implement own controller classes.

  • Ideal for being used as the URI and/or Http routing module in custom frameworks written in TypeScript

  • Over 150 unit tests covering 100% of codebase and many extra real-life use-cases are covered

  • Using industry standard eslint rules for typescript makes use of best practices for writing clean tyescript code

Features

  • Named uri parameters /catalog/{category}/{subcategory/
  • Catchall routes with support for named catchall parameter /images/**imagepath or just /images/** for anonymous catchall param
  • Support for prefix and postfix in uri segments /catalog/category-{category}.html In this example the category- is a prefix and .html and postfix and category will be extrafted from url
  • Support for regex matches in uri segments /cagegory/{categoryid:[0-9]+}}/info In this case the named parameter categoryid must be all numeric or it will not produce a match
  • Regex segments in match are also extracted and added to RouteMatch (return object from uri match)
  • Named routes for reverse route generation.
  • Multiple routes per uri match. This way a developer may have their own custom logic in addition to just matching the url. For example there could be a requirement to pick different controller for the same URI based on value of the request header, or presense of query parameters, or time of day, or anything else. When route is matched it always returns array of objects that implemnt IControllr interface If this feature is not required then just add a single object per URI ane a match will return array with one element Also there is a convenience classes for creating instances of IControllerContainer.
  • Compact tree structure for storing routes makes it very memory-efficient and fast.
  • Convenience class HttpRouter is a wrapper class than added support for adding routes specific to http request methods. Basically HttpRouter holds a Map<httpMethod, Router> and matches the http method first and if found delegates uri resolution to a router object for that method.

How it works

Here is a break-down of how the routing information is stored when we add 5 routes to the router. Router breaks up the URI into uri segments. Segment is a part of the URI that ends with path separator '/'

  1. /catalog/category/{categoryID}/item-{widget:[0-9]+}/info
  2. /catalog/category/shoes/{brand}
  3. /catalog/category/shoes/{brand}/{size}
  4. /customers/orders/{orderID:[0-9]+}
  5. /customers/customer-{customerID:[0-9]+}/info
Path            Node                                                    NodeType
--------------------------------------------------------------------------------------
|- /                                                                    ExactMatchNode
|  |- catalog/                                                          ExactMatchNode
|            |- category/                                               ExactMatchNode
|                       |- shoes/                                       ExactMatchNode
|                               |- {brand}                              PathParamNode
|                               |- {brand}/                             PathParamNode
|                                         |- {size}                     PathParamNode
|                       |- {categoryID}                                 PathParamNode
|                                     |- item-{widget:[0-9]+}/          RegexNode
|                                                            |- info    ExactMatchNode
|  |- customers/                                                        ExactMatchNode                   
|              |- customer-{customerID:[0-9]+}/                         RegexNode
|                                            |- info                    ExactMatchNode
|              |- orders/                                               ExactMatchNode
|                       |- {orderID:[0-9]+}                             RegexNode

Installation

Install using npm:

npm install holiday-router

API Reference

Interfaces

IControllerContainer

Developer must implement own class that implements an IControllerContainer interface or use one of 2 helper Classes: BasicController or UniqueController

interface IControllerContainer {
  /**
   * Controller must implement its own logic
   * of how it determines if another controller is functionally equal
   * to this controller.
   *
   * The purpose of calling equals(other) method is to prevent
   * having 2 controller that can respond to same uri.
   *
   * @param other
   */
  equals(other: IControllerContainer): boolean;

  /**
   * Multiple controller may exist in the same node, meaning
   * that more than one controller can match same uri
   * it's up to consuming program to iterate over results and
   * find the best match.
   * a controller with higher priority will be returned first from
   * controller iterator.
   * In general if multiple controllers can be used for same URI, a controller
   * will also have some sort of filter function that will accept one or more params
   * from consuming application to determine if controller is a match
   * a controller with a more specific filter should have higher priority
   *
   * For example one controller may require that request have a specific header
   * and another controller will serve every other request. The controller that requires
   * a specific header should be tested first, otherwise the second more general controller
   * will always match. For this reason the first controller must have higher priority
   */
  priority: number;

  /**
   * Identifier for a controller. It does not have to be unique
   * it is used primarily for logging and debugging, a way to add a name to controller.
   */
  id: string;

  /**
   * Used for logging and debugging
   */
  toString(): string;
}

IRouteMatch

interface IRouteMatch<T extends IControllerContainer> {
  params: IUriParams;
  node: Node<T>;
}

IRouteMatchResult

type IRouteMatchResult<T extends IControllerContainer> = undefined | IRouteMatch<T>;

IUriParams

interface IUriParams {
  pathParams: Array<IExtractedPathParam>;
  regexParams?: Array<IRegexParams>;
}

IExtractedPathParam

interface IExtractedPathParam {
  paramName: string;
  paramValue: string;
}

IRegexParams

interface IRegexParams {
  paramName: string;
  params: Array<string>;
}

IStringMap

interface IStringMap {
  [key: string]: string;
}

IRouteInfo

interface IRouteInfo {
  uri: string;
  controller: IControllerContainer;
}

IHttpRouteInfo

interface IHttpRouteInfo extends IRouteInfo {
  method: string;
}

Node<T extends IControllerContainer>

interface Node<T extends IControllerContainer> {
  type: string;

  priority: number;

  name: string;

  controllers?: Array<T>;

  /**
   * Original uri template that was used in addController method call
   * This way a full uri template can be recreated by following parent nodes.
   */
  uriTemplate: string;

  paramName: string;

  equals(other: Node<T>): boolean;

  getRouteMatch(uri: string, params?: IUriParams): IRouteMatchResult<T>;

  addChildNode(node: Node<T>): Node<T>;

  addController(controller: T): Node<T>;

  getAllRoutes(): Array<IRouteMatch<T>>;

  getRouteMatchByControllerId(id: string): IRouteMatchResult<T>;

  makeUri(params: IStringMap): string;

  children: Array<Node<T>>;

  /**
   * Having the property of type Symbol is an easy way
   * to exclude it from JSON.stringify
   * The parent node cannot be included in JSON because it
   * will create recursion error
   */
  [Symbol.for('HOLIDAY-ROUTER:PARENT_NODE')]?: Node<T>;
}

Errors

RouterError

class RouterError extends Error {
  constructor(public message: string, public code: RouterErrorCode) {
    super(message);
  }
}

new RouterError(message: string, code: RouterErrorCode)


RouterErrorCode

enum RouterErrorCode {
  ADD_CHILD = 1000000,
  ADD_CHILD_CATCHALL,
  DUPLICATE_CONTROLLER,
  INVALID_REGEX,
  MAKE_URI_MISSING_PARAM,
  MAKE_URI_REGEX_FAIL,
  CREATE_NODE_FAILED,
  NON_UNIQUE_PARAM,
  CONTROLLER_NOT_FOUND,
  UNSUPPORTED_HTTP_METHOD,
}

Classes

Router

new Router()

Creates a new instance of Router.

Example

import { Router } from 'holiday-router';

const router = new Router();

.addRoute(uri: string, controller: T): Node<T>

Adds route to router.

param type description
uri string uri with supported uri template syntax
controller IControllerContainer Controller is an object that must implement IControllerContainer interface

Example In this example we adding uri template that will match any uri that looks like /catalog/category/somecategory/widget-34/info

import { Router, BasicController } from 'holiday-router'; 

const router: Router = new Router();
router.addRoute('/catalog/category/{categoryID}/item-{widget:[0-9]+}/info', new BasicController('somecontroller', 'ctrl1'));

Notice that

  • First 2 uri segments must be matched exactly but third and fourth uri segments are placeholder segments.
  • Third segment can match any string and that string will then be available in the RouteMatch object when .getRouteMatch() is called with the uri
  • Fourth segment has a prefix widget- and the placeholder is a Regular Expression based param it must match the regex [0-9]+ (must be numeric value)

.getRouteMatch(uri: string): IRouteMatchResult<T>

Matches the URI and returns RouteMatch or undefined in no match found.

param type description
uri string a full uri path. uri is case-sensitive

Example In this example we going to add a route and then will get the matching object for the url: /catalog/category/toys/widget-34/info

import { Router, BasicController } from 'holiday-router'; 

const router: Router = new Router();
router.addRoute('/catalog/category/{categoryID}/widget-{widget:([0-9]+)-(blue|red)}/info', new BasicController('somecontroller', 'ctrl1'));
const routeMatch = router.getRouteMatch('/catalog/category/toys/widget-34-blue/info');

We will get back the result object RouteMatch (it implements IRouteMatchResult) The object will have the following structure:

{
  "params": {
    "pathParams": [
      {
        "paramName": "categoryID",
        "paramValue": "toys"
      },
      {
        "paramName": "widget",
        "paramValue": "34-blue"
      }
    ],
    "regexParams": [
      {
        "paramName": "widget",
        "params": [
          "34-blue",
          "34",
          "blue"
        ]
      }
    ]
  },
  "node": {
    "paramName": "",
    "uri": "",
    "basePriority": 100,
    "uriPattern": "",
    "children": [],
    "origUriPattern": "info",
    "segmentLength": 4,
    "controllers": [
      {
        "priority": 1,
        "controller": "somecontroller",
        "id": "ctrl1"
      }
    ]
  }
}

Notice the RouteMatch has 2 properties:

  • params which contains extracted pathParam and regexParams
  • node which contains .controllers array with our controller

Notice that regexParams contains array of values extracted from regex route match. The first element in array of regex matches is always the entire match, in this case it's "34-blue", second element is specific match of capturing groups in our regex: "34" from capturing group ([0-9]+) and "blue" from capturing group (blue|red)

    "regexParams": [
      {
        "paramName": "widget",
        "params": [
          "34-blue",
          "34",
          "blue"
        ]
      }
    ]

.makeUri(controllerId: string, params: IStringMap = {}): string

Generates URI for route. Replaces placeholders in URI template with values provided in params argument.

param type description
controllerId string value of .id of Controller (implements IControllerContainer ) for the route
params IStringMap Object with keys matching placeholders in URI template for the route and value to be used in place of placeholders

Throws RouterError with RouterErrorCode = RouterErrorCode.CONTROLLER_NOT_FOUND if controller not found by controllerId.

Throws RouterError with RouterErrorCode = RouterErrorCode.MAKE_URI_MISSING_PARAM if params object does not have a key matching any of paramNames in URI template for the route.

Throws RouterError with RouterErrorCode = RouterErrorCode.MAKE_URI_REGEX_FAIL if value of param in params object does not match Regex in regex segment in uri template.

Example In this example we going to add a route and then call makeUri method to generate URI for the route:

import { Router, BasicController } from 'holiday-router'; 

const router = new Router();
router.addRoute('/catalog/category/{categoryID}/widget-{widget:([0-9]+)-(blue|red)}/info', new BasicController('somecontroller', 'ctrl1'));
const uri = router.makeUri('ctrl1', {"categoryId":"toys", "widget":"24-blue"});

The value of uri in this example will be /catalog/category/toys/widget-24-blue/info


.getAllRoutes(): Array<IRouteInfo>

Example:

import { Router, BasicController, IRouteInfo } from 'holiday-router';
const uri1 = '/catalog/toys/';
const uri2 = '/catalog/toys/cars/{make}/{model}';
const uri3 = '/catalog/toys/cars/{make}/mymodel-{model-x}-item/id-{id}.html';
const uri4 = '/catalog/toys/cars/{id:widget-([0-9]+)(green|red)}/{year:([0-9]{4})}';
const uri5 = '/catalog/toys/cars/{make}/mymodel-{model-x}';

const ctrl1 = new BasicController('CTRL-1', 'ctrl1');
const ctrl2 = new BasicController('CTRL-2', 'ctrl2');
const ctrl3 = new BasicController('CTRL-3', 'ctrl3');
const ctrl4 = new BasicController('CTRL-4', 'ctrl4');
const ctrl5 = new BasicController('CTRL-5', 'ctrl5');
const ctrl6 = new BasicController('CTRL-6', 'ctrl6');
const router = new Router();
router.addRoute(uri1, ctrl1);
router.addRoute(uri2, ctrl2);
router.addRoute(uri3, ctrl3);
router.addRoute(uri4, ctrl4);
router.addRoute(uri5, ctrl5);
router.addRoute(uri2, ctrl6);

const res: Array<IRouteInfo> = router.getAllRoutes();

The value of res in this example will be

[
  {
    "uri": "/catalog/toys/",
    "controller": {
      "priority": 1,
      "controller": "CTRL-1",
      "id": "ctrl1"
    }
  },
  {
    "uri": "/catalog/toys/cars/{id:widget-([0-9]+)(green|red)}/{year:([0-9]{4})}",
    "controller": {
      "priority": 1,
      "controller": "CTRL-4",
      "id": "ctrl4"
    }
  },
  {
    "uri": "/catalog/toys/cars/{make}/mymodel-{model-x}-item/id-{id}.html",
    "controller": {
      "priority": 1,
      "controller": "CTRL-3",
      "id": "ctrl3"
    }
  },
  {
    "uri": "/catalog/toys/cars/{make}/mymodel-{model-x}",
    "controller": {
      "priority": 1,
      "controller": "CTRL-5",
      "id": "ctrl5"
    }
  },
  {
    "uri": "/catalog/toys/cars/{make}/{model}",
    "controller": {
      "priority": 1,
      "controller": "CTRL-2",
      "id": "ctrl2"
    }
  },
  {
    "uri": "/catalog/toys/cars/{make}/{model}",
    "controller": {
      "priority": 1,
      "controller": "CTRL-6",
      "id": "ctrl6"
    }
  }
]

HttpRouter

HttpRouter is a convenience wrapper class that internally holds map of httpMethod -> Router each method has own instance of Router object. Only methods supported by Node.js (included in array or Node.js http.METHODS) or by 'methods' npm module are supported Only methods that were added to the instance of HttpRouter with addRoute are added to the map of method -> router In other words if addRoute was used to only add GET and POST methods then the internal map method -> router will have only 2 elements.

IMPORTANT - when adding route with addRoute method the first parameter httpMethod is converted to upper case and used as key in map of method -> router as upper case string but the .getRouteMatch method does not convert the first parameter 'httpMethod' to upper case so you must make sure when you call .getRouteMatch that you pass the first argument in upper case. This is done for performance reasons since Node.js already give value of method in upper case, so we don't need to call .toUpperCase every time the .getRouteMatch is called

new Router()

Creates a new instance of Router.

Example

import { HttpRouter } from 'holiday-router';
import HTTPMethod from 'http-method-enum';

const router: HttpRouter = new HttpRouter();

.addRoute(httpMethod: HTTPMethod, uri: string, controller: T): Node<T>

Adds route to router.

param type description
httpMethod HTTPMethod Uses HTTPMethod enum from http-method-enum npm package
uri string uri with supported uri template syntax
controller IControllerContainer Controller is an object that must implement IControllerContainer interface

Throws RouterError with RouterErrorCode = RouterErrorCode.UNSUPPORTED_HTTP_METHOD if httpMethod not supported by version of Node.js (if used with Node.js) or not in list of method from 'methods' npm module


.getRouteMatch(httpMethod: HTTPMethod, uri: string): IRouteMatchResult<T>

Matches the http request method and URI and returns RouteMatch or undefined in no match found.

param type description
httpMethod HTTPMethod Http Request method. uses HTTPMethod enum from http-method-enum npm package
uri string a full uri path. uri is case-sensitive

Example In this example we going to add a route for the http 'GET' method and then will get the matching object for the 'GET' method and url: /catalog/category/toys/widget-34/info

import { HttpRouter, BasicController } from 'holiday-router'; 
import HTTPMethod from 'http-method-enum';

const router: HttpRouter = new HttpRouter();
router.addRoute(HTTPMethod.GET, '/catalog/category/{categoryID}/item-{widget:([0-9]+)-(blue|red)}/info', new BasicController('somecontroller', 'ctrl1'));
const routeMatch = router.getRouteMatch(HTTPMethod.GET, '/catalog/category/toys/item-34-blue/info');

.makeUri(httpMethod: string, controllerId: string, params: IStringMap = {}): string

Generates URI for route. Replaces placeholders in URI template with values provided in params argument.

param type description
httpMethod HTTPMethod uses emum from http-method-enum npm package
controllerId string value of .id of Controller (implements IControllerContainer ) for the route
params IStringMap Object with keys matching placeholders in URI template for the route and value to be used in place of placeholders

Throws RouterError with RouterErrorCode = RouterErrorCode.UNSUPPORTED_HTTP_METHOD if httpMethod not supported by version of Node.js (if used with Node.js) or not in list of method from 'methods' npm module

Throws RouterError with RouterErrorCode = RouterErrorCode.CONTROLLER_NOT_FOUND if controller not found by controllerId.

Throws RouterError with RouterErrorCode = RouterErrorCode.MAKE_URI_MISSING_PARAM if params object does not have a key matching any of paramNames in URI template for the route.

Throws RouterError with RouterErrorCode = RouterErrorCode.MAKE_URI_REGEX_FAIL if value of param in params object does not match Regex in regex segment in uri template.

Example In this example we going to add a route and then call makeUri method to generate URI for the route:

import { HttpRouter, BasicController } from 'holiday-router'; 
import HTTPMethod from 'http-method-enum';

const router: HttpRouter = new HttpRouter();
router.addRoute(HTTPMethod.GET, '/catalog/category/{categoryID}/item-{widget:([0-9]+)-(blue|red)}/info', new BasicController('somecontroller', 'ctrl1'));
const uri = router.makeUri(HTTPMethod.GET, 'ctrl1', {"categoryId":"toys", "widget":"24-blue"});

The value of uri in this example will be /catalog/category/toys/item-24-blue/info


.getAllRoutes(): Array<IHttpRouteInfo>

Example

import { HttpRouter, BasicController } from 'holiday-router';
import HTTPMethod from 'http-method-enum';

const uri1 = '/catalog/toys/';
const uri2 = '/catalog/toys/cars/{make}/{model}';

const ctrl1 = new BasicController('CTRL-1', 'ctrl1');
const ctrl2 = new BasicController('CTRL-2', 'ctrl2');
const ctrl3 = new BasicController('CTRL-3', 'ctrl3');
const ctrl4 = new BasicController('CTRL-4', 'ctrl4');
const ctrl5 = new BasicController('CTRL-5', 'ctrl5');
const ctrl6 = new BasicController('CTRL-6', 'ctrl6');

const httpRouter = new HttpRouter();
httpRouter.addRoute(HTTPMethod.GET, uri1, ctrl1);
httpRouter.addRoute(HTTPMethod.GET, uri2, ctrl2);
httpRouter.addRoute(HTTPMethod.POST, uri1, ctrl3);
httpRouter.addRoute(HTTPMethod.POST, uri2, ctrl4);
httpRouter.addRoute(HTTPMethod.POST, uri1, ctrl5);
httpRouter.addRoute(HTTPMethod.POST, uri2, ctrl6);

const allRoutes = httpRouter.getAllRoutes();

the value of allRoutes in this example will be

[
  {
    "uri": "/catalog/toys/",
    "controller": {
      "priority": 1,
      "controller": "CTRL-1",
      "id": "ctrl1"
    },
    "method": "GET"
  },
  {
    "uri": "/catalog/toys/cars/{make}/{model}",
    "controller": {
      "priority": 1,
      "controller": "CTRL-2",
      "id": "ctrl2"
    },
    "method": "GET"
  },
  {
    "uri": "/catalog/toys/",
    "controller": {
      "priority": 1,
      "controller": "CTRL-3",
      "id": "ctrl3"
    },
    "method": "POST"
  },
  {
    "uri": "/catalog/toys/",
    "controller": {
      "priority": 1,
      "controller": "CTRL-5",
      "id": "ctrl5"
    },
    "method": "POST"
  },
  {
    "uri": "/catalog/toys/cars/{make}/{model}",
    "controller": {
      "priority": 1,
      "controller": "CTRL-4",
      "id": "ctrl4"
    },
    "method": "POST"
  },
  {
    "uri": "/catalog/toys/cars/{make}/{model}",
    "controller": {
      "priority": 1,
      "controller": "CTRL-6",
      "id": "ctrl6"
    },
    "method": "POST"
  }
]

About

Simple Router for matching URI to controller

Resources

License

Stars

Watchers

Forks

Packages

No packages published