- 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.
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 '/'
- /catalog/category/{categoryID}/item-{widget:[0-9]+}/info
- /catalog/category/shoes/{brand}
- /catalog/category/shoes/{brand}/{size}
- /customers/orders/{orderID:[0-9]+}
- /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
Install using npm:
npm install holiday-router
-
Errors
-
Enums
-
Classes
- Router
- new Router<T extends IControllerContainer>()
- instance methods
- .addRoute(uri: string, controller: T) :
Node<T>
- .getRouteMatch(uri: string):
IRouteMatchResult<T extends IControllerContainer>
- .makeUri(controllerId: string, params: IStringMap = {}):
string
- .getAllRoutes():
Array<IRouteInfo>
- .addRoute(uri: string, controller: T) :
- HttpRouter
- new HttpRouter<T extends IControllerContainer>()
- instance methods
- .addRoute(httpMethod: HTTPMethod, uri: string, controller: T) :
Node<T>
- .getRouteMatch(httpMethod: HTTPMethod, uri: string):
undefined | IRouteMatch<T>
- .makeUri(httpMethod: HTTPMethod, controllerId: string, params?: IStringMap):
string
- .getAllRoutes():
Array<IHttpRouteInfo>
- .addRoute(httpMethod: HTTPMethod, uri: string, controller: T) :
- Router
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;
}
interface IRouteMatch<T extends IControllerContainer> {
params: IUriParams;
node: Node<T>;
}
type IRouteMatchResult<T extends IControllerContainer> = undefined | IRouteMatch<T>;
interface IUriParams {
pathParams: Array<IExtractedPathParam>;
regexParams?: Array<IRegexParams>;
}
interface IExtractedPathParam {
paramName: string;
paramValue: string;
}
interface IRegexParams {
paramName: string;
params: Array<string>;
}
interface IStringMap {
[key: string]: string;
}
interface IRouteInfo {
uri: string;
controller: IControllerContainer;
}
interface IHttpRouteInfo extends IRouteInfo {
method: string;
}
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>;
}
class RouterError extends Error {
constructor(public message: string, public code: RouterErrorCode) {
super(message);
}
}
new RouterError(message: string, code: 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,
}
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 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
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"
}
]