Skip to content

Commit 49f500c

Browse files
onebytegonejthomerson
authored andcommitted
feat: Add support for promises in handlers (#30)
1 parent c739611 commit 49f500c

5 files changed

Lines changed: 241 additions & 12 deletions

File tree

src/interfaces.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export interface RequestProcessor {
4040
* processing. Failure to either send a response or call `next` will result in a hung
4141
* process.
4242
*
43+
* If this function returns a Promise object, a `catch` method will automatically be
44+
* attached to the promise. If the promise is rejected, that `catch` method will call
45+
* `next`, passing along the rejected value or an error if the value is empty. If the
46+
* returned promise is resolved, `next` will *not* be called automatically. It is up to
47+
* the handler to call `next` when appropriate.
48+
*
4349
* @param req The request to be handled
4450
* @param resp The response that will be sent when the request-handling process is
4551
* complete

src/utils/common-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,9 @@ export function isStringArrayOfStringsMap(o: any): o is StringArrayOfStringsMap
6767
return memo && _.isString(k) && isArrayOfStrings(v);
6868
}, true);
6969
}
70+
71+
export function isPromise(o: any): o is Promise<unknown> {
72+
return o
73+
&& typeof o === 'object'
74+
&& typeof o.then === 'function';
75+
}

src/utils/wrapRequestProcessor.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import _ from 'underscore';
22
import { AnyRequestProcessor, NextCallback, ErrorHandlingRequestProcessor } from '../interfaces';
33
import { Request, Response } from '..';
4+
import { isPromise } from './common-types';
45

56
function isErrorHandler(rh: AnyRequestProcessor): rh is ErrorHandlingRequestProcessor {
67
return rh.length === 4;
@@ -10,7 +11,15 @@ export function wrapRequestProcessor(rp: AnyRequestProcessor): ErrorHandlingRequ
1011
if (isErrorHandler(rp)) {
1112
return (err: unknown, req: Request, resp: Response, next: NextCallback) => {
1213
if (err) {
13-
return rp(err, req, resp, next);
14+
const returned: any = rp(err, req, resp, next);
15+
16+
if (isPromise(returned)) {
17+
returned.then(null, (newErr: unknown) => {
18+
next(newErr || new Error('Rejected promise'));
19+
});
20+
}
21+
22+
return;
1423
}
1524

1625
// Error handlers should not get invoked if there is no error, so we simply
@@ -27,7 +36,13 @@ export function wrapRequestProcessor(rp: AnyRequestProcessor): ErrorHandlingRequ
2736
return next(err);
2837
}
2938

30-
rp(req, resp, next);
39+
const returned: any = rp(req, resp, next);
40+
41+
if (isPromise(returned)) {
42+
returned.then(null, (newErr: unknown) => {
43+
next(newErr || new Error('Rejected promise'));
44+
});
45+
}
3146
};
3247
}
3348

tests/chains/ProcessorChain.test.ts

