Skip to content

Commit

Permalink
Simplify middleware approach using Koa-like fns
Browse files Browse the repository at this point in the history
Closes #48
  • Loading branch information
blakeembrey committed Apr 5, 2016
1 parent 8941dcd commit f763eb6
Show file tree
Hide file tree
Showing 10 changed files with 324 additions and 480 deletions.
23 changes: 9 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Build status][travis-image]][travis-url]
[![Test coverage][coveralls-image]][coveralls-url]

**Popsicle** is the easiest way to make HTTP requests - offering a consistent, intuitive and light-weight API that works on node and the browser.
> **Popsicle** is the easiest way to make HTTP requests - offering a consistent, intuitive and light-weight API that works on node and the browser.
```js
popsicle.get('/users.json')
Expand Down Expand Up @@ -254,7 +254,7 @@ popsicle.get('/users')
})
```

If you live on the edge, try using it with generators (see [co](https://www.npmjs.com/package/co)) or ES7's `async`.
If you live on the edge, try using it with generators (see [co](https://www.npmjs.com/package/co)) or ES7 `async`/`await`.

```js
co(function * () {
Expand Down Expand Up @@ -330,12 +330,13 @@ Plugins can be passed in as an array with the initial options (which overrides d

#### Creating Plugins

Plugins must be a function that accepts configuration and returns another function. For example, here's a basic URL prefix plugin.
Plugins must be a function that accept config and return a middleware function. For example, here's a basic URL prefix plugin.

```js
function prefix (url) {
return function (self) {
request.url = url + req.url
return function (self, next) {
self.url = url + self.url
return next()
}
}

Expand All @@ -346,13 +347,7 @@ popsicle.request('/user')
})
```

Popsicle also has a way modify the request and response lifecycle, if needed. Any registered function can return a promise to defer the request or response resolution. This makes plugins such as rate-limiting and response body concatenation possible.

* **before(fn)** Register a function to run before the request is made
* **after(fn)** Register a function to receive the response object
* **always(fn)** Register a function that always runs on `resolve` or `reject`

**Tip:** Use the lifecycle hooks (above) when you want re-use (E.g. re-use when the request is cloned or options re-used).
Middleware functions accept two arguments - the current request and a function to proceed to the next middleware function (a la Koa `2.x`).

#### Checking The Environment

Expand All @@ -362,11 +357,11 @@ popsicle.browser //=> true

#### Transportation Layers

Creating a custom transportation layer is just a matter creating an object with `open`, `abort` and `use` options set. The open method should set any request information required between called as `request.raw`. Abort must abort the current request instance, while `open` must **always** resolve the promise. You can set `use` to an empty array if no plugins should be used by default. However, it's recommended you keep `use` set to the defaults, or as close as possible using your transport layer.
Creating a custom transportation layer is just a matter creating an object with `open`, `abort` and `use` options set. The open method should set any request information required between called as `request._raw`. Abort must abort the current request instance, while `open` must **always** resolve to a promise. You can set `use` to an empty array if no plugins should be used by default. However, it's recommended you keep `use` set to the defaults, or as close as possible using your transport layer.

## TypeScript

This project is written using [TypeScript](https://github.com/Microsoft/TypeScript) and [typings](https://github.com/typings/typings). From version `1.3.1`, you can install the type definition using `typings`.
This project is written using [TypeScript](https://github.com/Microsoft/TypeScript) and [typings](https://github.com/typings/typings). Since version `1.3.1`, you can install the type definition using `typings`.

```
typings install npm:popsicle --save
Expand Down
4 changes: 2 additions & 2 deletions lib/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function open (request: Request) {
return reject(request.error(`The request to "${url}" was blocked`, 'EBLOCKED'))
}

const xhr = request.raw = new XMLHttpRequest()
const xhr = request._raw = new XMLHttpRequest()

xhr.onload = function () {
return resolve({
Expand Down Expand Up @@ -96,7 +96,7 @@ function open (request: Request) {
* Close the current HTTP request.
*/
function abort (request: Request) {
request.raw.abort()
request._raw.abort()
}

/**
Expand Down
82 changes: 47 additions & 35 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function open (request: Request) {
const maxRedirects = num(options.maxRedirects, 5)
const followRedirects = options.followRedirects !== false
let requestCount = 0
let isStreaming = false

const confirmRedirect = typeof options.followRedirects === 'function' ?
options.followRedirects : falsey
Expand Down Expand Up @@ -76,39 +77,39 @@ function open (request: Request) {
arg.cert = options.cert
arg.key = options.key

const req = engine(arg)
const rawRequest = engine(arg)

// Track upload progress through a stream.
const requestProxy = new PassThrough()
const responseProxy = new PassThrough()
const requestStream = new PassThrough()
const responseStream = new PassThrough()

requestProxy.on('data', function (chunk: Buffer) {
requestStream.on('data', function (chunk: Buffer) {
request.uploadedBytes = request.uploadedBytes + chunk.length
})

requestProxy.on('end', function () {
requestStream.on('end', function () {
request.uploadedBytes = request.uploadLength
})

responseProxy.on('data', function (chunk: Buffer) {
responseStream.on('data', function (chunk: Buffer) {
request.downloadedBytes = request.downloadedBytes + chunk.length
})

responseProxy.on('end', function () {
responseStream.on('end', function () {
request.downloadedBytes = request.downloadLength
})

// Handle the HTTP response.
function response (res: IncomingMessage) {
const status = res.statusCode
function response (rawResponse: IncomingMessage) {
const status = rawResponse.statusCode
const redirect = REDIRECT_STATUS[status]

// Handle HTTP redirects.
if (followRedirects && redirect != null && res.headers.location) {
const newUrl = urlLib.resolve(url, res.headers.location)
if (followRedirects && redirect != null && rawResponse.headers.location) {
const newUrl = urlLib.resolve(url, rawResponse.headers.location)

// Ignore the result of the response on redirect.
res.resume()
rawResponse.resume()

// Kill the old cookies on redirect.
request.remove('Cookie')
Expand All @@ -127,57 +128,68 @@ function open (request: Request) {
}

// Allow the user to confirm redirect according to HTTP spec.
if (confirmRedirect(req, res)) {
if (confirmRedirect(rawRequest, rawResponse)) {
return get(newUrl, method, body)
}
}
}

request.downloadLength = num(res.headers['content-length'], 0)
// When the request is streaming already, errors need to be handled differently.
isStreaming = true

request.downloadLength = num(rawResponse.headers['content-length'], 0)

// Track download progress.
res.pipe(responseProxy)
rawResponse.pipe(responseStream)

return Promise.resolve({
body: responseProxy,
body: responseStream,
status: status,
statusText: res.statusMessage,
headers: res.headers,
rawHeaders: res.rawHeaders,
statusText: rawResponse.statusMessage,
headers: rawResponse.headers,
rawHeaders: rawResponse.rawHeaders,
url: url
})
}

// Handle the response.
req.once('response', function (message: IncomingMessage) {
return resolve(setCookies(request, message).then(() => response(message)))
// Emit an error event.
function emitError (error: Error) {
if (isStreaming) {
responseStream.emit('error', error)
} else {
reject(error)
}
}

rawRequest.once('response', function (message: IncomingMessage) {
resolve(setCookies(request, message).then(() => response(message)))
})

// io.js has an abort event instead of "error".
req.once('abort', function () {
return reject(request.error('Request aborted', 'EABORT'))
// Emit the abort error back through the stream.
rawRequest.once('abort', function () {
emitError(request.error('Request aborted', 'EABORT'))
})

req.once('error', function (error: Error) {
return reject(request.error(`Unable to connect to "${url}"`, 'EUNAVAILABLE', error))
rawRequest.once('error', function (error: Error) {
emitError(request.error(`Unable to connect to "${url}"`, 'EUNAVAILABLE', error))
})

// Node 0.10 needs to catch errors on the request proxy.
requestProxy.once('error', reject)
requestStream.once('error', reject)

request.raw = req
request.uploadLength = num(req.getHeader('content-length'), 0)
requestProxy.pipe(req)
request._raw = rawRequest
request.uploadLength = num(rawRequest.getHeader('content-length'), 0)
requestStream.pipe(rawRequest)

// Pipe the body to the stream.
if (body) {
if (typeof body.pipe === 'function') {
body.pipe(requestProxy)
body.pipe(requestStream)
} else {
requestProxy.end(body)
requestStream.end(body)
}
} else {
requestProxy.end()
requestStream.end()
}
})
})
Expand All @@ -190,7 +202,7 @@ function open (request: Request) {
* Close the current HTTP request.
*/
function abort (request: Request) {
request.raw.abort()
request._raw.abort()
}

/**
Expand Down
107 changes: 48 additions & 59 deletions lib/plugins/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,16 @@ if (process.browser) {
}

/**
* Set up default headers for requests.
* Simply wrap a value and return it.
*/
function defaultHeaders (request: Request) {
export function wrap <T> (value: T): () => T {
return () => value
}

/**
* Remove default headers.
*/
export const headers = wrap(function (request: Request, next: () => Promise<Response>) {
// If we have no accept header set already, default to accepting
// everything. This is needed because otherwise Firefox defaults to
// an accept header of `html/xml`.
Expand All @@ -47,23 +54,25 @@ function defaultHeaders (request: Request) {

// Remove headers that should never be set by the user.
request.remove('Host')
}

return next()
})

/**
* Stringify known contents and mime types.
* Stringify the request body.
*/
function stringifyRequest (request: Request) {
export const stringify = wrap(function (request: Request, next: () => Promise<Response>) {
const { body } = request

// Convert primitives types into strings.
if (Object(body) !== body) {
request.body = body == null ? null : String(body)

return
return next()
}

if (isHostObject(body)) {
return
return next()
}

let type = request.type()
Expand Down Expand Up @@ -93,60 +102,40 @@ function stringifyRequest (request: Request) {
if (request.body instanceof FormData) {
request.remove('Content-Type')
}
}

/**
* Parse the response automatically.
*/
function parseResponse (response: Response) {
const body = response.body

if (typeof body !== 'string') {
return
}

if (body === '') {
response.body = null

return
}

const type = response.type()

try {
if (JSON_MIME_REGEXP.test(type)) {
response.body = body === '' ? null : JSON.parse(body)
} else if (QUERY_MIME_REGEXP.test(type)) {
response.body = parseQuery(body)
}
} catch (err) {
return Promise.reject(response.error('Unable to parse response body: ' + err.message, 'EPARSE', err))
}
}

/**
* Remove default headers.
*/
export function headers () {
return function (request: Request) {
request.before(defaultHeaders)
}
}

/**
* Stringify the request body.
*/
export function stringify () {
return function (request: Request) {
request.before(stringifyRequest)
}
}
return next()
})

/**
* Automatic stringification and parsing middleware.
*/
export function parse () {
return function (request: Request) {
request.after(parseResponse)
}
}
export const parse = wrap(function (request: Request, next: () => Promise<Response>) {
return next()
.then(function (response) {
const { body } = response

if (typeof body !== 'string') {
return response
}

if (body === '') {
response.body = null

return response
}

const type = response.type()

try {
if (JSON_MIME_REGEXP.test(type)) {
response.body = body === '' ? null : JSON.parse(body)
} else if (QUERY_MIME_REGEXP.test(type)) {
response.body = parseQuery(body)
}
} catch (err) {
return Promise.reject(request.error('Unable to parse response body: ' + err.message, 'EPARSE', err))
}

return response
})
})
Loading

0 comments on commit f763eb6

Please sign in to comment.