Skip to content

Commit 5bf5e54

Browse files
authored
Chainable api support (#53)
Chainable api support
2 parents 85f667c + c583ff6 commit 5bf5e54

File tree

6 files changed

+498
-8
lines changed

6 files changed

+498
-8
lines changed

README.md

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,36 @@ try {
4343
}
4444
```
4545

46+
You can also use chaining methods if you do not pass the callback function. Check [here](#method-chaining) for details.
47+
48+
```js
49+
// chaining methods
50+
inject(dispatch)
51+
.get('/') // set the request method to GET, and request URL to '/'
52+
.headers({ foo: 'bar' }) // set the request headers
53+
.query({ foo: 'bar' }) // set the query parameters
54+
.end((err, res) => {
55+
console.log(res.payload)
56+
})
57+
58+
inject(dispatch)
59+
.post('/') // set the request method to POST, and request URL to '/'
60+
.payload('request payload') // set the request payload
61+
.body('request body') // alias for payload
62+
.end((err, res) => {
63+
console.log(res.payload)
64+
})
65+
66+
// async-await is also supported
67+
try {
68+
const chain = inject(dispatch).get('/')
69+
const res = await chain.end()
70+
console.log(res.payload)
71+
} catch (err) {
72+
console.log(err)
73+
}
74+
```
75+
4676
File uploads (multipart/form-data) can be achieved by using [form-data](https://github.com/form-data/form-data) package as shown below:
4777

4878
```js
@@ -103,7 +133,7 @@ The declaration file exports types for the following parts of the API:
103133

104134
## API
105135

106-
#### `inject(dispatchFunc, options, callback)`
136+
#### `inject(dispatchFunc[, options, callback])`
107137

108138
Injects a fake request into an HTTP server.
109139

@@ -148,6 +178,50 @@ Note: You can also pass a string in place of the `options` object as a shorthand
148178

149179
Checks if given object `obj` is a *light-my-request* `Request` object.
150180

181+
#### Method chaining
182+
183+
There are following methods you can used as chaining:
184+
- `delete`, `get`, `head`, `options`, `patch`, `post`, `put`, `trace`. They will set the HTTP request method and also the request URL.
185+
- `body`, `headers`, `payload`, `query`. They can be used to set the request options object.
186+
187+
And finally you need to call `end`. It has the signature `function (callback)`.
188+
If you invoke `end` without a callback function, the method will return a promise, thus you can:
189+
190+
```js
191+
const chain = inject(dispatch).get('/')
192+
193+
try {
194+
const res = await chain.end()
195+
console.log(res.payload)
196+
} catch (err) {
197+
// handle error
198+
}
199+
200+
// or
201+
chain.end()
202+
.then(res => {
203+
console.log(res.payload)
204+
})
205+
.catch(err => {
206+
// handle error
207+
})
208+
```
209+
210+
By the way, you can also use promises without calling `end`!
211+
212+
```js
213+
inject(dispatch)
214+
.get('/')
215+
.then(res => {
216+
console.log(res.payload)
217+
})
218+
.catch(err => {
219+
// handle error
220+
})
221+
```
222+
223+
Note: The application would not respond multiple times. If you try to invoking any method after the application has responded, the application would throw an error.
224+
151225
## Acknowledgements
152226
This project has been forked from [`hapi/shot`](https://github.com/hapijs/shot) because we wanted to support *Node ≥ v4* and not only *Node ≥ v8*.
153227
All the credits before the commit [00a2a82](https://github.com/fastify/light-my-request/commit/00a2a82eb773b765003b6085788cc3564cd08326) goes to the `hapi/shot` project [contributors](https://github.com/hapijs/shot/graphs/contributors).

index.d.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,18 @@ type HTTPMethods = 'DELETE' | 'delete' |
1212
declare namespace LightMyRequest {
1313
function inject (
1414
dispatchFunc: DispatchFunc,
15-
options: string | InjectOptions
16-
): Promise<Response>
15+
options?: string | InjectOptions
16+
): Chain
1717
function inject (
1818
dispatchFunc: DispatchFunc,
1919
options: string | InjectOptions,
20-
callback: (err: Error, response: Response) => void
20+
callback: CallbackFunc
2121
): void
2222

2323
type DispatchFunc = (req: Request, res: Response) => void
2424

25+
type CallbackFunc = (err: Error, response: Response) => void
26+
2527
type InjectPayload = string | object | Buffer | NodeJS.ReadableStream
2628

2729
function isInjection (obj: Request | Response): boolean
@@ -78,6 +80,22 @@ declare namespace LightMyRequest {
7880
body: string
7981
json: () => object
8082
}
83+
84+
interface Chain {
85+
delete: (url: string) => Chain
86+
get: (url: string) => Chain
87+
head: (url: string) => Chain
88+
options: (url: string) => Chain
89+
patch: (url: string) => Chain
90+
post: (url: string) => Chain
91+
put: (url: string) => Chain
92+
trace: (url: string) => Chain
93+
body: (body: InjectPayload) => Chain
94+
headers: (headers: http.IncomingHttpHeaders | http.OutgoingHttpHeaders) => Chain
95+
payload: (payload: InjectPayload) => Chain
96+
query: (query: object) => Chain
97+
end: (callback?: CallbackFunc) => Chain | Promise<Response>
98+
}
8199
}
82100

83101
export = LightMyRequest

index.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ const schema = {
6363
const optsValidator = ajv.compile(schema)
6464

6565
function inject (dispatchFunc, options, callback) {
66+
if (typeof callback === 'undefined') {
67+
return new Chain(dispatchFunc, options)
68+
} else {
69+
return doInject(dispatchFunc, options, callback)
70+
}
71+
}
72+
73+
function doInject (dispatchFunc, options, callback) {
6674
options = (typeof options === 'string' ? { url: options } : options)
6775

6876
if (options.validate !== false) {
@@ -90,11 +98,88 @@ function inject (dispatchFunc, options, callback) {
9098
}
9199
}
92100

101+
function Chain (dispatch, option) {
102+
this.option = Object.assign({}, option)
103+
this.dispatch = dispatch
104+
this._hasInvoked = false
105+
this._promise = null
106+
}
107+
108+
const httpMethods = [
109+
'delete',
110+
'get',
111+
'head',
112+
'options',
113+
'patch',
114+
'post',
115+
'put',
116+
'trace'
117+
]
118+
119+
httpMethods.forEach(method => {
120+
Chain.prototype[method] = function (url) {
121+
if (this._hasInvoked === true || this._promise) {
122+
throwIfAlreadyInvoked()
123+
}
124+
this.option.url = url
125+
this.option.method = method.toUpperCase()
126+
return this
127+
}
128+
})
129+
130+
const chainMethods = [
131+
'body',
132+
'headers',
133+
'payload',
134+
'query'
135+
]
136+
137+
chainMethods.forEach(method => {
138+
Chain.prototype[method] = function (value) {
139+
if (this._hasInvoked === true || this._promise) {
140+
throwIfAlreadyInvoked()
141+
}
142+
this.option[method] = value
143+
return this
144+
}
145+
})
146+
147+
Chain.prototype.end = function (callback) {
148+
if (this._hasInvoked === true || this._promise) {
149+
throwIfAlreadyInvoked()
150+
}
151+
this._hasInvoked = true
152+
if (typeof callback === 'function') {
153+
doInject(this.dispatch, this.option, callback)
154+
} else {
155+
this._promise = doInject(this.dispatch, this.option)
156+
return this._promise
157+
}
158+
}
159+
160+
Object.getOwnPropertyNames(Promise.prototype).forEach(method => {
161+
if (method === 'constructor') return
162+
Chain.prototype[method] = function (...args) {
163+
if (!this._promise) {
164+
if (this._hasInvoked === true) {
165+
throwIfAlreadyInvoked()
166+
}
167+
this._promise = doInject(this.dispatch, this.option)
168+
this._hasInvoked = true
169+
}
170+
return this._promise[method](...args)
171+
}
172+
})
173+
93174
function isInjection (obj) {
94175
return (obj instanceof Request || obj instanceof Response)
95176
}
96177

97178
function toLowerCase (m) { return m.toLowerCase() }
98179

180+
function throwIfAlreadyInvoked () {
181+
throw new Error('The dispatch function has already been invoked')
182+
}
183+
99184
module.exports = inject
100185
module.exports.isInjection = isInjection

test/async-await.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict'
22

33
function asyncAwaitTest (t, inject) {
4-
t.plan(2)
4+
t.plan(3)
55

66
t.test('basic async await', async t => {
77
const dispatch = function (req, res) {
@@ -29,6 +29,21 @@ function asyncAwaitTest (t, inject) {
2929
t.ok(err)
3030
}
3131
})
32+
33+
t.test('chainable api with async await', async t => {
34+
const dispatch = function (req, res) {
35+
res.writeHead(200, { 'Content-Type': 'text/plain' })
36+
res.end('hello')
37+
}
38+
39+
try {
40+
const chain = inject(dispatch).get('http://example.com:8080/hello')
41+
const res = await chain.end()
42+
t.equal(res.payload, 'hello')
43+
} catch (err) {
44+
t.fail(err)
45+
}
46+
})
3247
}
3348

3449
module.exports = asyncAwaitTest

test/index.test-d.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { inject, isInjection, Request, Response, DispatchFunc, InjectOptions } from '../index'
1+
import { inject, isInjection, Request, Response, DispatchFunc, InjectOptions, Chain } from '../index'
22
import { expectType } from 'tsd'
33

44
expectType<InjectOptions>({ url: '/' })
@@ -21,6 +21,15 @@ inject(dispatch, { method: 'get', url: '/' }, (err, res) => {
2121
expectType<Function>(res.json)
2222
})
2323

24-
expectType<Promise<Response>>(inject(dispatch, { method: 'get', url: '/' }))
24+
inject(dispatch)
25+
.get('/')
26+
.end((err, res) => {
27+
expectType<Error>(err)
28+
expectType<Response>(res)
29+
console.log(res.payload)
30+
})
31+
32+
expectType<Chain>(inject(dispatch))
33+
expectType<Chain>(inject(dispatch, { method: 'get', url: '/' }))
2534
// @ts-ignore tsd supports top-level await, but normal ts does not so ignore
2635
expectType<Response>(await inject(dispatch, { method: 'get', url: '/' }))

0 commit comments

Comments
 (0)