Lines changed: 176 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ describe('ProcessorChain', () => {
4040
return new ProcessorChain(wrapRequestProcessors(procs));
4141
};
4242

43+
const makeDoneFn = (endTest: Mocha.Done, tests: (args: any[]) => void): (() => void) => {
44+
return (...args) => {
45+
try {
46+
tests(args);
47+
endTest();
48+
} catch(err) {
49+
endTest(err);
50+
}
51+
};
52+
};
53+
4354
it('calls processors in correct order - no error handlers and no errors', () => {
4455
const procs: SinonSpy[] = [
4556
/* 0 */ makeRequestProcessor('mw1'),
@@ -54,20 +65,39 @@ describe('ProcessorChain', () => {
5465
assert.calledWithExactly(done); // `done` called with no args
5566
});
5667

57-
it('does not call error handlers when no errors', () => {
68+
it('calls async processors returning promises in correct order - no error handlers and no errors', (endTest) => {
69+
const procs: SinonSpy[] = [
70+
/* 0 */ makeRequestProcessor('mw1', { returnsResolvedPromise: true }),
71+
/* 1 */ makeRequestProcessor('mw2'),
72+
/* 2 */ makeRequestProcessor('mw3', { returnsResolvedPromise: true }),
73+
/* 3 */ makeRequestProcessor('rh1'),
74+
/* 4 */ makeRequestProcessor('rh2'),
75+
/* 5 */ makeRequestProcessor('rh3', { returnsResolvedPromise: true }),
76+
];
77+
78+
makeChain(procs).run(undefined, req, resp, makeDoneFn(endTest, (args) => {
79+
assertAllCalledOnceInOrder(...procs);
80+
expect(args).to.eql([]); // callback was called with no args
81+
}));
82+
});
83+
84+
it('does not call error handlers when no errors', (endTest) => {
5885
const procs: SinonSpy[] = [
5986
/* 0 */ makeRequestProcessor('mw1'),
6087
/* 1 */ makeRequestProcessor('rh1'),
6188
/* 2 */ makeRequestProcessor('eh1', { handlesErrors: true }),
6289
/* 3 */ makeRequestProcessor('mw2'),
6390
/* 4 */ makeRequestProcessor('rh2'),
64-
/* 5 */ makeRequestProcessor('eh2', { handlesErrors: true }),
91+
/* 5 */ makeRequestProcessor('eh2', { handlesErrors: true, returnsResolvedPromise: true }),
92+
/* 6 */ makeRequestProcessor('rh3'),
93+
/* 7 */ makeRequestProcessor('eh3', { handlesErrors: true }),
6594
];
6695

67-
makeChain(procs).run(undefined, req, resp, done);
68-
assertAllCalledOnceInOrder(procs[0], procs[1], procs[3], procs[4], done);
69-
assertNotCalled(procs[2], procs[5]);
70-
assert.calledWithExactly(done); // `done` called with no args
96+
makeChain(procs).run(undefined, req, resp, makeDoneFn(endTest, (args) => {
97+
assertAllCalledOnceInOrder(procs[0], procs[1], procs[3], procs[4], procs[6]);
98+
assertNotCalled(procs[2], procs[5], procs[7]);
99+
expect(args).to.eql([]); // callback was called with no args
100+
}));
71101
});
72102

73103
it('skips to error handlers on first thrown error', () => {
@@ -88,7 +118,26 @@ describe('ProcessorChain', () => {
88118
assert.calledWithExactly(done); // `done` called with no args
89119
});
90120

91-
it('calls subsequent error handlers when thrown error passed on (thrown error)', () => {
121+
it('skips to error handlers on first rejected promise', (endTest) => {
122+
const procs: SinonSpy[] = [
123+
/* 0 */ makeRequestProcessor('mw1'),
124+
/* 1 */ makeRequestProcessor('mw2', { returnsRejectedPromise: true }),
125+
/* 2 */ makeRequestProcessor('rh1'),
126+
/* 3 */ makeRequestProcessor('rh2'),
127+
/* 4 */ makeRequestProcessor('eh1', { handlesErrors: true }),
128+
// since the first error handler does not pass the error on when it calls `next`,
129+
// this second error handler will not be called
130+
/* 5 */ makeRequestProcessor('eh2', { handlesErrors: true }),
131+
];
132+
133+
makeChain(procs).run(undefined, req, resp, makeDoneFn(endTest, (args) => {
134+
assertAllCalledOnceInOrder(procs[0], procs[1], procs[4]);
135+
assertNotCalled(procs[2], procs[3], procs[5]);
136+
expect(args).to.eql([]); // callback was called with no args
137+
}));
138+
});
139+
140+
it('calls subsequent error handlers when thrown error is passed on (thrown error)', () => {
92141
const procs: SinonSpy[] = [
93142
/* 0 */ makeRequestProcessor('mw1'),
94143
/* 1 */ makeRequestProcessor('mw2', { throwsError: true }),
@@ -104,6 +153,103 @@ describe('ProcessorChain', () => {
104153
assertCalledWith(done, 'Error from "mw2"', true); // `done` called with Error(string)
105154
});
106155

156+
it('skips to error handlers on first rejected promise (empty rejection)', (endTest) => {
157+
const procs: SinonSpy[] = [
158+
/* 0 */ makeRequestProcessor('mw1'),
159+
/* 1 */ makeRequestProcessor('mw2', { returnsEmptyRejectedPromise: true }),
160+
/* 2 */ makeRequestProcessor('rh1'),
161+
/* 3 */ makeRequestProcessor('rh2'),
162+
/* 4 */ makeRequestProcessor('eh1', { handlesErrors: true }),
163+
// since the first error handler does not pass the error on when it calls `next`,
164+
// this second error handler will not be called
165+
/* 5 */ makeRequestProcessor('eh2', { handlesErrors: true }),
166+
];
167+
168+
makeChain(procs).run(undefined, req, resp, makeDoneFn(endTest, (args) => {
169+
assertAllCalledOnceInOrder(procs[0], procs[1], procs[4]);
170+
assertNotCalled(procs[2], procs[3], procs[5]);
171+
expect(args).to.eql([]); // callback was called with no args
172+
}));
173+
});
174+
175+
it('calls subsequent error handlers when a rejected promise\'s error is passed on', (endTest) => {
176+
const procs: SinonSpy[] = [
177+
/* 0 */ makeRequestProcessor('mw1'),
178+
/* 1 */ makeRequestProcessor('mw2', { returnsRejectedPromise: true }),
179+
/* 2 */ makeRequestProcessor('rh1'),
180+
/* 3 */ makeRequestProcessor('rh2'),
181+
/* 4 */ makeRequestProcessor('eh1', { handlesErrors: true, passesErrorToNext: true }),
182+
/* 5 */ makeRequestProcessor('eh2', { handlesErrors: true, passesErrorToNext: true }),
183+
];
184+
185+
makeChain(procs).run(undefined, req, resp, makeDoneFn(endTest, (args) => {
186+
assertAllCalledOnceInOrder(procs[0], procs[1], procs[4], procs[5]);
187+
assertNotCalled(procs[2], procs[3]);
188+
// callback called with error string (not Error instance)
189+
expect(args).to.have.length(1);
190+
expect(args[0]).to.eql('Rejection from "mw2"');
191+
}));
192+
});
193+
194+
it('calls subsequent error handlers with default error from empty rejected promise', (endTest) => {
195+
const procs: SinonSpy[] = [
196+
/* 0 */ makeRequestProcessor('mw1'),
197+
/* 1 */ makeRequestProcessor('mw2', { returnsEmptyRejectedPromise: true }),
198+
/* 2 */ makeRequestProcessor('rh1'),
199+
/* 3 */ makeRequestProcessor('rh2'),
200+
/* 4 */ makeRequestProcessor('eh1', { handlesErrors: true, passesErrorToNext: true }),
201+
/* 5 */ makeRequestProcessor('eh2', { handlesErrors: true, passesErrorToNext: true }),
202+
];
203+
204+
makeChain(procs).run(undefined, req, resp, makeDoneFn(endTest, (args) => {
205+
assertAllCalledOnceInOrder(procs[0], procs[1], procs[4], procs[5]);
206+
assertNotCalled(procs[2], procs[3]);
207+
// callback called with Error(string)
208+
expect(args).to.have.length(1);
209+
expect(args[0]).to.be.an.instanceOf(Error);
210+
expect(args[0].message).to.eql('Rejected promise');
211+
}));
212+
});
213+
214+
it('calls subsequent error handlers with error from rejected promise', (endTest) => {
215+
const procs: SinonSpy[] = [
216+
/* 0 */ makeRequestProcessor('mw1'),
217+
/* 1 */ makeRequestProcessor('mw2', { throwsError: true }),
218+
/* 2 */ makeRequestProcessor('rh1'),
219+
/* 3 */ makeRequestProcessor('rh2'),
220+
/* 4 */ makeRequestProcessor('eh1', { handlesErrors: true, returnsRejectedPromise: true }),
221+
/* 5 */ makeRequestProcessor('eh2', { handlesErrors: true, passesErrorToNext: true }),
222+
];
223+
224+
makeChain(procs).run(undefined, req, resp, makeDoneFn(endTest, (args) => {
225+
assertAllCalledOnceInOrder(procs[0], procs[1], procs[4], procs[5]);
226+
assertNotCalled(procs[2], procs[3]);
227+
// callback called with error string (not Error instance)
228+
expect(args).to.have.length(1);
229+
expect(args[0]).to.eql('Rejection from "eh1"');
230+
}));
231+
});
232+
233+
it('calls subsequent error handlers with default error from empty rejected promise', (endTest) => {
234+
const procs: SinonSpy[] = [
235+
/* 0 */ makeRequestProcessor('mw1'),
236+
/* 1 */ makeRequestProcessor('mw2', { throwsError: true }),
237+
/* 2 */ makeRequestProcessor('rh1'),
238+
/* 3 */ makeRequestProcessor('rh2'),
239+
/* 4 */ makeRequestProcessor('eh1', { handlesErrors: true, returnsEmptyRejectedPromise: true }),
240+
/* 5 */ makeRequestProcessor('eh2', { handlesErrors: true, passesErrorToNext: true }),
241+
];
242+
243+
makeChain(procs).run(undefined, req, resp, makeDoneFn(endTest, (args) => {
244+
assertAllCalledOnceInOrder(procs[0], procs[1], procs[4], procs[5]);
245+
assertNotCalled(procs[2], procs[3]);
246+
// callback called with Error(string)
247+
expect(args).to.have.length(1);
248+
expect(args[0]).to.be.an.instanceOf(Error);
249+
expect(args[0].message).to.eql('Rejected promise');
250+
}));
251+
});
252+
107253
it('skips to error handlers on first non-thrown error', () => {
108254
const procs: SinonSpy[] = [
109255
/* 0 */ makeRequestProcessor('mw1'),
@@ -122,7 +268,7 @@ describe('ProcessorChain', () => {
122268
assert.calledWithExactly(done); // `done` called with no args
123269
});
124270

125-
it('calls subsequent error handlers when thrown error passed on (non-thrown error)', () => {
271+
it('calls subsequent error handlers when thrown error is passed on (non-thrown error)', () => {
126272
const procs: SinonSpy[] = [
127273
/* 0 */ makeRequestProcessor('mw1'),
128274
/* 1 */ makeRequestProcessor('mw2', { callsNextWithError: true }),
@@ -159,6 +305,28 @@ describe('ProcessorChain', () => {
159305
assert.calledWithExactly(done); // `done` called with no args
160306
});
161307

308+
it('resumes processors after error handler handles rejected promise', (endTest) => {
309+
const procs: SinonSpy[] = [
310+
/* 0 */ makeRequestProcessor('mw1'),
311+
/* 1 */ makeRequestProcessor('mw2', { returnsRejectedPromise: true }),
312+
/* 2 */ makeRequestProcessor('rh1'),
313+
/* 3 */ makeRequestProcessor('rh2'),
314+
/* 4 */ makeRequestProcessor('eh1', { handlesErrors: true }),
315+
// since the previous error handler (eh1) did not pass the error on to the next,
316+
// then the next error handler (eh2) will not get called
317+
/* 5 */ makeRequestProcessor('eh2', { handlesErrors: true }),
318+
// but these regular processor (route handler / middleware) will get called
319+
/* 6 */ makeRequestProcessor('rh3'),
320+
/* 7 */ makeRequestProcessor('rh4'),
321+
];
322+
323+
makeChain(procs).run(undefined, req, resp, makeDoneFn(endTest, (args) => {
324+
assertAllCalledOnceInOrder(procs[0], procs[1], procs[4], procs[6], procs[7]);
325+
assertNotCalled(procs[2], procs[3], procs[5]);
326+
expect(args).to.eql([]); // callback was called with no args
327+
}));
328+
});
329+
162330
it('short-circuits to done when next(\'route\') called', () => {
163331
const procs: SinonSpy[] = [
164332
/* 0 */ makeRequestProcessor('mw1'),

tests/test-utils/makeRequestProcessor.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ interface MakeFunctionOpts {
1010
callsNextWithError?: boolean;
1111
callsNextRoute?: boolean;
1212
passesErrorToNext?: boolean;
13+
returnsResolvedPromise?: boolean;
14+
returnsRejectedPromise?: boolean;
15+
returnsEmptyRejectedPromise?: boolean;
1316
}
1417

1518
const DEFAULT_OPTS: MakeFunctionOpts = {
@@ -19,8 +22,19 @@ const DEFAULT_OPTS: MakeFunctionOpts = {
1922
callsNextWithError: false,
2023
callsNextRoute: false,
2124
passesErrorToNext: false,
25+
returnsResolvedPromise: false,
26+
returnsRejectedPromise: false,
27+
returnsEmptyRejectedPromise: false,
2228
};
2329

30+
function delay(value: unknown, ms: number = 4): Promise<unknown> {
31+
return new Promise((resolve) => {
32+
setTimeout(() => {
33+
resolve(value);
34+
}, ms);
35+
});
36+
}
37+
2438
/**
2539
* When using sinon assertions (especially for asserting that many functions were called,
2640
* and that call order was correct), it's best to have named functions. So, this function
@@ -33,7 +47,7 @@ export default function makeRequestProcessor(name: string, userOpts?: MakeFuncti
3347
rp: AnyRequestProcessor;
3448

3549
if (opts.handlesErrors) {
36-
rp = (err: unknown, _req: Request, _resp: Response, next: NextCallback): void => {
50+
rp = (err: unknown, _req: Request, _resp: Response, next: NextCallback): void | Promise<unknown> => {
3751
if (opts.throwsError) {
3852
throw new Error(`Error from "${name}"`);
3953
} else if (opts.callsNextWithError) {
@@ -42,6 +56,16 @@ export default function makeRequestProcessor(name: string, userOpts?: MakeFuncti
4256
return next('route');
4357
} else if (opts.passesErrorToNext) {
4458
return next(err);
59+
} else if (opts.returnsRejectedPromise) {
60+
return delay(Promise.reject(`Rejection from "${name}"`));
61+
} else if (opts.returnsEmptyRejectedPromise) {
62+
return delay(Promise.reject());
63+
} else if (opts.returnsResolvedPromise && opts.callsNext) {
64+
return delay(undefined).then(() => {
65+
next(); // eslint-disable-line callback-return
66+
});
67+
} else if (opts.returnsResolvedPromise) {
68+
return delay(Promise.resolve());
4569
} else if (opts.callsNext) {
4670
return next();
4771
}
@@ -50,13 +74,23 @@ export default function makeRequestProcessor(name: string, userOpts?: MakeFuncti
5074
if (opts.passesErrorToNext) {
5175
throw new Error(`Invalid makeFunction options: ${JSON.stringify(opts)}`);
5276
}
53-
rp = (_req: Request, _resp: Response, next: NextCallback): void => {
77+
rp = (_req: Request, _resp: Response, next: NextCallback): void | Promise<unknown> => {
5478
if (opts.throwsError) {
5579
throw new Error(`Error from "${name}"`);
5680
} else if (opts.callsNextWithError) {
5781
return next(`Error from "${name}"`);
5882
} else if (opts.callsNextRoute) {
5983
return next('route');
84+
} else if (opts.returnsRejectedPromise) {
85+
return delay(Promise.reject(`Rejection from "${name}"`));
86+
} else if (opts.returnsEmptyRejectedPromise) {
87+
return delay(Promise.reject());
88+
} else if (opts.returnsResolvedPromise && opts.callsNext) {
89+
return delay(undefined).then(() => {
90+
next(); // eslint-disable-line callback-return
91+
});
92+
} else if (opts.returnsResolvedPromise) {
93+
return delay(Promise.resolve());
6094
} else if (opts.callsNext) {
6195
return next();
6296
}

0 commit comments

Comments
 (0)