Skip to content

Commit 02adb91

Browse files
committed
feat(routing): add internal re-routing (#23)
Route handlers may internally redirect requests to other route handlers by changing the request's `url` property.
1 parent 05c11e2 commit 02adb91

4 files changed

Lines changed: 400 additions & 69 deletions

File tree

src/Request.ts

Lines changed: 129 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -87,31 +87,29 @@ export default class Request {
8787
public readonly method: string;
8888

8989
/**
90-
* This property is much like `req.url`; however, it retains the original request URL,
91-
* allowing you to rewrite `req.url` freely for internal routing purposes. For example,
92-
* the "mounting" feature of `app.use()` will rewrite `req.url` to strip the mount
93-
* point.
90+
* This property is much like `req.url`; however, it always retains the original URL
91+
* from the event that triggered the request, allowing you to rewrite `req.url` freely
92+
* for internal routing purposes. For example, the "mounting" feature of `app.use()`
93+
* will rewrite `req.url` to strip the mount point:
9494
*
95-
* TODO: We still don't support internal re-routing mid-request. We need to investigate
96-
* how, exactly, Express does this, and what it would take to support it.
97-
*
98-
* ```
99-
* // GET /search?q=something
100-
* req.originalUrl
101-
* // => "/search?q=something"
10295
* ```
96+
* // GET 'http://www.example.com/admin/new'
10397
*
104-
* In a middleware function, `req.originalUrl` is a combination of `req.baseUrl` and
105-
* `req.path`, as shown in the following example.
98+
* let router = new Router();
10699
*
107-
* ```
108-
* app.use('/admin', function(req, res, next) { // GET 'http://www.example.com/admin/new'
100+
* router.get('/new', function(req, res, next) {
109101
* console.log(req.originalUrl); // '/admin/new'
110102
* console.log(req.baseUrl); // '/admin'
111103
* console.log(req.path); // '/new'
104+
* console.log(req.url); // '/new'
112105
* next();
113106
* });
107+
*
108+
* app.addSubRouter('/admin', router);
114109
* ```
110+
*
111+
* `req.originalUrl` stays the same even when a route handler changes `req.url` for
112+
* internal re-routing. See `req.url` for an example of internal re-routing.
115113
*/
116114
public readonly originalUrl: string;
117115

@@ -142,24 +140,6 @@ export default class Request {
142140
*/
143141
public readonly params: Readonly<StringMap>;
144142

145-
/**
146-
* Contains the path part of the request URL.
147-
*
148-
* ```
149-
* // example.com/users?sort=desc
150-
* req.path // => "/users"
151-
* ```
152-
*
153-
* When called from a middleware, the mount point is not included in req.path. See
154-
* `req.originalUrl` for more details.
155-
*/
156-
public readonly path: string;
157-
158-
/**
159-
* Synonymous with `req.path`.
160-
*/
161-
public readonly url: string;
162-
163143
/**
164144
* Contains the request protocol string: either `http` or (for TLS requests) `https`
165145
* (always lowercase).
@@ -226,10 +206,6 @@ export default class Request {
226206
*/
227207
public readonly eventSourceType: ('ALB' | 'APIGW');
228208

229-
// TODO: maybe some of those properties should not be read-only ... for example, how
230-
// would some middleware do the equivalent of an internal redirect? How does Express
231-
// handle that?
232-
233209
/**
234210
* The body of the request. If the body is an empty value (e.g. `''`), `req.body` will
235211
* be `null` to make body-exists checks (e.g. `if (req.body)`) simpler.
@@ -239,10 +215,29 @@ export default class Request {
239215
*/
240216
public body?: unknown;
241217

218+
protected _parentRequest?: Request;
219+
protected _url: string;
220+
protected _path: string;
221+
242222
private readonly _headers: StringArrayOfStringsMap;
243223
private readonly _event: RequestEvent;
244224

245-
public constructor(app: Application, event: RequestEvent, context: HandlerContext, baseURL: string = '', params: StringMap = {}) {
225+
public constructor(app: Application, eventOrRequest: RequestEvent | Request, context: HandlerContext,
226+
baseURL: string = '', params: StringMap = {}) {
227+
let event: RequestEvent,
228+
path: string;
229+
230+
if (eventOrRequest instanceof Request) {
231+
// Make this request a sub-request of the request passed into the constructor
232+
this._parentRequest = eventOrRequest;
233+
path = this._parentRequest.path.substring(baseURL.length);
234+
baseURL = this._parentRequest.baseUrl + baseURL;
235+
event = this._parentRequest._event;
236+
} else {
237+
event = eventOrRequest;
238+
path = event.path;
239+
}
240+
246241
this.app = app;
247242
this._event = event;
248243
this._headers = this._parseHeaders(event);
@@ -267,19 +262,108 @@ export default class Request {
267262

268263
// Fields related to routing:
269264
this.baseUrl = baseURL;
270-
this.path = this.url = event.path;
271-
this.originalUrl = baseURL + event.path;
265+
this._path = this._url = path;
266+
// Despite the fact that the Express docs say that the `originalUrl` is `baseUrl +
267+
// path`, it's actually always equal to the original URL that initiated the request.
268+
// If, for example, a route handler changes the `url` of a request, the `path` is
269+
// changed too, *but* `originalUrl` stays the same. This would not be the case if
270+
// `originalUrl = `baseUrl + path`. See the documentation on the `url` getter for
271+
// more details.
272+
this.originalUrl = event.path;
272273
this.params = Object.freeze(params);
273274
}
274275

276+
/** PUBLIC PROPERTIES: GETTERS AND SETTERS */
277+
278+
/**
279+
* `req.url` is the same as `req.path` in most cases.
280+
*
281+
* However, route handlers and other middleware may change the value of `req.url` to
282+
* redirect the request to other registered middleware. For example:
283+
*
284+
* ```
285+
* // GET example.com/admin/users/1337
286+
*
287+
* const router1 = new express.Router(),
288+
* router2 = new express.Router();
289+
*
290+
* router1.get('/users/:userID', function(req, res, next) {
291+
* // ...
292+
* if (req.params.userID === authenticatedUser.id) {
293+
* // User ID is the same as the authenticated user's. Re-route to user profile
294+
* // handler:
295+
* req.url = '/profile';
296+
* return next();
297+
* }
298+
* // ...
299+
* });
300+
*
301+
* router2.get('/profile', function(req, res) {
302+
* console.log(req.originalUrl); // '/admin/users/1337'
303+
* console.log(req.baseUrl); // '/admin'
304+
* console.log(req.path); // '/profile'
305+
* console.log(req.url); // '/profile'
306+
* // ...
307+
* });
308+
*
309+
* app.addSubRouter('/admin', router1);
310+
* app.addSubRouter('/admin', router2);
311+
* ```
312+
*
313+
* In the example above, the `GET` request to `/admin/users/1337` is re-routed to the
314+
* `/profile` handler in `router2`. Any other route handlers on `router1` that would
315+
* have handled the `/users/1337` route are skiped. Also, notice that `req.url` keeps
316+
* the value given to it by `router1`'s route handler, but `req.originalUrl` stays the
317+
* same.
318+
*
319+
* If the route handler or middleware that changes `req.url` adds a query string to
320+
* `req.url`, the query string is retained on the `req.url` property but the query
321+
* string keys and values are *not* parsed and `req.params` is *not* updated. This
322+
* follows Express' apparent behavior when handling internal re-routing with URLs that
323+
* contain query strings.
324+
*/
325+
public get url(): string {
326+
return this._url;
327+
}
328+
329+
public set url(url: string) {
330+
url = url || '';
331+
332+
// Update the parent request's URL with the new URL value
333+
if (this._parentRequest) {
334+
let indexOfCurrentURL = this._parentRequest.url.length - this._url.length;
335+
336+
this._parentRequest.url = this._parentRequest.url.substring(0, indexOfCurrentURL) + url;
337+
}
338+
this._url = url;
339+
// Remove query parameters from the URL to form the new path
340+
this._path = url.split('?')[0];
341+
}
342+
343+
/**
344+
* Contains the path part of the request URL.
345+
*
346+
* ```
347+
* // example.com/users?sort=desc
348+
* req.path // => "/users"
349+
* ```
350+
*
351+
* When referenced from middleware, the mount point is not included in `req.path`. See
352+
* `req.originalUrl` for more details.
353+
*
354+
* When any middleware changes the value of `req.url` for internal re-routing,
355+
* `req.path` is updated also. See `req.url` for an example of internal re-routing.
356+
*/
357+
public get path(): string {
358+
return this._path;
359+
}
360+
361+
// Disable changing the `path` via the public API by not implementing a setter here.
362+
275363
/** CLONING FUNCTION */
276364

277365
public makeSubRequest(baseURL: string, params?: StringMap): Request {
278-
const restOfPath = this._event.path.substring(baseURL.length),
279-
// TODO: this isn't a deep clone - does it need to be?
280-
event = _.extend({}, this._event, { path: restOfPath });
281-
282-
return new Request(this.app, event, this.context, baseURL, params);
366+
return new Request(this.app, this, this.context, baseURL, params);
283367
}
284368

285369
/** CONVENIENCE FUNCTIONS */

src/Router.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
NextCallback,
99
ErrorHandlingRequestProcessor,
1010
} from './interfaces';
11-
import { IRequestMatchingProcessorChain, IProcessorChain } from './chains/ProcessorChain';
11+
import { IRequestMatchingProcessorChain } from './chains/ProcessorChain';
1212
import { Request, Response } from '.';
1313
import { wrapRequestProcessor, wrapRequestProcessors } from './utils/wrapRequestProcessor';
1414
import { RouteMatchingProcessorChain } from './chains/RouteMatchingProcessorChain';
@@ -35,15 +35,33 @@ export default class Router implements IRouter {
3535
// using the case-sensitivity setting of this router.
3636

3737
public handle(originalErr: unknown, req: Request, resp: Response, done: NextCallback): void {
38-
const processors = _.filter(this._processors, (p) => { return p.matches(req); });
39-
40-
const go = _.reduce(processors.reverse(), (next: NextCallback, p: IProcessorChain): NextCallback => {
41-
return (err) => {
42-
p.run(err, req, resp, next);
43-
};
44-
}, done);
45-
46-
go(originalErr);
38+
const processors = this._processors;
39+
40+
let index = 0;
41+
42+
const processRequest = (err: unknown, processorReq: Request, processorResp: Response, next: NextCallback): void => {
43+
let processor = processors[index];
44+
45+
index += 1;
46+
47+
if (processor === undefined) {
48+
// We've looped through all available processors.
49+
return next(err);
50+
} else if (processor.matches(processorReq)) {
51+
// ^^^^ User-defined route handlers may change the request object's URL to
52+
// re-route the request to other route handlers. Therefore, we must re-check
53+
// whether the current processor matches the request object after every
54+
// processor is run.
55+
processor.run(err, processorReq, processorResp, (processorError) => {
56+
processRequest(processorError, processorReq, processorResp, next);
57+
});
58+
} else {
59+
// Current processor does not match. Continue.
60+
processRequest(err, processorReq, processorResp, next);
61+
}
62+
};
63+
64+
processRequest(originalErr, req, resp, done);
4765
}
4866

4967
/**

tests/Request.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,32 @@ describe('Request', () => {
3636
expect(new Request(app, evt2, handlerContext()).method).to.strictlyEqual('');
3737
});
3838

39+
it('sets URL related fields correctly, when created from an event', () => {
40+
const event = albRequest(),
41+
request = new Request(app, event, handlerContext());
42+
43+
expect(request.url).to.strictlyEqual(event.path);
44+
expect(request.path).to.strictlyEqual(event.path);
45+
expect(request.originalUrl).to.strictlyEqual(event.path);
46+
});
47+
48+
it('sets URL related fields correctly, when created from a parent request', () => {
49+
const event = albRequest();
50+
51+
let parentRequest, request;
52+
53+
event.path = '/a/b/c';
54+
55+
parentRequest = new Request(app, event, handlerContext());
56+
request = new Request(app, parentRequest, handlerContext(), '/a/b');
57+
58+
expect(request.url).to.strictlyEqual('/c');
59+
expect(request.path).to.strictlyEqual('/c');
60+
expect(request.baseUrl).to.strictlyEqual('/a/b');
61+
expect(request.originalUrl).to.strictlyEqual(event.path);
62+
expect(request.originalUrl).to.strictlyEqual(parentRequest.url);
63+
});
64+
3965
});
4066

4167
describe('makeSubRequest', () => {
@@ -470,4 +496,69 @@ describe('Request', () => {
470496

471497
});
472498

499+
describe('`url` property', () => {
500+
501+
it('should be able to be updated', () => {
502+
let req = new Request(app, apiGatewayRequest(), handlerContext()),
503+
newURL = '/test';
504+
505+
// Assert that we have a valid test
506+
expect(req.url).to.not.strictlyEqual(newURL);
507+
508+
req.url = newURL;
509+
expect(req.url).to.strictlyEqual(newURL);
510+
});
511+
512+
it('should accept blank values', () => {
513+
let req = new Request(app, apiGatewayRequest(), handlerContext()),
514+
newURL = '';
515+
516+
// Assert that we have a valid test
517+
expect(req.url).to.not.strictlyEqual(newURL);
518+
519+
req.url = newURL;
520+
expect(req.url).to.strictlyEqual(newURL);
521+
});
522+
523+
it('should update `path` when `url` changes', () => {
524+
let req = new Request(app, apiGatewayRequest(), handlerContext()),
525+
newURL = '/test';
526+
527+
// Assert that we have a valid test
528+
expect(req.path).to.not.strictlyEqual(newURL);
529+
530+
req.url = newURL;
531+
expect(req.path).to.strictlyEqual(newURL);
532+
});
533+
534+
it('should update the parent request\'s `url` and related properties when a sub-request\'s `url` is updated', () => {
535+
let event = apiGatewayRequest(),
536+
req, subReq, subSubReq;
537+
538+
// Assert that we have a valid test
539+
expect(event.path).to.not.strictlyEqual('/path/path/old');
540+
541+
event.path = '/path/path/old';
542+
543+
req = new Request(app, event, handlerContext());
544+
subReq = req.makeSubRequest('/path');
545+
subSubReq = subReq.makeSubRequest('/path');
546+
547+
subSubReq.url = '/new';
548+
549+
expect(subSubReq.url).to.strictlyEqual('/new');
550+
expect(subSubReq.baseUrl).to.strictlyEqual('/path/path');
551+
expect(subSubReq.originalUrl).to.strictlyEqual('/path/path/old');
552+
553+
expect(subReq.url).to.strictlyEqual('/path/new');
554+
expect(subReq.baseUrl).to.strictlyEqual('/path');
555+
expect(subReq.originalUrl).to.strictlyEqual('/path/path/old');
556+
557+
expect(req.url).to.strictlyEqual('/path/path/new');
558+
expect(req.baseUrl).to.strictlyEqual('');
559+
expect(req.originalUrl).to.strictlyEqual('/path/path/old');
560+
});
561+
562+
});
563+
473564
});

0 commit comments

Comments
 (0)