diff --git a/src/Route.ts b/src/Route.ts new file mode 100644 index 0000000..fd7bb03 --- /dev/null +++ b/src/Route.ts @@ -0,0 +1,53 @@ +import { IRoute, PathParams, ProcessorOrProcessors } from './interfaces'; +import Router from './Router'; + +export default class Route implements IRoute { + + protected _router: Router; + + public constructor(path: PathParams, parentRouter: Router) { + this._router = new Router(parentRouter.routerOptions); + parentRouter.addSubRouter(path, this._router); + } + + public all(...handlers: ProcessorOrProcessors[]): this { + this._router.all('/', ...handlers); + return this; + } + + public head(...handlers: ProcessorOrProcessors[]): this { + this._router.head('/', ...handlers); + return this; + } + + public get(...handlers: ProcessorOrProcessors[]): this { + this._router.get('/', ...handlers); + return this; + } + + public post(...handlers: ProcessorOrProcessors[]): this { + this._router.post('/', ...handlers); + return this; + } + + public put(...handlers: ProcessorOrProcessors[]): this { + this._router.put('/', ...handlers); + return this; + } + + public delete(...handlers: ProcessorOrProcessors[]): this { + this._router.delete('/', ...handlers); + return this; + } + + public patch(...handlers: ProcessorOrProcessors[]): this { + this._router.patch('/', ...handlers); + return this; + } + + public options(...handlers: ProcessorOrProcessors[]): this { + this._router.options('/', ...handlers); + return this; + } + +} diff --git a/src/Router.ts b/src/Router.ts index dba41fa..0531e1b 100644 --- a/src/Router.ts +++ b/src/Router.ts @@ -7,6 +7,7 @@ import { RouterOptions, NextCallback, ErrorHandlingRequestProcessor, + IRoute, } from './interfaces'; import { IRequestMatchingProcessorChain } from './chains/ProcessorChain'; import { Request, Response } from '.'; @@ -14,6 +15,7 @@ import { wrapRequestProcessor, wrapRequestProcessors } from './utils/wrapRequest import { RouteMatchingProcessorChain } from './chains/RouteMatchingProcessorChain'; import { MatchAllRequestsProcessorChain } from './chains/MatchAllRequestsProcessorChain'; import { SubRouterProcessorChain } from './chains/SubRouterProcessorChain'; +import Route from './Route'; const DEFAULT_OPTS: RouterOptions = { caseSensitive: false, @@ -28,11 +30,9 @@ export default class Router implements IRouter { this.routerOptions = _.defaults(options, DEFAULT_OPTS); } - // TODO: do we need `router.route`? - // https://expressjs.com/en/guide/routing.html#app-route - // https://expressjs.com/en/4x/api.html#router.route - // If we do add it, we need to set the case-sensitivity of the sub-router it creates - // using the case-sensitivity setting of this router. + public route(prefix: PathParams): IRoute { + return new Route(prefix, this); + } public handle(originalErr: unknown, req: Request, resp: Response, done: NextCallback): void { const processors = this._processors; diff --git a/src/interfaces.ts b/src/interfaces.ts index f12cb80..e340216 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -170,4 +170,78 @@ export interface IRouter { */ handle(err: unknown, req: Request, resp: Response, done: NextCallback): void; + /** + * Returns an instance of a route-building helper class, which you can then use to + * handle HTTP verbs with optional middleware. Use app.route() to avoid duplicate route + * names (and thus typo errors). For example: + * + * ``` + * app.route('/hello') + * .all(function(req, res, next) { + * // Runs for all HTTP verbs + * }) + * .get(function(req, res, next) { + * res.json(...); + * }) + * .post(function(req, res, next) { + * // Handle POSTs to /hello + * }); + * ``` + */ + route(path: PathParams): IRoute; + +} + +export interface IRoute { + + /** + * Express-standard routing method for adding handlers that get invoked regardless of + * the request method (e.g. `OPTIONS`, `GET`, `POST`, etc) for a specific path (or set + * of paths). + */ + all: RouteProcessorAppender; + + /** + * Express-standard routing method for `HEAD` requests. + */ + head: RouteProcessorAppender; + + /** + * Express-standard routing method for `GET` requests. + */ + get: RouteProcessorAppender; + + /** + * Express-standard routing method for `POST` requests. + */ + post: RouteProcessorAppender; + + /** + * Express-standard routing method for `PUT` requests. + */ + put: RouteProcessorAppender; + + /** + * Express-standard routing method for `DELETE` requests. + */ + delete: RouteProcessorAppender; + + /** + * Express-standard routing method for `PATCH` requests. + */ + patch: RouteProcessorAppender; + + /** + * Express-standard routing method for `OPTIONS` requests. + */ + options: RouteProcessorAppender; + +} + +export interface RouteProcessorAppender { + + /** + * @param handlers the processors to mount to this route's path + */ + (...handlers: ProcessorOrProcessors[]): T; } diff --git a/tests/integration-tests.test.ts b/tests/integration-tests.test.ts index 3208dde..c597c87 100644 --- a/tests/integration-tests.test.ts +++ b/tests/integration-tests.test.ts @@ -3,7 +3,7 @@ import { apiGatewayRequest, handlerContext, albRequest, albMultiValHeadersReques import { spy, SinonSpy, assert } from 'sinon'; import { Application, Request, Response, Router } from '../src'; import { RequestEvent } from '../src/request-response-types'; -import { NextCallback } from '../src/interfaces'; +import { NextCallback, IRoute, IRouter } from '../src/interfaces'; import { expect } from 'chai'; import { StringArrayOfStringsMap, StringMap, KeyValueStringObject } from '../src/utils/common-types'; @@ -547,4 +547,90 @@ describe('integration tests', () => { }); + describe('building routes with router.route', () => { + + it('is chainable', () => { + let handler = (_req: Request, resp: Response): void => { resp.send('Test'); }, + getSpy = spy(handler), + postSpy = spy(handler), + putSpy = spy(handler); + + app.route('/test') + .get(getSpy) + .post(postSpy) + .put(putSpy); + + // Ensure that chained handlers were registered properly + + testOutcome('GET', '/test', 'Test'); + assert.calledOnce(getSpy); + assert.notCalled(postSpy); + assert.notCalled(putSpy); + getSpy.resetHistory(); + + testOutcome('POST', '/test', 'Test'); + assert.calledOnce(postSpy); + assert.notCalled(getSpy); + assert.notCalled(putSpy); + postSpy.resetHistory(); + + testOutcome('PUT', '/test', 'Test'); + assert.calledOnce(putSpy); + assert.notCalled(getSpy); + assert.notCalled(postSpy); + postSpy.resetHistory(); + }); + + it('registers route handlers properly', () => { + let handlers: SinonSpy[] = [], + methods: (keyof IRoute & keyof IRouter)[], + allHandler: SinonSpy, + route: IRoute; + + route = app.route('/test'); + + // methods to test + methods = [ 'get', 'post', 'put', 'delete', 'head', 'options', 'patch' ]; + + // Register handler that runs for every request + allHandler = spy((_req: Request, _resp: Response, next: NextCallback) => { next(); }); + route.all(allHandler); + + + // Register a handler for each method + _.each(methods, (method) => { + let handler = spy((_req: Request, resp: Response) => { resp.send('Test'); }); + + // Save the handler spy for testing later + handlers.push(handler); + + route[method](handler); + }); + + app.use((_req: Request, resp: Response) => { + resp.send('not found'); + }); + + // Run once for each method type + _.each(methods, (method) => { + testOutcome(method.toUpperCase(), '/test', 'Test'); + }); + + // Check that each handler was called exactly once + _.each(handlers, (handler) => { assert.calledOnce(handler); }); + + // Check that the "all" handler was called for each method + expect(allHandler.callCount).to.strictlyEqual(methods.length); + + // Other tests + _.each(methods, (method) => { + // Ensure trailing slash matches + testOutcome(method.toUpperCase(), '/test/', 'Test'); + // Ensure only exact matches trigger the route handler + testOutcome(method.toUpperCase(), '/test/anything', 'not found'); + }); + }); + + }); + });