add koa Request / Response objects and delegation. Closes #52

This commit is contained in:
TJ Holowaychuk 2013-11-13 09:01:15 -08:00
parent 4cc5b30fb1
commit c699c75c52
38 changed files with 2967 additions and 2280 deletions

View file

@ -3,6 +3,9 @@ test:
@NODE_ENV=test ./node_modules/.bin/mocha \ @NODE_ENV=test ./node_modules/.bin/mocha \
--require should \ --require should \
--harmony-generators \ --harmony-generators \
test/context/* \
test/request/* \
test/response/* \
--bail --bail
bench: bench:

View file

@ -26,7 +26,7 @@ alias node='node --harmony-generators'
## Community ## Community
- [API](docs/api.md) documentation - [API](docs/api/index.md) documentation
- [Middleware](https://github.com/koajs/koa/wiki) list - [Middleware](https://github.com/koajs/koa/wiki) list
- [Wiki](https://github.com/koajs/koa/wiki) - [Wiki](https://github.com/koajs/koa/wiki)
- [G+ Community](https://plus.google.com/communities/101845768320796750641) - [G+ Community](https://plus.google.com/communities/101845768320796750641)

View file

@ -1,661 +0,0 @@
## Application
A Koa application is not a 1-to-1 representation of an HTTP server,
as one or more Koa applications may be mounted together to form larger
applications, with a single HTTP server.
The following is a useless Koa application bound to port `3000`:
```js
var koa = require('koa');
var app = koa();
app.listen(3000);
```
The `app.listen(...)` method is simply sugar for the following:
```js
var http = require('http');
var koa = require('koa');
var app = koa();
http.createServer(app.callback()).listen(3000);
```
This means you can spin up the same application as both HTTP and HTTPS,
or on multiple addresses:
```js
var http = require('http');
var koa = require('koa');
var app = koa();
http.createServer(app.callback()).listen(3000);
http.createServer(app.callback()).listen(3001);
```
### Settings
Application settings are properties on the `app` instance, currently
the following are supported:
- `app.name` optionally give your application a name
- `app.env` defaulting to the __NODE_ENV__ or "development"
- `app.proxy` when true proxy header fields will be trusted
- `app.subdomainOffset` offset of `.subdomains` to ignore [2]
- `app.jsonSpaces` default JSON response spaces [2]
- `app.outputErrors` output err.stack to stderr [false in "test" environment]
### app.listen(...)
Create and return an HTTP server, passing the given arguments to
`Server#listen()`. These arguments are documented on [nodejs.org](http://nodejs.org/api/http.html#http_server_listen_port_hostname_backlog_callback).
### app.callback()
Return a callback function suitable for the `http.createServer()`
method to handle a request.
### app.use(function)
Add the given middleware function to this application. See [Middleware](#middleware) for
more information.
## Context
A Koa Context encapsulates node's `request` and `response` objects
into a single object which provides many helpful methods for writing
web applications and APIs.
These operations are used so frequently in HTTP server development
that they are added at this level, instead of a higher level framework,
which would force middlware to re-implement this common functionality.
A `Context` is created _per_ request, and is referenced in middleware
as the receiver, or the `this` variable.
### ctx.req
Node's `request` object.
### ctx.res
Node's `response` object.
### ctx.app
Application instance reference.
### ctx.header
Request header object.
### ctx.responseHeader
Response header object.
### ctx.method
Request method.
### ctx.method=
Set request method, useful for implementing middleware
such as `methodOverride()`.
### ctx.status
Get response status.
### ctx.status=
Set response status via numeric code or case-insensitive string:
- 100 "continue"
- 101 "switching protocols"
- 102 "processing"
- 200 "ok"
- 201 "created"
- 202 "accepted"
- 203 "non-authoritative information"
- 204 "no content"
- 205 "reset content"
- 206 "partial content"
- 207 "multi-status"
- 300 "multiple choices"
- 301 "moved permanently"
- 302 "moved temporarily"
- 303 "see other"
- 304 "not modified"
- 305 "use proxy"
- 307 "temporary redirect"
- 400 "bad request"
- 401 "unauthorized"
- 402 "payment required"
- 403 "forbidden"
- 404 "not found"
- 405 "method not allowed"
- 406 "not acceptable"
- 407 "proxy authentication required"
- 408 "request time-out"
- 409 "conflict"
- 410 "gone"
- 411 "length required"
- 412 "precondition failed"
- 413 "request entity too large"
- 414 "request-uri too large"
- 415 "unsupported media type"
- 416 "requested range not satisfiable"
- 417 "expectation failed"
- 418 "i'm a teapot"
- 422 "unprocessable entity"
- 423 "locked"
- 424 "failed dependency"
- 425 "unordered collection"
- 426 "upgrade required"
- 428 "precondition required"
- 429 "too many requests"
- 431 "request header fields too large"
- 500 "internal server error"
- 501 "not implemented"
- 502 "bad gateway"
- 503 "service unavailable"
- 504 "gateway time-out"
- 505 "http version not supported"
- 506 "variant also negotiates"
- 507 "insufficient storage"
- 509 "bandwidth limit exceeded"
- 510 "not extended"
- 511 "network authentication required"
__NOTE__: don't worry too much about memorizing these strings,
if you have a typo an error will be thrown, displaying this list
so you can make a correction.
### ctx.length=
Set response Content-Length to the given value.
### ctx.length
Return request Content-Length as a number when present, or undefined.
### ctx.responseLength
Return response Content-Length as a number when present, or deduce
from `ctx.body` when possible, or undefined.
### ctx.body
Get response body. When `ctx.body` is `null` and `ctx.status` is still
200 it is considered a 404. This is to prevent the developer from manually
specifying `this.status = 200` on every response.
### ctx.body=
Set response body to one of the following:
- `string` written
- `Buffer` written
- `Stream` piped
- `Object` json-stringified
- `null` no content response
#### String
The Content-Type is defaulted to text/html or text/plain, both with
a default charset of utf-8. The Content-Length field is also set.
#### Buffer
The Content-Type is defaulted to application/octet-stream, and Content-Length
is also set.
#### Stream
The Content-Type is defaulted to application/octet-stream.
#### Object
The Content-Type is defaulted to application/json.
#### Notes
To alter the JSON response formatting use the `app.jsonSpaces`
setting, for example to compress JSON responses set:
```js
app.jsonSpaces = 0;
```
### ctx.get(field)
Get a request header field value with case-insensitive `field`.
```js
var etag = this.get('If-None-Match');
```
### ctx.set(field, value)
Set response header `field` to `value`:
```js
this.set('Cache-Control', 'no-cache');
```
### ctx.set(fields)
Set several response header `fields` with an object:
```js
this.set({
'Etag': '1234',
'Last-Modified': date
});
```
### ctx.type
Get request `Content-Type` void of parameters such as "charset".
```js
var ct = this.type;
// => "image/png"
```
### ctx.type=
Set response `Content-Type` via mime string or file extension.
```js
this.type = 'text/plain; charset=utf-8';
this.type = 'image/png';
this.type = '.png';
this.type = 'png';
```
Note: when appropriate a `charset` is selected for you, for
example `ctx.type = 'html'` will default to "utf-8", however
when explicitly defined in full as `ctx.type = 'text/html'`
no charset is assigned.
### ctx.url
Get request URL.
### ctx.url=
Set request URL, useful for url rewrites.
### ctx.path
Get request pathname.
### ctx.path=
Set request pathname and retain query-string when present.
### ctx.query
Get parsed query-string, returning an empty object when no
query-string is present. Note that this getter does _not_
support nested parsing.
For example "color=blue&size=small":
```js
{
color: 'blue',
size: 'small'
}
```
### ctx.query=
Set query-string to the given object. Note that this
setter does _not_ support nested objects.
```js
this.query = { next: '/login' };
```
### ctx.querystring
Get raw query string void of `?`.
### ctx.querystring=
Set raw query string.
### ctx.host
Get host void of port number when present. Supports `X-Forwarded-Host`
when `app.proxy` is __true__, otherwise `Host` is used.
### ctx.fresh
Check if a request cache is "fresh", aka the contents have not changed. This
method is for cache negotiation between `If-None-Match` / `ETag`, and `If-Modified-Since` and `Last-Modified`. It should be referenced after setting one or more of these response headers.
```js
this.set('ETag', '123');
// cache is ok
if (this.fresh) {
this.status = 304;
return;
}
// cache is stale
// fetch new data
this.body = yield db.find('something');
```
### ctx.stale
Inverse of `ctx.fresh`.
### ctx.protocol
Return request protocol, "https" or "http". Supports `X-Forwarded-Proto`
when `app.proxy` is __true__.
### ctx.secure
Shorthand for `this.protocol == "https"` to check if a requset was
issued via TLS.
### ctx.ip
Request remote address. Supports `X-Forwarded-For` when `app.proxy`
is __true__.
### ctx.ips
When `X-Forwarded-For` is present and `app.proxy` is enabled an array
of these ips is returned, ordered from upstream -> downstream. When disabled
an empty array is returned.
### ctx.subdomains
Return subdomains as an array.
Subdomains are the dot-separated parts of the host before the main domain of
the app. By default, the domain of the app is assumed to be the last two
parts of the host. This can be changed by setting `app.subdomainOffset`.
For example, if the domain is "tobi.ferrets.example.com":
If `app.subdomainOffset` is not set, this.subdomains is `["ferrets", "tobi"]`.
If `app.subdomainOffset` is 3, this.subdomains is `["tobi"]`.
### ctx.is(type)
Check if the incoming request contains the `Content-Type`
header field, and it contains the give mime `type`.
```js
// With Content-Type: text/html; charset=utf-8
this.is('html');
this.is('.html');
this.is('text/html');
this.is('text/*');
// => true
// When Content-Type is application/json
this.is('json');
this.is('.json');
this.is('application/json');
this.is('application/*');
// => true
this.is('html');
// => false
```
### ctx.redirect(url, [alt])
Perform a 302 redirect to `url`.
The string "back" is special-cased
to provide Referrer support, when Referrer
is not present `alt` or "/" is used.
```js
this.redirect('back');
this.redirect('back', '/index.html');
this.redirect('/login');
this.redirect('http://google.com');
```
To alter the default status of `302` or the response
body simply re-assign after this call:
```js
this.redirect('/cart');
this.status = 301;
this.body = 'Redirecting to shopping cart';
```
### ctx.attachment([filename])
Set `Content-Disposition` to "attachment" to signal the client
to prompt for download. Optionally specify the `filename` of the
download.
### ctx.accepts(types)
Check if the given `type(s)` is acceptable, returning
the best match when true, otherwise `undefined`, in which
case you should respond with 406 "Not Acceptable".
The `type` value may be one or more mime type string
such as "application/json", the extension name
such as "json", or an array `["json", "html", "text/plain"]`. When a list or array is given the _best_ match, if any is returned.
```js
// Accept: text/html
this.accepts('html');
// => "html"
// Accept: text/*, application/json
this.accepts('html');
// => "html"
this.accepts('text/html');
// => "text/html"
this.accepts('json', 'text');
// => "json"
this.accepts('application/json');
// => "application/json"
// Accept: text/*, application/json
this.accepts('image/png');
this.accepts('png');
// => undefined
// Accept: text/*;q=.5, application/json
this.accepts(['html', 'json']);
this.accepts('html', 'json');
// => "json"
```
You may call `this.accepts()` as may times as you like,
or use a switch:
```js
switch (this.accepts('json', 'html', 'text')) {
case 'json': break;
case 'html': break;
case 'text': break;
}
```
### ctx.acceptsEncodings(encodings)
Check if `encodings` are acceptable, returning
the best match when true, otherwise `undefined`.
```js
// Accept-Encoding: gzip
this.acceptsEncodings('gzip', 'deflate');
// => "gzip"
this.acceptsEncodings(['gzip', 'deflate']);
// => "gzip"
```
When no arguments are given all accepted encodings
are returned as an array:
```js
// Accept-Encoding: gzip, deflate
this.acceptsEncodings();
// => ["gzip", "deflate"]
```
### ctx.acceptsCharsets(charsets)
Check if `charsets` are acceptable, returning
the best match when true, otherwise `undefined`.
```js
// Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5
this.acceptsCharsets('utf-8', 'utf-7');
// => "utf-8"
this.acceptsCharsets(['utf-7', 'utf-8']);
// => "utf-8"
```
When no arguments are given all accepted charsets
are returned as an array:
```js
// Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5
this.acceptsCharsets();
// => ["utf-8", "utf-7", "iso-8859-1"]
```
### ctx.acceptsLanguages(langs)
Check if `langs` are acceptable, returning
the best match when true, otherwise `undefined`.
```js
// Accept-Language: en;q=0.8, es, pt
this.acceptsLanguages('es', 'en');
// => "es"
this.acceptsLanguages(['en', 'es']);
// => "es"
```
When no arguments are given all accepted languages
are returned as an array:
```js
// Accept-Language: en;q=0.8, es, pt
this.acceptsLanguages();
// => ["es", "pt", "en"]
```
### ctx.headerSent
Check if a response header has already been sent. Useful for seeing
if the client may be notified on error.
### ctx.cookies.get(name, [options])
Get cookie `name` with `options`:
- `signed` the cookie requested should be signed
### ctx.cookies.set(name, value, [options])
Set cookie `name` to `value` with `options`:
- `signed` sign the cookie value
- `expires` a `Date` for cookie expiration
- `path` cookie path, `/'` by default
- `domain` cookie domain
- `secure` secure cookie
- `httpOnly` server-accessible cookie, __true__ by default
### ctx.socket
Request socket object.
### ctx.error(msg, [status])
Helper method to throw an error with a `.status` property
that will allow Koa to respond appropriately. The following
combinations are allowed:
```js
this.error(403)
this.error('name required', 400)
this.error('something exploded')
```
For example `this.error('name required', 400)` is requivalent to:
```js
var err = new Error('name required');
err.status = 400;
throw err;
```
Note that these are user-level errors and are flagged with
`err.expose` meaning the messages are appropriate for
client responses, which is typically not the case for
error messages since you do not want to leak failure
details.
## Error Handling
By default outputs all errors to stderr unless __NODE_ENV__ is "test". To perform custom error-handling logic such as centralized logging you
can add an "error" event listener:
```js
app.on('error', function(err){
log.error('server error', err);
});
```
If an error in the req/res cycle and it is _not_ possible to respond to the client, the `Context` instance is also passed:
```js
app.on('error', function(err){
log.error('server error', err);
});
```
When an error occurs _and_ it is still possible to respond to the client, aka no data has been written to the socket, Koa will respond
appropriately with a 500 "Internal Server Error". In either case
an app-level "error" is emitted for logging purposes.
## Notes
### HEAD Support
Koa's upstream response middleware supports __HEAD__ for you,
however expensive requests would benefit from custom handling. For
example instead of reading a file into memory and piping it to the
client, you may wish to `stat()` and set the `Content-*` header fields
appropriately to bypass the read.
### Socket Errors
Node http servers emit a "clientError" event when a socket error occurs. You'll probably want to delegate this to your
Koa handler by doing the following, in order to centralize
logging:
```js
var app = koa();
var srv = app.listen(3000);
srv.on('clientError', function(err){
app.emit('error', err);
});
```
# License
MIT

132
docs/api/context.md Normal file
View file

@ -0,0 +1,132 @@
# Context
A Koa Context encapsulates node's `request` and `response` objects
into a single object which provides many helpful methods for writing
web applications and APIs.
Many accesors and methods simply delegate to their `ctx.request` or `ctx.response`
equivalents for convenience, and are otherwise identical.
These operations are used so frequently in HTTP server development
that they are added at this level, instead of a higher level framework,
which would force middlware to re-implement this common functionality.
A `Context` is created _per_ request, and is referenced in middleware
as the receiver, or the `this` identifier.
## Request aliases
The following accessors and alias [Request](request.md) equivalents:
- `ctx.header`
- `ctx.method`
- `ctx.method=`
- `ctx.url`
- `ctx.url=`
- `ctx.path`
- `ctx.path=`
- `ctx.query`
- `ctx.query=`
- `ctx.querystring`
- `ctx.querystring=`
- `ctx.length`
- `ctx.host`
- `ctx.fresh`
- `ctx.stale`
- `ctx.socket`
- `ctx.protocol`
- `ctx.secure`
- `ctx.ip`
- `ctx.ips`
- `ctx.subdomains`
- `ctx.is()`
- `ctx.accepts()`
- `ctx.acceptsEncodings()`
- `ctx.acceptsCharsets()`
- `ctx.acceptsLanguages()`
- `ctx.get()`
## Response aliases
The following accessors and alias [Response](response.md) equivalents:
- `ctx.body`
- `ctx.body=`
- `ctx.status`
- `ctx.status=`
- `ctx.length=`
- `ctx.type`
- `ctx.type=`
- `ctx.headerSent`
- `ctx.redirect()`
- `ctx.attachment()`
- `ctx.set()`
## API
`Context` specific methods and accessors.
### ctx.req
Node's `request` object.
### ctx.res
Node's `response` object.
### ctx.request
A koa `Request` object.
### ctx.response
A koa `Response` object.
### ctx.app
Application instance reference.
### ctx.cookies.get(name, [options])
Get cookie `name` with `options`:
- `signed` the cookie requested should be signed
### ctx.cookies.set(name, value, [options])
Set cookie `name` to `value` with `options`:
- `signed` sign the cookie value
- `expires` a `Date` for cookie expiration
- `path` cookie path, `/'` by default
- `domain` cookie domain
- `secure` secure cookie
- `httpOnly` server-accessible cookie, __true__ by default
### ctx.error(msg, [status])
Helper method to throw an error with a `.status` property
that will allow Koa to respond appropriately. The following
combinations are allowed:
```js
this.error(403)
this.error('name required', 400)
this.error('something exploded')
```
For example `this.error('name required', 400)` is requivalent to:
```js
var err = new Error('name required');
err.status = 400;
throw err;
```
Note that these are user-level errors and are flagged with
`err.expose` meaning the messages are appropriate for
client responses, which is typically not the case for
error messages since you do not want to leak failure
details.

119
docs/api/index.md Normal file
View file

@ -0,0 +1,119 @@
## Application
A Koa application is not a 1-to-1 representation of an HTTP server,
as one or more Koa applications may be mounted together to form larger
applications, with a single HTTP server.
The following is a useless Koa application bound to port `3000`:
```js
var koa = require('koa');
var app = koa();
app.listen(3000);
```
The `app.listen(...)` method is simply sugar for the following:
```js
var http = require('http');
var koa = require('koa');
var app = koa();
http.createServer(app.callback()).listen(3000);
```
This means you can spin up the same application as both HTTP and HTTPS,
or on multiple addresses:
```js
var http = require('http');
var koa = require('koa');
var app = koa();
http.createServer(app.callback()).listen(3000);
http.createServer(app.callback()).listen(3001);
```
### Settings
Application settings are properties on the `app` instance, currently
the following are supported:
- `app.name` optionally give your application a name
- `app.env` defaulting to the __NODE_ENV__ or "development"
- `app.proxy` when true proxy header fields will be trusted
- `app.subdomainOffset` offset of `.subdomains` to ignore [2]
- `app.jsonSpaces` default JSON response spaces [2]
- `app.outputErrors` output err.stack to stderr [false in "test" environment]
### app.listen(...)
Create and return an HTTP server, passing the given arguments to
`Server#listen()`. These arguments are documented on [nodejs.org](http://nodejs.org/api/http.html#http_server_listen_port_hostname_backlog_callback).
### app.callback()
Return a callback function suitable for the `http.createServer()`
method to handle a request.
### app.use(function)
Add the given middleware function to this application. See [Middleware](#middleware) for
more information.
## Handling Requests
Koa requests are manipulated using a `Context` object containing both a Koa `Request` and `Response` object. For more information on these view:
- [Context](context.md)
- [Request](request.md)
- [Response](response.md)
## Error Handling
By default outputs all errors to stderr unless __NODE_ENV__ is "test". To perform custom error-handling logic such as centralized logging you
can add an "error" event listener:
```js
app.on('error', function(err){
log.error('server error', err);
});
```
If an error in the req/res cycle and it is _not_ possible to respond to the client, the `Context` instance is also passed:
```js
app.on('error', function(err){
log.error('server error', err);
});
```
When an error occurs _and_ it is still possible to respond to the client, aka no data has been written to the socket, Koa will respond
appropriately with a 500 "Internal Server Error". In either case
an app-level "error" is emitted for logging purposes.
## Notes
### HEAD Support
Koa's upstream response middleware supports __HEAD__ for you,
however expensive requests would benefit from custom handling. For
example instead of reading a file into memory and piping it to the
client, you may wish to `stat()` and set the `Content-*` header fields
appropriately to bypass the read.
### Socket Errors
Node http servers emit a "clientError" event when a socket error occurs. You'll probably want to delegate this to your
Koa handler by doing the following, in order to centralize
logging:
```js
var app = koa();
var srv = app.listen(3000);
srv.on('clientError', function(err){
app.emit('error', err);
});
```
# License
MIT

309
docs/api/request.md Normal file
View file

@ -0,0 +1,309 @@
# Request
A Koa `Request` object is an abstraction on top of node's vanilla request object,
providing additional functionality that is useful for every day HTTP server
development.
## API
### req.header
Request header object.
### req.method
Request method.
### req.method=
Set request method, useful for implementing middleware
such as `methodOverride()`.
### req.length
Return request Content-Length as a number when present, or undefined.
### req.type
Get request `Content-Type` void of parameters such as "charset".
```js
var ct = this.type;
// => "image/png"
```
### req.url
Get request URL.
### req.url=
Set request URL, useful for url rewrites.
### req.path
Get request pathname.
### req.path=
Set request pathname and retain query-string when present.
### req.query
Get parsed query-string, returning an empty object when no
query-string is present. Note that this getter does _not_
support nested parsing.
For example "color=blue&size=small":
```js
{
color: 'blue',
size: 'small'
}
```
### req.query=
Set query-string to the given object. Note that this
setter does _not_ support nested objects.
```js
this.query = { next: '/login' };
```
### req.querystring
Get raw query string void of `?`.
### req.querystring=
Set raw query string.
### req.host
Get host void of port number when present. Supports `X-Forwarded-Host`
when `app.proxy` is __true__, otherwise `Host` is used.
### req.fresh
Check if a request cache is "fresh", aka the contents have not changed. This
method is for cache negotiation between `If-None-Match` / `ETag`, and `If-Modified-Since` and `Last-Modified`. It should be referenced after setting one or more of these response headers.
```js
this.set('ETag', '123');
// cache is ok
if (this.fresh) {
this.status = 304;
return;
}
// cache is stale
// fetch new data
this.body = yield db.find('something');
```
### req.stale
Inverse of `req.fresh`.
### req.protocol
Return request protocol, "https" or "http". Supports `X-Forwarded-Proto`
when `app.proxy` is __true__.
### req.secure
Shorthand for `this.protocol == "https"` to check if a requset was
issued via TLS.
### req.ip
Request remote address. Supports `X-Forwarded-For` when `app.proxy`
is __true__.
### req.ips
When `X-Forwarded-For` is present and `app.proxy` is enabled an array
of these ips is returned, ordered from upstream -> downstream. When disabled
an empty array is returned.
### req.subdomains
Return subdomains as an array.
Subdomains are the dot-separated parts of the host before the main domain of
the app. By default, the domain of the app is assumed to be the last two
parts of the host. This can be changed by setting `app.subdomainOffset`.
For example, if the domain is "tobi.ferrets.example.com":
If `app.subdomainOffset` is not set, this.subdomains is `["ferrets", "tobi"]`.
If `app.subdomainOffset` is 3, this.subdomains is `["tobi"]`.
### req.is(type)
Check if the incoming request contains the `Content-Type`
header field, and it contains the give mime `type`.
```js
// With Content-Type: text/html; charset=utf-8
this.is('html');
this.is('.html');
this.is('text/html');
this.is('text/*');
// => true
// When Content-Type is application/json
this.is('json');
this.is('.json');
this.is('application/json');
this.is('application/*');
// => true
this.is('html');
// => false
```
### req.accepts(types)
Check if the given `type(s)` is acceptable, returning
the best match when true, otherwise `undefined`, in which
case you should respond with 406 "Not Acceptable".
The `type` value may be one or more mime type string
such as "application/json", the extension name
such as "json", or an array `["json", "html", "text/plain"]`. When a list or array is given the _best_ match, if any is returned.
```js
// Accept: text/html
this.accepts('html');
// => "html"
// Accept: text/*, application/json
this.accepts('html');
// => "html"
this.accepts('text/html');
// => "text/html"
this.accepts('json', 'text');
// => "json"
this.accepts('application/json');
// => "application/json"
// Accept: text/*, application/json
this.accepts('image/png');
this.accepts('png');
// => undefined
// Accept: text/*;q=.5, application/json
this.accepts(['html', 'json']);
this.accepts('html', 'json');
// => "json"
```
You may call `this.accepts()` as may times as you like,
or use a switch:
```js
switch (this.accepts('json', 'html', 'text')) {
case 'json': break;
case 'html': break;
case 'text': break;
}
```
### req.acceptsEncodings(encodings)
Check if `encodings` are acceptable, returning
the best match when true, otherwise `undefined`.
```js
// Accept-Encoding: gzip
this.acceptsEncodings('gzip', 'deflate');
// => "gzip"
this.acceptsEncodings(['gzip', 'deflate']);
// => "gzip"
```
When no arguments are given all accepted encodings
are returned as an array:
```js
// Accept-Encoding: gzip, deflate
this.acceptsEncodings();
// => ["gzip", "deflate"]
```
### req.acceptsCharsets(charsets)
Check if `charsets` are acceptable, returning
the best match when true, otherwise `undefined`.
```js
// Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5
this.acceptsCharsets('utf-8', 'utf-7');
// => "utf-8"
this.acceptsCharsets(['utf-7', 'utf-8']);
// => "utf-8"
```
When no arguments are given all accepted charsets
are returned as an array:
```js
// Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5
this.acceptsCharsets();
// => ["utf-8", "utf-7", "iso-8859-1"]
```
### req.acceptsLanguages(langs)
Check if `langs` are acceptable, returning
the best match when true, otherwise `undefined`.
```js
// Accept-Language: en;q=0.8, es, pt
this.acceptsLanguages('es', 'en');
// => "es"
this.acceptsLanguages(['en', 'es']);
// => "es"
```
When no arguments are given all accepted languages
are returned as an array:
```js
// Accept-Language: en;q=0.8, es, pt
this.acceptsLanguages();
// => ["es", "pt", "en"]
```
### req.error(msg, [status])
Helper method to throw an error with a `.status` property
that will allow Koa to respond appropriately. The following
combinations are allowed:
```js
this.error(403)
this.error('name required', 400)
this.error('something exploded')
```
For example `this.error('name required', 400)` is requivalent to:
```js
var err = new Error('name required');
err.status = 400;
throw err;
```
Note that these are user-level errors and are flagged with
`err.expose` meaning the messages are appropriate for
client responses, which is typically not the case for
error messages since you do not want to leak failure
details.

221
docs/api/response.md Normal file
View file

@ -0,0 +1,221 @@
# Response
A Koa `Response` object is an abstraction on top of node's vanilla response object,
providing additional functionality that is useful for every day HTTP server
development.
## API
### res.header
Response header object.
### res.status
Get response status.
### res.status=
Set response status via numeric code or case-insensitive string:
- 100 "continue"
- 101 "switching protocols"
- 102 "processing"
- 200 "ok"
- 201 "created"
- 202 "accepted"
- 203 "non-authoritative information"
- 204 "no content"
- 205 "reset content"
- 206 "partial content"
- 207 "multi-status"
- 300 "multiple choices"
- 301 "moved permanently"
- 302 "moved temporarily"
- 303 "see other"
- 304 "not modified"
- 305 "use proxy"
- 307 "temporary redirect"
- 400 "bad request"
- 401 "unauthorized"
- 402 "payment required"
- 403 "forbidden"
- 404 "not found"
- 405 "method not allowed"
- 406 "not acceptable"
- 407 "proxy authentication required"
- 408 "request time-out"
- 409 "conflict"
- 410 "gone"
- 411 "length required"
- 412 "precondition failed"
- 413 "request entity too large"
- 414 "request-uri too large"
- 415 "unsupported media type"
- 416 "requested range not satisfiable"
- 417 "expectation failed"
- 418 "i'm a teapot"
- 422 "unprocessable entity"
- 423 "locked"
- 424 "failed dependency"
- 425 "unordered collection"
- 426 "upgrade required"
- 428 "precondition required"
- 429 "too many requests"
- 431 "request header fields too large"
- 500 "internal server error"
- 501 "not implemented"
- 502 "bad gateway"
- 503 "service unavailable"
- 504 "gateway time-out"
- 505 "http version not supported"
- 506 "variant also negotiates"
- 507 "insufficient storage"
- 509 "bandwidth limit exceeded"
- 510 "not extended"
- 511 "network authentication required"
__NOTE__: don't worry too much about memorizing these strings,
if you have a typo an error will be thrown, displaying this list
so you can make a correction.
### res.length=
Set response Content-Length to the given value.
### res.length
Return response Content-Length as a number when present, or deduce
from `res.body` when possible, or undefined.
### res.body
Get response body. When `res.body` is `null` and `res.status` is still
200 it is considered a 404. This is to prevent the developer from manually
specifying `this.status = 200` on every response.
### res.body=
Set response body to one of the following:
- `string` written
- `Buffer` written
- `Stream` piped
- `Object` json-stringified
- `null` no content response
#### String
The Content-Type is defaulted to text/html or text/plain, both with
a default charset of utf-8. The Content-Length field is also set.
#### Buffer
The Content-Type is defaulted to application/octet-stream, and Content-Length
is also set.
#### Stream
The Content-Type is defaulted to application/octet-stream.
#### Object
The Content-Type is defaulted to application/json.
#### Notes
To alter the JSON response formatting use the `app.jsonSpaces`
setting, for example to compress JSON responses set:
```js
app.jsonSpaces = 0;
```
### res.get(field)
Get a response header field value with case-insensitive `field`.
```js
var etag = this.get('ETag');
```
### res.set(field, value)
Set response header `field` to `value`:
```js
this.set('Cache-Control', 'no-cache');
```
### res.set(fields)
Set several response header `fields` with an object:
```js
this.set({
'Etag': '1234',
'Last-Modified': date
});
```
### res.type
Get response `Content-Type` void of parameters such as "charset".
```js
var ct = this.type;
// => "image/png"
```
### res.type=
Set response `Content-Type` via mime string or file extension.
```js
this.type = 'text/plain; charset=utf-8';
this.type = 'image/png';
this.type = '.png';
this.type = 'png';
```
Note: when appropriate a `charset` is selected for you, for
example `res.type = 'html'` will default to "utf-8", however
when explicitly defined in full as `res.type = 'text/html'`
no charset is assigned.
### res.redirect(url, [alt])
Perform a 302 redirect to `url`.
The string "back" is special-cased
to provide Referrer support, when Referrer
is not present `alt` or "/" is used.
```js
this.redirect('back');
this.redirect('back', '/index.html');
this.redirect('/login');
this.redirect('http://google.com');
```
To alter the default status of `302` or the response
body simply re-assign after this call:
```js
this.redirect('/cart');
this.status = 301;
this.body = 'Redirecting to shopping cart';
```
### res.attachment([filename])
Set `Content-Disposition` to "attachment" to signal the client
to prompt for download. Optionally specify the `filename` of the
download.
### res.headerSent
Check if a response header has already been sent. Useful for seeing
if the client may be notified on error.

File diff suppressed because it is too large Load diff

612
lib/request.js Normal file
View file

@ -0,0 +1,612 @@
/**
* Module dependencies.
*/
var debug = require('debug')('koa:context');
var Negotiator = require('negotiator');
var statuses = require('./status');
var Cookies = require('cookies');
var qs = require('querystring');
var Stream = require('stream');
var fresh = require('fresh');
var http = require('http');
var path = require('path');
var mime = require('mime');
var basename = path.basename;
var extname = path.extname;
var url = require('url');
var parse = url.parse;
var stringify = url.format;
/**
* Expose `Request`.
*/
module.exports = Request;
/**
* Initialize a new Request.
*
* @api private
*/
function Request(ctx){
this.app = ctx.app;
this.req = ctx.req;
this.ctx = ctx;
}
/**
* Prototype.
*/
Request.prototype = {
/**
* Return request header.
*
* @return {Object}
* @api public
*/
get header() {
return this.req.headers;
},
/**
* Get request URL.
*
* @return {String}
* @api public
*/
get url() {
return this.req.url;
},
/**
* Set request URL.
*
* @api public
*/
set url(val) {
this.req.url = val;
},
/**
* Get request method.
*
* @return {String}
* @api public
*/
get method() {
return this.req.method;
},
/**
* Set request method.
*
* @param {String} val
* @api public
*/
set method(val) {
this.req.method = val;
},
/**
* Get request pathname.
*
* @return {String}
* @api public
*/
get path() {
var c = this._pathcache = this._pathcache || {};
return c[this.url] || (c[this.url] = parse(this.url).pathname);
},
/**
* Set pathname, retaining the query-string when present.
*
* @param {String} path
* @api public
*/
set path(path) {
var url = parse(this.url);
url.pathname = path;
this.url = stringify(url);
},
/**
* Get parsed query-string.
*
* @return {Object}
* @api public
*/
get query() {
var str = this.querystring;
if (!str) return {};
var c = this._querycache = this._querycache || {};
return c[str] || (c[str] = qs.parse(str));
},
/**
* Set query-string as an object.
*
* @param {Object} obj
* @api public
*/
set query(obj) {
this.querystring = qs.stringify(obj);
},
/**
* Get query string.
*
* @return {String}
* @api public
*/
get querystring() {
var c = this._qscache = this._qscache || {};
return c[this.url] || (c[this.url] = parse(this.url).query || '');
},
/**
* Set querystring.
*
* @param {String} str
* @api public
*/
set querystring(str) {
var url = parse(this.url);
url.search = str;
this.url = stringify(url);
},
/**
* Parse the "Host" header field hostname
* and support X-Forwarded-Host when a
* proxy is enabled.
*
* @return {String}
* @api public
*/
get host() {
var proxy = this.app.proxy;
var host = proxy && this.get('X-Forwarded-Host');
host = host || this.get('Host');
if (!host) return;
return host.split(/\s*,\s*/)[0].split(':')[0];
},
/**
* Check if the request is fresh, aka
* Last-Modified and/or the ETag
* still match.
*
* @return {Boolean}
* @api public
*/
get fresh() {
var method = this.method;
var s = this.ctx.status;
// GET or HEAD for weak freshness validation only
if ('GET' != method && 'HEAD' != method) return false;
// 2xx or 304 as per rfc2616 14.26
if ((s >= 200 && s < 300) || 304 == s) {
return fresh(this.header, this.ctx.response.header);
}
return false;
},
/**
* Check if the request is stale, aka
* "Last-Modified" and / or the "ETag" for the
* resource has changed.
*
* @return {Boolean}
* @api public
*/
get stale() {
return !this.fresh;
},
/**
* Check if the request is idempotent.
*
* @return {Boolean}
* @api public
*/
get idempotent() {
return 'GET' == this.method
|| 'HEAD' == this.method;
},
/**
* Return the request socket.
*
* @return {Connection}
* @api public
*/
get socket() {
// TODO: TLS
return this.req.socket;
},
/**
* Return parsed Content-Length when present.
*
* @return {Number}
* @api public
*/
get length() {
var len = this.get('Content-Length');
if (null == len) return;
return ~~len;
},
/**
* Return the protocol string "http" or "https"
* when requested with TLS. When the proxy setting
* is enabled the "X-Forwarded-Proto" header
* field will be trusted. If you're running behind
* a reverse proxy that supplies https for you this
* may be enabled.
*
* @return {String}
* @api public
*/
get protocol() {
var proxy = this.app.proxy;
if (this.socket.encrypted) return 'https';
if (!proxy) return 'http';
var proto = this.get('X-Forwarded-Proto') || 'http';
return proto.split(/\s*,\s*/)[0];
},
/**
* Short-hand for:
*
* this.protocol == 'https'
*
* @return {Boolean}
* @api public
*/
get secure() {
return 'https' == this.protocol;
},
/**
* Return the remote address, or when
* `app.proxy` is `true` return
* the upstream addr.
*
* @return {String}
* @api public
*/
get ip() {
return this.ips[0] || this.connection.remoteAddress;
},
/**
* When `app.proxy` is `true`, parse
* the "X-Forwarded-For" ip address list.
*
* For example if the value were "client, proxy1, proxy2"
* you would receive the array `["client", "proxy1", "proxy2"]`
* where "proxy2" is the furthest down-stream.
*
* @return {Array}
* @api public
*/
get ips() {
var proxy = this.app.proxy;
var val = this.get('X-Forwarded-For');
return proxy && val
? val.split(/ *, */)
: [];
},
/**
* Return subdomains as an array.
*
* Subdomains are the dot-separated parts of the host before the main domain of
* the app. By default, the domain of the app is assumed to be the last two
* parts of the host. This can be changed by setting `app.subdomainOffset`.
*
* For example, if the domain is "tobi.ferrets.example.com":
* If `app.subdomainOffset` is not set, this.subdomains is `["ferrets", "tobi"]`.
* If `app.subdomainOffset` is 3, this.subdomains is `["tobi"]`.
*
* @return {Array}
* @api public
*/
get subdomains() {
var offset = this.app.subdomainOffset;
return (this.host || '')
.split('.')
.reverse()
.slice(offset);
},
/**
* Check if the given `type(s)` is acceptable, returning
* the best match when true, otherwise `undefined`, in which
* case you should respond with 406 "Not Acceptable".
*
* The `type` value may be a single mime type string
* such as "application/json", the extension name
* such as "json" or an array `["json", "html", "text/plain"]`. When a list
* or array is given the _best_ match, if any is returned.
*
* Examples:
*
* // Accept: text/html
* this.accepts('html');
* // => "html"
*
* // Accept: text/*, application/json
* this.accepts('html');
* // => "html"
* this.accepts('text/html');
* // => "text/html"
* this.accepts('json', 'text');
* // => "json"
* this.accepts('application/json');
* // => "application/json"
*
* // Accept: text/*, application/json
* this.accepts('image/png');
* this.accepts('png');
* // => undefined
*
* // Accept: text/*;q=.5, application/json
* this.accepts(['html', 'json']);
* this.accepts('html', 'json');
* // => "json"
*
* @param {String|Array} type(s)...
* @return {String|Array|Boolean}
* @api public
*/
accepts: function(types){
if (!Array.isArray(types)) types = [].slice.call(arguments);
var n = new Negotiator(this.req);
if (!types.length) return n.preferredMediaTypes();
var mimes = types.map(extToMime);
var accepts = n.preferredMediaTypes(mimes);
var first = accepts[0];
if (!first) return false;
return types[mimes.indexOf(first)];
},
/**
* Return accepted encodings or best fit based on `encodings`.
*
* Given `Accept-Encoding: gzip, deflate`
* an array sorted by quality is returned:
*
* ['gzip', 'deflate']
*
* @param {String|Array} encoding(s)...
* @return {String|Array}
* @api public
*/
acceptsEncodings: function(encodings) {
if (!Array.isArray(encodings)) encodings = [].slice.call(arguments);
var n = new Negotiator(this.req);
if (!encodings.length) return n.preferredEncodings();
return n.preferredEncodings(encodings)[0];
},
/**
* Return accepted charsets or best fit based on `charsets`.
*
* Given `Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5`
* an array sorted by quality is returned:
*
* ['utf-8', 'utf-7', 'iso-8859-1']
*
* @param {String|Array} charset(s)...
* @return {String|Array}
* @api public
*/
acceptsCharsets: function(charsets) {
if (!Array.isArray(charsets)) charsets = [].slice.call(arguments);
var n = new Negotiator(this.req);
if (!charsets.length) return n.preferredCharsets();
return n.preferredCharsets(charsets)[0];
},
/**
* Return accepted languages or best fit based on `langs`.
*
* Given `Accept-Language: en;q=0.8, es, pt`
* an array sorted by quality is returned:
*
* ['es', 'pt', 'en']
*
* @param {String|Array} lang(s)...
* @return {Array|String}
* @api public
*/
acceptsLanguages: function(langs) {
if (!Array.isArray(langs)) langs = [].slice.call(arguments);
var n = new Negotiator(this.req);
if (!langs.length) return n.preferredLanguages();
return n.preferredLanguages(langs)[0];
},
/**
* Vary on `field`.
*
* @param {String} field
* @api public
*/
vary: function(field){
this.append('Vary', field);
},
/**
* Check if the incoming request contains the "Content-Type"
* header field, and it contains the give mime `type`.
*
* Examples:
*
* // With Content-Type: text/html; charset=utf-8
* this.is('html');
* this.is('text/html');
* this.is('text/*');
* // => true
*
* // When Content-Type is application/json
* this.is('json');
* this.is('application/json');
* this.is('application/*');
* // => true
*
* this.is('html');
* // => false
*
* @param {String} type
* @return {Boolean}
* @api public
*/
is: function(type){
var ct = this.type;
if (!ct) return false;
ct = ct.split(';')[0];
// extension given
if (!~type.indexOf('/')) type = mime.lookup(type);
// type or subtype match
if (~type.indexOf('*')) {
type = type.split('/');
ct = ct.split('/');
if ('*' == type[0] && type[1] == ct[1]) return true;
if ('*' == type[1] && type[0] == ct[0]) return true;
return false;
}
// exact match
return type == ct;
},
/**
* Return the request mime type void of
* parameters such as "charset".
*
* @return {String}
* @api public
*/
get type() {
var type = this.get('Content-Type');
if (!type) return;
return type.split(';')[0];
},
/**
* Return request header.
*
* The `Referrer` header field is special-cased,
* both `Referrer` and `Referer` are interchangeable.
*
* Examples:
*
* this.get('Content-Type');
* // => "text/plain"
*
* this.get('content-type');
* // => "text/plain"
*
* this.get('Something');
* // => undefined
*
* @param {String} name
* @return {String}
* @api public
*/
get: function(name){
var req = this.req;
switch (name = name.toLowerCase()) {
case 'referer':
case 'referrer':
return req.headers.referrer || req.headers.referer;
default:
return req.headers[name];
}
},
/**
* Inspect implementation.
*
* TODO: add tests
*
* @return {Object}
* @api public
*/
inspect: function(){
return this.toJSON();
},
/**
* Return JSON representation.
*
* @return {Object}
* @api public
*/
toJSON: function(){
return {
method: this.method,
header: this.header
}
}
};
/**
* Convert extnames to mime.
*
* @param {String} type
* @return {String}
* @api private
*/
function extToMime(type) {
if (~type.indexOf('/')) return type;
return mime.lookup(type);
}

433
lib/response.js Normal file
View file

@ -0,0 +1,433 @@
/**
* Module dependencies.
*/
var debug = require('debug')('koa:response');
var Negotiator = require('negotiator');
var statuses = require('./status');
var Cookies = require('cookies');
var qs = require('querystring');
var Stream = require('stream');
var fresh = require('fresh');
var http = require('http');
var path = require('path');
var mime = require('mime');
var basename = path.basename;
var extname = path.extname;
var url = require('url');
var parse = url.parse;
var stringify = url.format;
/**
* Expose `Response`.
*/
module.exports = Response;
/**
* Initialize a new Response.
*
* @api private
*/
function Response(ctx){
this.res = ctx.res;
this.ctx = ctx;
}
/**
* Prototype.
*/
Response.prototype = {
/**
* Return response header.
*
* @return {Object}
* @api public
*/
get header() {
// TODO: wtf
return this.res._headers || {};
},
/**
* Return response status string.
*
* @return {String}
* @api public
*/
get statusString() {
return http.STATUS_CODES[this.status];
},
/**
* Get response status code.
*
* @return {Number}
* @api public
*/
get status() {
return this.res.statusCode;
},
/**
* Set response status code.
*
* @param {Number|String} val
* @api public
*/
set status(val) {
if ('string' == typeof val) {
var n = statuses[val.toLowerCase()];
if (!n) throw new Error(statusError(val));
val = n;
}
this.res.statusCode = val;
var noContent = 304 == this.status || 204 == this.status;
if (noContent && this.body) this.body = null;
},
/**
* Get response body.
*
* @return {Mixed}
* @api public
*/
get body() {
return this._body;
},
/**
* Set response body.
*
* @param {String|Buffer|Object|Stream} val
* @api public
*/
set body(val) {
this._body = val;
// no content
if (null == val) {
var s = this.status;
this.status = 304 == s ? 304 : 204;
this.res.removeHeader('Content-Type');
this.res.removeHeader('Content-Length');
this.res.removeHeader('Transfer-Encoding');
return;
}
// set the content-type only if not yet set
var setType = !this.header['content-type'];
// string
if ('string' == typeof val) {
if (setType) this.type = ~val.indexOf('<') ? 'html' : 'text';
this.length = Buffer.byteLength(val);
return;
}
// buffer
if (Buffer.isBuffer(val)) {
if (setType) this.type = 'bin';
this.length = val.length;
return;
}
// stream
if (val instanceof Stream) {
if (setType) this.type = 'bin';
return;
}
// json
this.type = 'json';
},
/**
* Set Content-Length field to `n`.
*
* @param {Number} n
* @api public
*/
set length(n) {
this.set('Content-Length', n);
},
/**
* Return parsed response Content-Length when present.
*
* @return {Number}
* @api public
*/
get length() {
var len = this.header['content-length'];
var body = this.body;
if (null == len) {
if (!body) return;
if ('string' == typeof body) return Buffer.byteLength(body);
return body.length;
}
return ~~len;
},
/**
* Check if a header has been written to the socket.
*
* @return {Boolean}
* @api public
*/
get headerSent() {
return this.res.headersSent;
},
/**
* Vary on `field`.
*
* @param {String} field
* @api public
*/
vary: function(field){
this.append('Vary', field);
},
/**
* Perform a 302 redirect to `url`.
*
* The string "back" is special-cased
* to provide Referrer support, when Referrer
* is not present `alt` or "/" is used.
*
* Examples:
*
* this.redirect('back');
* this.redirect('back', '/index.html');
* this.redirect('/login');
* this.redirect('http://google.com');
*
* @param {String} url
* @param {String} alt
* @api public
*/
redirect: function(url, alt){
if ('back' == url) url = this.ctx.get('Referrer') || alt || '/';
this.set('Location', url);
if (!~[300, 301, 302, 303, 305, 307].indexOf(this.status)) this.status = 302;
// html
if (this.ctx.accepts('html')) {
url = escape(url);
this.type = 'text/html; charset=utf-8';
this.body = 'Redirecting to <a href="' + url + '">' + url + '</a>.';
return;
}
// text
this.body = 'Redirecting to ' + url + '.';
},
/**
* Set Content-Disposition header to "attachment" with optional `filename`.
*
* @param {String} filename
* @api public
*/
attachment: function(filename){
if (filename) this.type = extname(filename);
this.set('Content-Disposition', filename
? 'attachment; filename="' + basename(filename) + '"'
: 'attachment');
},
/**
* Set Content-Type response header with `type` through `mime.lookup()`
* when it does not contain "/", or set the Content-Type to `type` otherwise.
*
* Examples:
*
* this.type = '.html';
* this.type = 'html';
* this.type = 'json';
* this.type = 'application/json';
* this.type = 'png';
*
* @param {String} type
* @api public
*/
set type(type){
if (!~type.indexOf('/')) {
type = mime.lookup(type);
var cs = mime.charsets.lookup(type);
if (cs) type += '; charset=' + cs.toLowerCase();
}
this.set('Content-Type', type);
},
/**
* Return the request mime type void of
* parameters such as "charset".
*
* @return {String}
* @api public
*/
get type() {
var type = this.get('Content-Type');
if (!type) return;
return type.split(';')[0];
},
/**
* Return response header.
*
* Examples:
*
* this.get('Content-Type');
* // => "text/plain"
*
* this.get('content-type');
* // => "text/plain"
*
* @param {String} name
* @return {String}
* @api public
*/
get: function(name){
return this.header[name.toLowerCase()];
},
/**
* Set header `field` to `val`, or pass
* an object of header fields.
*
* Examples:
*
* this.set('Foo', ['bar', 'baz']);
* this.set('Accept', 'application/json');
* this.set({ Accept: 'text/plain', 'X-API-Key': 'tobi' });
*
* @param {String|Object|Array} field
* @param {String} val
* @api public
*/
set: function(field, val){
if (2 == arguments.length) {
if (Array.isArray(val)) val = val.map(String);
else val = String(val);
this.res.setHeader(field, val);
} else {
for (var key in field) {
this.set(key, field[key]);
}
}
},
/**
* Append `val` to header `field`.
*
* @param {String} field
* @param {String} val
* @api public
*/
append: function(field, val){
field = field.toLowerCase();
var header = this.header;
var list = header[field];
// not set
if (!list) return this.set(field, val);
// append
list = list.split(/ *, */);
if (!~list.indexOf(val)) list.push(val);
this.set(field, list.join(', '));
},
/**
* Inspect implementation.
*
* TODO: add tests
*
* @return {Object}
* @api public
*/
inspect: function(){
var o = this.toJSON();
o.body = this.body;
o.statusString = this.statusString;
return o;
},
/**
* Return JSON representation.
*
* @return {Object}
* @api public
*/
toJSON: function(){
return {
status: this.status,
header: this.header
}
}
};
/**
* Return status error message.
*
* @param {String} val
* @return {String}
* @api private
*/
function statusError(val) {
var s = 'invalid status string "' + val + '", try:\n\n';
Object.keys(statuses).forEach(function(name){
var n = statuses[name];
s += ' - ' + n + ' "' + name + '"\n';
});
return s;
}
/**
* Escape special characters in the given string of html.
*
* @param {String} html
* @return {String}
* @api private
*/
function escape(html) {
return String(html)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

View file

@ -13,872 +13,4 @@ function context(req, res) {
return ctx; return ctx;
} }
describe('ctx.body=', function(){ module.exports = context;
describe('when Content-Type is set', function(){
it('should not override', function(){
var ctx = context();
ctx.type = 'png';
ctx.body = new Buffer('something');
assert('image/png' == ctx.responseHeader['content-type']);
})
describe('when body is an object', function(){
it('should override as json', function(){
var ctx = context();
ctx.body = '<em>hey</em>';
assert('text/html; charset=utf-8' == ctx.responseHeader['content-type']);
ctx.body = { foo: 'bar' };
assert('application/json' == ctx.responseHeader['content-type']);
})
})
it('should override length', function(){
var ctx = context();
ctx.type = 'html';
ctx.body = 'something';
ctx.responseLength.should.equal(9);
})
})
describe('when a string is given', function(){
it('should default to text', function(){
var ctx = context();
ctx.body = 'Tobi';
assert('text/plain; charset=utf-8' == ctx.responseHeader['content-type']);
})
it('should set length', function(){
var ctx = context();
ctx.body = 'Tobi';
assert('4' == ctx.responseHeader['content-length']);
})
})
describe('when an html string is given', function(){
it('should default to html', function(){
var ctx = context();
ctx.body = '<h1>Tobi</h1>';
assert('text/html; charset=utf-8' == ctx.responseHeader['content-type']);
})
it('should set length', function(){
var string = '<h1>Tobi</h1>';
var ctx = context();
ctx.body = string;
assert.equal(ctx.responseLength, Buffer.byteLength(string));
})
it('should set length when body is overriden', function(){
var string = '<h1>Tobi</h1>';
var ctx = context();
ctx.body = string;
ctx.body = string + string;
assert.equal(ctx.responseLength, 2 * Buffer.byteLength(string));
})
})
describe('when a stream is given', function(){
it('should default to an octet stream', function(){
var ctx = context();
ctx.body = fs.createReadStream('LICENSE');
assert('application/octet-stream' == ctx.responseHeader['content-type']);
})
})
describe('when a buffer is given', function(){
it('should default to an octet stream', function(){
var ctx = context();
ctx.body = new Buffer('hey');
assert('application/octet-stream' == ctx.responseHeader['content-type']);
})
it('should set length', function(){
var ctx = context();
ctx.body = new Buffer('Tobi');
assert('4' == ctx.responseHeader['content-length']);
})
})
describe('when an object is given', function(){
it('should default to json', function(){
var ctx = context();
ctx.body = { foo: 'bar' };
assert('application/json' == ctx.responseHeader['content-type']);
})
})
})
describe('ctx.error(msg)', function(){
it('should set .status to 500', function(done){
var ctx = context();
try {
ctx.error('boom');
} catch (err) {
assert(500 == err.status);
done();
}
})
})
describe('ctx.error(msg, status)', function(){
it('should throw an error', function(done){
var ctx = context();
try {
ctx.error('name required', 400);
} catch (err) {
assert('name required' == err.message);
assert(400 == err.status);
done();
}
})
})
describe('ctx.error(status)', function(){
it('should throw an error', function(done){
var ctx = context();
try {
ctx.error(400);
} catch (err) {
assert('Bad Request' == err.message);
assert(400 == err.status);
done();
}
})
})
describe('ctx.length', function(){
describe('when Content-Length is defined', function(){
it('should return a number', function(){
var ctx = context();
ctx.header['content-length'] = '120';
ctx.length.should.equal(120);
})
})
})
describe('ctx.responseLength', function(){
describe('when Content-Length is defined', function(){
it('should return a number', function(){
var ctx = context();
ctx.set('Content-Length', '1024');
ctx.responseLength.should.equal(1024);
})
})
describe('when Content-Length is not defined', function(){
describe('and a .body is set', function(){
it('should return a number', function(){
var ctx = context();
ctx.body = 'foo';
ctx.responseLength.should.equal(3);
ctx.body = new Buffer('foo');
ctx.responseLength.should.equal(3);
})
})
describe('and .body is not', function(){
it('should return undefined', function(){
var ctx = context();
assert(null == ctx.responseLength);
})
})
})
})
describe('ctx.header', function(){
it('should return the request header object', function(){
var ctx = context();
ctx.header.should.equal(ctx.req.headers);
})
})
describe('ctx.protocol', function(){
describe('when encrypted', function(){
it('should return "https"', function(){
var ctx = context();
ctx.req.socket = { encrypted: true };
ctx.protocol.should.equal('https');
})
})
describe('when unencrypted', function(){
it('should return "http"', function(){
var ctx = context();
ctx.req.socket = {};
ctx.protocol.should.equal('http');
})
})
describe('when X-Forwarded-Proto is set', function(){
describe('and proxy is trusted', function(){
it('should be used', function(){
var ctx = context();
ctx.app.proxy = true;
ctx.req.socket = {};
ctx.header['x-forwarded-proto'] = 'https, http';
ctx.protocol.should.equal('https');
})
})
describe('and proxy is not trusted', function(){
it('should not be used', function(){
var ctx = context();
ctx.req.socket = {};
ctx.header['x-forwarded-proto'] = 'https, http';
ctx.protocol.should.equal('http');
})
})
})
})
describe('ctx.secure', function(){
it('should return true when encrypted', function(){
var ctx = context();
ctx.req.socket = { encrypted: true };
ctx.secure.should.be.true;
})
})
describe('ctx.host', function(){
it('should return host void of port', function(){
var ctx = context();
ctx.header.host = 'foo.com:3000';
ctx.host.should.equal('foo.com');
})
describe('when X-Forwarded-Host is present', function(){
describe('and proxy is not trusted', function(){
it('should be ignored', function(){
var ctx = context();
ctx.header['x-forwarded-host'] = 'bar.com';
ctx.header['host'] = 'foo.com';
ctx.host.should.equal('foo.com')
})
})
describe('and proxy is trusted', function(){
it('should be used', function(){
var ctx = context();
ctx.app.proxy = true;
ctx.header['x-forwarded-host'] = 'bar.com, baz.com';
ctx.header['host'] = 'foo.com';
ctx.host.should.equal('bar.com')
})
})
})
})
describe('ctx.status=', function(){
describe('when a status string', function(){
describe('and valid', function(){
it('should set the status', function(){
var ctx = context();
ctx.status = 'forbidden';
ctx.status.should.equal(403);
})
it('should be case-insensitive', function(){
var ctx = context();
ctx.status = 'ForBidden';
ctx.status.should.equal(403);
})
})
describe('and invalid', function(){
it('should throw', function(){
var ctx = context();
var err;
try {
ctx.status = 'maru';
} catch (e) {
err = e;
}
assert(err);
})
})
})
function strip(status) {
it('should strip content related header fields', function(done){
var app = koa();
app.use(function(next){
return function *(){
this.body = { foo: 'bar' };
this.set('Content-Type', 'application/json');
this.set('Content-Length', '15');
this.set('Transfer-Encoding', 'chunked');
this.status = status;
assert(null == this.responseHeader['content-type']);
assert(null == this.responseHeader['content-length']);
assert(null == this.responseHeader['transfer-encoding']);
}
});
request(app.listen())
.get('/')
.expect(status)
.end(done);
})
}
describe('when 204', function(){
strip(204);
})
describe('when 304', function(){
strip(304);
})
})
describe('ctx.stale', function(){
it('should be the inverse of ctx.fresh', function(){
var ctx = context();
ctx.status = 200;
ctx.req.method = 'GET';
ctx.req.headers['if-none-match'] = '123';
ctx.res._headers['etag'] = '123';
ctx.fresh.should.be.true;
ctx.stale.should.be.false;
})
})
describe('ctx.fresh', function(){
describe('the response is non-2xx', function(){
it('should return false', function(){
var ctx = context();
ctx.status = 404;
ctx.req.method = 'GET';
ctx.req.headers['if-none-match'] = '123';
ctx.res._headers['etag'] = '123';
ctx.fresh.should.be.false;
})
});
describe('the response is 2xx', function(){
describe('and etag matches', function(){
it('should return true', function(){
var ctx = context();
ctx.status = 200;
ctx.req.method = 'GET';
ctx.req.headers['if-none-match'] = '123';
ctx.res._headers['etag'] = '123';
ctx.fresh.should.be.true;
})
})
describe('and etag do not match', function(){
it('should return false', function(){
var ctx = context();
ctx.status = 200;
ctx.req.method = 'GET';
ctx.req.headers['if-none-match'] = '123';
ctx.res._headers['etag'] = 'hey';
ctx.fresh.should.be.false;
})
})
})
})
describe('ctx.vary(field)', function(){
describe('when Vary is not set', function(){
it('should set it', function(){
var ctx = context();
ctx.vary('Accept');
ctx.responseHeader.vary.should.equal('Accept');
})
})
describe('when Vary is set', function(){
it('should append', function(){
var ctx = context();
ctx.vary('Accept');
ctx.vary('Accept-Encoding');
ctx.responseHeader.vary.should.equal('Accept, Accept-Encoding');
})
})
describe('when Vary already contains the value', function(){
it('should not append', function(){
var ctx = context();
ctx.vary('Accept');
ctx.vary('Accept-Encoding');
ctx.vary('Accept');
ctx.vary('Accept-Encoding');
ctx.responseHeader.vary.should.equal('Accept, Accept-Encoding');
})
})
})
describe('ctx.accepts(types)', function(){
describe('with no arguments', function(){
it('should return all accepted types', function(){
var ctx = context();
ctx.req.headers.accept = 'application/*;q=0.2, image/jpeg;q=0.8, text/html, text/plain';
ctx.accepts().should.eql(['text/html', 'text/plain', 'image/jpeg', 'application/*']);
})
})
describe('with no valid types', function(){
it('should return false', function(){
var ctx = context();
ctx.accepts('', 'hey').should.be.false;
})
})
describe('when extensions are given', function(){
it('should convert to mime types', function(){
var ctx = context();
ctx.req.headers.accept = 'text/plain, text/html';
ctx.accepts('html').should.equal('html');
ctx.accepts('.html').should.equal('.html');
ctx.accepts('txt').should.equal('txt');
ctx.accepts('.txt').should.equal('.txt');
ctx.accepts('png').should.be.false;
})
})
describe('when an array is given', function(){
it('should return the first match', function(){
var ctx = context();
ctx.req.headers.accept = 'text/plain, text/html';
ctx.accepts(['png', 'text', 'html']).should.equal('text');
ctx.accepts(['png', 'html']).should.equal('html');
})
})
describe('when multiple arguments are given', function(){
it('should return the first match', function(){
var ctx = context();
ctx.req.headers.accept = 'text/plain, text/html';
ctx.accepts('png', 'text', 'html').should.equal('text');
ctx.accepts('png', 'html').should.equal('html');
})
})
describe('when present in Accept as an exact match', function(){
it('should return the type', function(){
var ctx = context();
ctx.req.headers.accept = 'text/plain, text/html';
ctx.accepts('text/html').should.equal('text/html');
ctx.accepts('text/plain').should.equal('text/plain');
})
})
describe('when present in Accept as a type match', function(){
it('should return the type', function(){
var ctx = context();
ctx.req.headers.accept = 'application/json, */*';
ctx.accepts('text/html').should.equal('text/html');
ctx.accepts('text/plain').should.equal('text/plain');
ctx.accepts('image/png').should.equal('image/png');
})
})
describe('when present in Accept as a subtype match', function(){
it('should return the type', function(){
var ctx = context();
ctx.req.headers.accept = 'application/json, text/*';
ctx.accepts('text/html').should.equal('text/html');
ctx.accepts('text/plain').should.equal('text/plain');
ctx.accepts('image/png').should.be.false;
})
})
})
describe('ctx.acceptsLanguages(langs)', function(){
describe('with no arguments', function(){
describe('when Accept-Language is populated', function(){
it('should return accepted types', function(){
var ctx = context();
ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt';
ctx.acceptsLanguages().should.eql(['es', 'pt', 'en']);
})
})
describe('when Accept-Language is not populated', function(){
it('should return an empty array', function(){
var ctx = context();
ctx.acceptsLanguages().should.eql([]);
})
})
})
describe('with multiple arguments', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt';
ctx.acceptsLanguages('es', 'en').should.equal('es');
})
})
describe('with an array', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt';
ctx.acceptsLanguages(['es', 'en']).should.equal('es');
})
})
})
describe('ctx.acceptsCharsts()', function(){
describe('with no arguments', function(){
describe('when Accept-Charset is populated', function(){
it('should return accepted types', function(){
var ctx = context();
ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5';
ctx.acceptsCharsets().should.eql(['utf-8', 'utf-7', 'iso-8859-1']);
})
})
describe('when Accept-Charset is not populated', function(){
it('should return an empty array', function(){
var ctx = context();
ctx.acceptsCharsets().should.eql([]);
})
})
})
describe('with multiple arguments', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5';
ctx.acceptsCharsets('utf-7', 'utf-8').should.equal('utf-8');
})
})
describe('with an array', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5';
ctx.acceptsCharsets(['utf-7', 'utf-8']).should.equal('utf-8');
})
})
})
describe('ctx.acceptsEncodings()', function(){
describe('with no arguments', function(){
describe('when Accept-Encoding is populated', function(){
it('should return accepted types', function(){
var ctx = context();
ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2';
ctx.acceptsEncodings().should.eql(['gzip', 'compress', 'identity']);
})
})
describe('when Accept-Encoding is not populated', function(){
it('should return identity', function(){
var ctx = context();
ctx.acceptsEncodings().should.eql(['identity']);
})
})
})
describe('with multiple arguments', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2';
ctx.acceptsEncodings('compress', 'gzip').should.eql('gzip');
ctx.acceptsEncodings('gzip', 'compress').should.eql('gzip');
})
})
describe('with an array', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2';
ctx.acceptsEncodings(['compress', 'gzip']).should.eql('gzip');
})
})
})
describe('ctx.path', function(){
it('should return the pathname', function(){
var ctx = new Context(null, {
url: '/login?next=/dashboard'
});
ctx.path.should.equal('/login');
})
})
describe('ctx.path=', function(){
it('should set the pathname', function(){
var ctx = new Context(null, {
url: '/login?next=/dashboard'
});
ctx.path = '/logout';
ctx.path.should.equal('/logout');
ctx.url.should.equal('/logout?next=/dashboard');
})
})
describe('ctx.get(name)', function(){
it('should return the field value', function(){
var req = { headers: { 'host': 'http://google.com' } };
var ctx = new Context(null, req);
ctx.get('HOST').should.equal('http://google.com');
ctx.get('Host').should.equal('http://google.com');
ctx.get('host').should.equal('http://google.com');
})
})
describe('ctx.set(name, val)', function(){
it('should set a field value', function(){
var ctx = context();
ctx.set('x-foo', 'bar');
ctx.responseHeader['x-foo'].should.equal('bar');
})
it('should coerce to a string', function(){
var ctx = context();
ctx.set('x-foo', 5);
ctx.responseHeader['x-foo'].should.equal('5');
})
})
describe('ctx.set(object)', function(){
it('should set multiple fields', function(){
var ctx = context();
ctx.set({
foo: '1',
bar: '2'
});
ctx.responseHeader.foo.should.equal('1');
ctx.responseHeader.bar.should.equal('2');
})
})
describe('ctx.query', function(){
describe('when missing', function(){
it('should return an empty object', function(){
var ctx = context({ url: '/' });
ctx.query.should.eql({});
})
})
it('should return a parsed query-string', function(){
var ctx = context({ url: '/?page=2' });
ctx.query.page.should.equal('2');
})
})
describe('ctx.query=', function(){
it('should stringify and replace the querystring', function(){
var ctx = context({ url: '/store/shoes' });
ctx.query = { page: 2, color: 'blue' };
ctx.url.should.equal('/store/shoes?page=2&color=blue');
ctx.querystring.should.equal('page=2&color=blue');
})
})
describe('ctx.querystring=', function(){
it('should replace the querystring', function(){
var ctx = context({ url: '/store/shoes' });
ctx.querystring = 'page=2&color=blue';
ctx.url.should.equal('/store/shoes?page=2&color=blue');
ctx.querystring.should.equal('page=2&color=blue');
})
})
describe('ctx.type=', function(){
describe('with a mime', function(){
it('should set the Content-Type', function(){
var ctx = context();
ctx.type = 'text/plain';
ctx.responseHeader['content-type'].should.equal('text/plain');
})
})
describe('with an extension', function(){
it('should lookup the mime', function(){
var ctx = context();
ctx.type = 'json';
ctx.responseHeader['content-type'].should.equal('application/json');
})
})
})
describe('ctx.type', function(){
describe('with no Content-Type', function(){
it('should return null', function(){
var ctx = context();
assert(null == ctx.type);
})
})
describe('with a Content-Type', function(){
it('should return the mime', function(){
var ctx = context();
ctx.req.headers['content-type'] = 'text/html; charset=utf8';
ctx.type.should.equal('text/html');
})
})
})
describe('ctx.is(type)', function(){
it('should ignore params', function(){
var ctx = context();
ctx.header['content-type'] = 'text/html; charset=utf-8';
ctx.is('text/*').should.be.true;
})
describe('given a mime', function(){
it('should check the type', function(){
var ctx = context();
ctx.header['content-type'] = 'image/png';
ctx.is('image/png').should.be.true;
ctx.is('image/*').should.be.true;
ctx.is('*/png').should.be.true;
ctx.is('image/jpeg').should.be.false;
ctx.is('text/*').should.be.false;
ctx.is('*/jpeg').should.be.false;
})
})
describe('given an extension', function(){
it('should check the type', function(){
var ctx = context();
ctx.header['content-type'] = 'image/png';
ctx.is('png').should.be.true;
ctx.is('.png').should.be.true;
ctx.is('jpeg').should.be.false;
ctx.is('.jpeg').should.be.false;
})
})
})
describe('ctx.attachment([filename])', function(){
describe('when given a filename', function(){
it('should set the filename param', function(){
var ctx = context();
ctx.attachment('path/to/tobi.png');
var str = 'attachment; filename="tobi.png"';
ctx.responseHeader['content-disposition'].should.equal(str);
})
})
describe('when omitting filename', function(){
it('should not set filename param', function(){
var ctx = context();
ctx.attachment();
ctx.responseHeader['content-disposition'].should.equal('attachment');
})
})
})
describe('ctx.redirect(url)', function(){
it('should redirect to the given url', function(){
var ctx = context();
ctx.redirect('http://google.com');
ctx.responseHeader.location.should.equal('http://google.com');
ctx.status.should.equal(302);
})
describe('with "back"', function(){
it('should redirect to Referrer', function(){
var ctx = context();
ctx.req.headers.referrer = '/login';
ctx.redirect('back');
ctx.responseHeader.location.should.equal('/login');
})
it('should redirect to Referer', function(){
var ctx = context();
ctx.req.headers.referer = '/login';
ctx.redirect('back');
ctx.responseHeader.location.should.equal('/login');
})
it('should default to alt', function(){
var ctx = context();
ctx.redirect('back', '/index.html');
ctx.responseHeader.location.should.equal('/index.html');
})
it('should default redirect to /', function(){
var ctx = context();
ctx.redirect('back');
ctx.responseHeader.location.should.equal('/');
})
})
describe('when html is accepted', function(){
it('should respond with html', function(){
var ctx = context();
var url = 'http://google.com';
ctx.header.accept = 'text/html';
ctx.redirect(url);
ctx.responseHeader['content-type'].should.equal('text/html; charset=utf-8');
ctx.body.should.equal('Redirecting to <a href="' + url + '">' + url + '</a>.');
})
it('should escape the url', function(){
var ctx = context();
var url = '<script>';
ctx.header.accept = 'text/html';
ctx.redirect(url);
url = escape(url);
ctx.responseHeader['content-type'].should.equal('text/html; charset=utf-8');
ctx.body.should.equal('Redirecting to <a href="' + url + '">' + url + '</a>.');
})
})
describe('when text is accepted', function(){
it('should respond with text', function(){
var ctx = context();
var url = 'http://google.com';
ctx.header.accept = 'text/plain';
ctx.redirect(url);
ctx.body.should.equal('Redirecting to ' + url + '.');
})
})
describe('when status is 301', function(){
it('should not change the status code', function(){
var ctx = context();
var url = 'http://google.com';
ctx.status = 301;
ctx.header.accept = 'text/plain';
ctx.redirect('http://google.com');
ctx.status.should.equal(301);
ctx.body.should.equal('Redirecting to ' + url + '.');
})
})
describe('when status is 304', function(){
it('should change the status code', function(){
var ctx = context();
var url = 'http://google.com';
ctx.status = 304;
ctx.header.accept = 'text/plain';
ctx.redirect('http://google.com');
ctx.status.should.equal(302);
ctx.body.should.equal('Redirecting to ' + url + '.');
})
})
})
function escape(html) {
return String(html)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

44
test/context/error.js Normal file
View file

@ -0,0 +1,44 @@
var context = require('../context');
var assert = require('assert');
describe('ctx.error(msg)', function(){
it('should set .status to 500', function(done){
var ctx = context();
try {
ctx.error('boom');
} catch (err) {
assert(500 == err.status);
done();
}
})
})
describe('ctx.error(msg, status)', function(){
it('should throw an error', function(done){
var ctx = context();
try {
ctx.error('name required', 400);
} catch (err) {
assert('name required' == err.message);
assert(400 == err.status);
done();
}
})
})
describe('ctx.error(status)', function(){
it('should throw an error', function(done){
var ctx = context();
try {
ctx.error(400);
} catch (err) {
assert('Bad Request' == err.message);
assert(400 == err.status);
done();
}
})
})

18
test/request.js Normal file
View file

@ -0,0 +1,18 @@
var Context = require('../lib/context');
var Request = require('../lib/request');
var koa = require('..');
function context(req, res) {
req = req || { headers: {} };
res = res || { _headers: {} };
res.setHeader = function(k, v){ res._headers[k.toLowerCase()] = v };
var ctx = new Context({}, req, res);
return ctx;
}
function request(req, res) {
return new Request(context(req, res));
}
module.exports = request;

78
test/request/accepts.js Normal file
View file

@ -0,0 +1,78 @@
var context = require('../context');
describe('ctx.accepts(types)', function(){
describe('with no arguments', function(){
it('should return all accepted types', function(){
var ctx = context();
ctx.req.headers.accept = 'application/*;q=0.2, image/jpeg;q=0.8, text/html, text/plain';
ctx.accepts().should.eql(['text/html', 'text/plain', 'image/jpeg', 'application/*']);
})
})
describe('with no valid types', function(){
it('should return false', function(){
var ctx = context();
ctx.accepts('', 'hey').should.be.false;
})
})
describe('when extensions are given', function(){
it('should convert to mime types', function(){
var ctx = context();
ctx.req.headers.accept = 'text/plain, text/html';
ctx.accepts('html').should.equal('html');
ctx.accepts('.html').should.equal('.html');
ctx.accepts('txt').should.equal('txt');
ctx.accepts('.txt').should.equal('.txt');
ctx.accepts('png').should.be.false;
})
})
describe('when an array is given', function(){
it('should return the first match', function(){
var ctx = context();
ctx.req.headers.accept = 'text/plain, text/html';
ctx.accepts(['png', 'text', 'html']).should.equal('text');
ctx.accepts(['png', 'html']).should.equal('html');
})
})
describe('when multiple arguments are given', function(){
it('should return the first match', function(){
var ctx = context();
ctx.req.headers.accept = 'text/plain, text/html';
ctx.accepts('png', 'text', 'html').should.equal('text');
ctx.accepts('png', 'html').should.equal('html');
})
})
describe('when present in Accept as an exact match', function(){
it('should return the type', function(){
var ctx = context();
ctx.req.headers.accept = 'text/plain, text/html';
ctx.accepts('text/html').should.equal('text/html');
ctx.accepts('text/plain').should.equal('text/plain');
})
})
describe('when present in Accept as a type match', function(){
it('should return the type', function(){
var ctx = context();
ctx.req.headers.accept = 'application/json, */*';
ctx.accepts('text/html').should.equal('text/html');
ctx.accepts('text/plain').should.equal('text/plain');
ctx.accepts('image/png').should.equal('image/png');
})
})
describe('when present in Accept as a subtype match', function(){
it('should return the type', function(){
var ctx = context();
ctx.req.headers.accept = 'application/json, text/*';
ctx.accepts('text/html').should.equal('text/html');
ctx.accepts('text/plain').should.equal('text/plain');
ctx.accepts('image/png').should.be.false;
})
})
})

View file

@ -0,0 +1,37 @@
var context = require('../context');
describe('ctx.acceptsCharsets()', function(){
describe('with no arguments', function(){
describe('when Accept-Charset is populated', function(){
it('should return accepted types', function(){
var ctx = context();
ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5';
ctx.acceptsCharsets().should.eql(['utf-8', 'utf-7', 'iso-8859-1']);
})
})
describe('when Accept-Charset is not populated', function(){
it('should return an empty array', function(){
var ctx = context();
ctx.acceptsCharsets().should.eql([]);
})
})
})
describe('with multiple arguments', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5';
ctx.acceptsCharsets('utf-7', 'utf-8').should.equal('utf-8');
})
})
describe('with an array', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5';
ctx.acceptsCharsets(['utf-7', 'utf-8']).should.equal('utf-8');
})
})
})

View file

@ -0,0 +1,38 @@
var context = require('../context');
describe('ctx.acceptsEncodings()', function(){
describe('with no arguments', function(){
describe('when Accept-Encoding is populated', function(){
it('should return accepted types', function(){
var ctx = context();
ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2';
ctx.acceptsEncodings().should.eql(['gzip', 'compress', 'identity']);
})
})
describe('when Accept-Encoding is not populated', function(){
it('should return identity', function(){
var ctx = context();
ctx.acceptsEncodings().should.eql(['identity']);
})
})
})
describe('with multiple arguments', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2';
ctx.acceptsEncodings('compress', 'gzip').should.eql('gzip');
ctx.acceptsEncodings('gzip', 'compress').should.eql('gzip');
})
})
describe('with an array', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2';
ctx.acceptsEncodings(['compress', 'gzip']).should.eql('gzip');
})
})
})

View file

@ -0,0 +1,37 @@
var context = require('../context');
describe('ctx.acceptsLanguages(langs)', function(){
describe('with no arguments', function(){
describe('when Accept-Language is populated', function(){
it('should return accepted types', function(){
var ctx = context();
ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt';
ctx.acceptsLanguages().should.eql(['es', 'pt', 'en']);
})
})
describe('when Accept-Language is not populated', function(){
it('should return an empty array', function(){
var ctx = context();
ctx.acceptsLanguages().should.eql([]);
})
})
})
describe('with multiple arguments', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt';
ctx.acceptsLanguages('es', 'en').should.equal('es');
})
})
describe('with an array', function(){
it('should return the best fit', function(){
var ctx = context();
ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt';
ctx.acceptsLanguages(['es', 'en']).should.equal('es');
})
})
})

39
test/request/fresh.js Normal file
View file

@ -0,0 +1,39 @@
var context = require('../context');
describe('ctx.fresh', function(){
describe('the response is non-2xx', function(){
it('should return false', function(){
var ctx = context();
ctx.status = 404;
ctx.req.method = 'GET';
ctx.req.headers['if-none-match'] = '123';
ctx.set('ETag', '123');
ctx.fresh.should.be.false;
})
});
describe('the response is 2xx', function(){
describe('and etag matches', function(){
it('should return true', function(){
var ctx = context();
ctx.status = 200;
ctx.req.method = 'GET';
ctx.req.headers['if-none-match'] = '123';
ctx.set('ETag', '123');
ctx.fresh.should.be.true;
})
})
describe('and etag do not match', function(){
it('should return false', function(){
var ctx = context();
ctx.status = 200;
ctx.req.method = 'GET';
ctx.req.headers['if-none-match'] = '123';
ctx.set('ETag', 'hey');
ctx.fresh.should.be.false;
})
})
})
})

12
test/request/get.js Normal file
View file

@ -0,0 +1,12 @@
var context = require('../context');
describe('ctx.get(name)', function(){
it('should return the field value', function(){
var ctx = context();
ctx.req.headers.host = 'http://google.com';
ctx.get('HOST').should.equal('http://google.com');
ctx.get('Host').should.equal('http://google.com');
ctx.get('host').should.equal('http://google.com');
})
})

9
test/request/header.js Normal file
View file

@ -0,0 +1,9 @@
var request = require('../request');
describe('req.header', function(){
it('should return the request header object', function(){
var req = request();
req.header.should.equal(req.req.headers);
})
})

31
test/request/host.js Normal file
View file

@ -0,0 +1,31 @@
var request = require('../request');
describe('req.host', function(){
it('should return host void of port', function(){
var req = request();
req.header.host = 'foo.com:3000';
req.host.should.equal('foo.com');
})
describe('when X-Forwarded-Host is present', function(){
describe('and proxy is not trusted', function(){
it('should be ignored', function(){
var req = request();
req.header['x-forwarded-host'] = 'bar.com';
req.header['host'] = 'foo.com';
req.host.should.equal('foo.com')
})
})
describe('and proxy is trusted', function(){
it('should be used', function(){
var req = request();
req.app.proxy = true;
req.header['x-forwarded-host'] = 'bar.com, baz.com';
req.header['host'] = 'foo.com';
req.host.should.equal('bar.com')
})
})
})
})

38
test/request/is.js Normal file
View file

@ -0,0 +1,38 @@
var context = require('../context');
describe('ctx.is(type)', function(){
it('should ignore params', function(){
var ctx = context();
ctx.header['content-type'] = 'text/html; charset=utf-8';
ctx.is('text/*').should.be.true;
})
describe('given a mime', function(){
it('should check the type', function(){
var ctx = context();
ctx.header['content-type'] = 'image/png';
ctx.is('image/png').should.be.true;
ctx.is('image/*').should.be.true;
ctx.is('*/png').should.be.true;
ctx.is('image/jpeg').should.be.false;
ctx.is('text/*').should.be.false;
ctx.is('*/jpeg').should.be.false;
})
})
describe('given an extension', function(){
it('should check the type', function(){
var ctx = context();
ctx.header['content-type'] = 'image/png';
ctx.is('png').should.be.true;
ctx.is('.png').should.be.true;
ctx.is('jpeg').should.be.false;
ctx.is('.jpeg').should.be.false;
})
})
})

21
test/request/path.js Normal file
View file

@ -0,0 +1,21 @@
var context = require('../context');
describe('ctx.path', function(){
it('should return the pathname', function(){
var ctx = context();
ctx.url = '/login?next=/dashboard';
ctx.path.should.equal('/login');
})
})
describe('ctx.path=', function(){
it('should set the pathname', function(){
var ctx = context();
ctx.url = '/login?next=/dashboard';
ctx.path = '/logout';
ctx.path.should.equal('/logout');
ctx.url.should.equal('/logout?next=/dashboard');
})
})

41
test/request/protocol.js Normal file
View file

@ -0,0 +1,41 @@
var request = require('../request');
describe('req.protocol', function(){
describe('when encrypted', function(){
it('should return "https"', function(){
var req = request();
req.req.socket = { encrypted: true };
req.protocol.should.equal('https');
})
})
describe('when unencrypted', function(){
it('should return "http"', function(){
var req = request();
req.req.socket = {};
req.protocol.should.equal('http');
})
})
describe('when X-Forwarded-Proto is set', function(){
describe('and proxy is trusted', function(){
it('should be used', function(){
var req = request();
req.app.proxy = true;
req.req.socket = {};
req.header['x-forwarded-proto'] = 'https, http';
req.protocol.should.equal('https');
})
})
describe('and proxy is not trusted', function(){
it('should not be used', function(){
var req = request();
req.req.socket = {};
req.header['x-forwarded-proto'] = 'https, http';
req.protocol.should.equal('http');
})
})
})
})

25
test/request/query.js Normal file
View file

@ -0,0 +1,25 @@
var context = require('../context');
describe('ctx.query', function(){
describe('when missing', function(){
it('should return an empty object', function(){
var ctx = context({ url: '/' });
ctx.query.should.eql({});
})
})
it('should return a parsed query-string', function(){
var ctx = context({ url: '/?page=2' });
ctx.query.page.should.equal('2');
})
})
describe('ctx.query=', function(){
it('should stringify and replace the querystring', function(){
var ctx = context({ url: '/store/shoes' });
ctx.query = { page: 2, color: 'blue' };
ctx.url.should.equal('/store/shoes?page=2&color=blue');
ctx.querystring.should.equal('page=2&color=blue');
})
})

View file

@ -0,0 +1,11 @@
var context = require('../context');
describe('ctx.querystring=', function(){
it('should replace the querystring', function(){
var ctx = context({ url: '/store/shoes' });
ctx.querystring = 'page=2&color=blue';
ctx.url.should.equal('/store/shoes?page=2&color=blue');
ctx.querystring.should.equal('page=2&color=blue');
})
})

10
test/request/secure.js Normal file
View file

@ -0,0 +1,10 @@
var request = require('../request');
describe('req.secure', function(){
it('should return true when encrypted', function(){
var req = request();
req.req.socket = { encrypted: true };
req.secure.should.be.true;
})
})

14
test/request/stale.js Normal file
View file

@ -0,0 +1,14 @@
var context = require('../context');
describe('req.stale', function(){
it('should be the inverse of req.fresh', function(){
var ctx = context();
ctx.status = 200;
ctx.method = 'GET';
ctx.req.headers['if-none-match'] = '"123"';
ctx.set('ETag', '"123"');
ctx.fresh.should.be.true;
ctx.stale.should.be.false;
})
})

18
test/response.js Normal file
View file

@ -0,0 +1,18 @@
var Context = require('../lib/context');
var Response = require('../lib/response');
var koa = require('..');
function context(req, res) {
req = req || { headers: {} };
res = res || { _headers: {} };
res.setHeader = function(k, v){ res._headers[k.toLowerCase()] = v };
var ctx = new Context({}, req, res);
return ctx;
}
function response(req, res) {
return new Response(context(req, res));
}
module.exports = response;

View file

@ -0,0 +1,21 @@
var context = require('../context');
describe('ctx.attachment([filename])', function(){
describe('when given a filename', function(){
it('should set the filename param', function(){
var ctx = context();
ctx.attachment('path/to/tobi.png');
var str = 'attachment; filename="tobi.png"';
ctx.response.header['content-disposition'].should.equal(str);
})
})
describe('when omitting filename', function(){
it('should not set filename param', function(){
var ctx = context();
ctx.attachment();
ctx.response.header['content-disposition'].should.equal('attachment');
})
})
})

101
test/response/body.js Normal file
View file

@ -0,0 +1,101 @@
var response = require('../response');
var assert = require('assert');
var fs = require('fs');
describe('res.body=', function(){
describe('when Content-Type is set', function(){
it('should not override', function(){
var res = response();
res.type = 'png';
res.body = new Buffer('something');
assert('image/png' == res.header['content-type']);
})
describe('when body is an object', function(){
it('should override as json', function(){
var res = response();
res.body = '<em>hey</em>';
assert('text/html; charset=utf-8' == res.header['content-type']);
res.body = { foo: 'bar' };
assert('application/json' == res.header['content-type']);
})
})
it('should override length', function(){
var res = response();
res.type = 'html';
res.body = 'something';
res.length.should.equal(9);
})
})
describe('when a string is given', function(){
it('should default to text', function(){
var res = response();
res.body = 'Tobi';
assert('text/plain; charset=utf-8' == res.header['content-type']);
})
it('should set length', function(){
var res = response();
res.body = 'Tobi';
assert('4' == res.header['content-length']);
})
})
describe('when an html string is given', function(){
it('should default to html', function(){
var res = response();
res.body = '<h1>Tobi</h1>';
assert('text/html; charset=utf-8' == res.header['content-type']);
})
it('should set length', function(){
var string = '<h1>Tobi</h1>';
var res = response();
res.body = string;
assert.equal(res.length, Buffer.byteLength(string));
})
it('should set length when body is overriden', function(){
var string = '<h1>Tobi</h1>';
var res = response();
res.body = string;
res.body = string + string;
assert.equal(res.length, 2 * Buffer.byteLength(string));
})
})
describe('when a stream is given', function(){
it('should default to an octet stream', function(){
var res = response();
res.body = fs.createReadStream('LICENSE');
assert('application/octet-stream' == res.header['content-type']);
})
})
describe('when a buffer is given', function(){
it('should default to an octet stream', function(){
var res = response();
res.body = new Buffer('hey');
assert('application/octet-stream' == res.header['content-type']);
})
it('should set length', function(){
var res = response();
res.body = new Buffer('Tobi');
assert('4' == res.header['content-length']);
})
})
describe('when an object is given', function(){
it('should default to json', function(){
var res = response();
res.body = { foo: 'bar' };
assert('application/json' == res.header['content-type']);
})
})
})

10
test/response/header.js Normal file
View file

@ -0,0 +1,10 @@
var response = require('../response');
describe('res.header', function(){
it('should return the response header object', function(){
var res = response();
res.set('X-Foo', 'bar');
res.header.should.eql({ 'x-foo': 'bar' });
})
})

44
test/response/length.js Normal file
View file

@ -0,0 +1,44 @@
var response = require('../response');
var assert = require('assert');
describe('res.length', function(){
describe('when Content-Length is defined', function(){
it('should return a number', function(){
var res = response();
res.header['content-length'] = '120';
res.length.should.equal(120);
})
})
})
describe('res.length', function(){
describe('when Content-Length is defined', function(){
it('should return a number', function(){
var res = response();
res.set('Content-Length', '1024');
res.length.should.equal(1024);
})
})
describe('when Content-Length is not defined', function(){
describe('and a .body is set', function(){
it('should return a number', function(){
var res = response();
res.body = 'foo';
res.length.should.equal(3);
res.body = new Buffer('foo');
res.length.should.equal(3);
})
})
describe('and .body is not', function(){
it('should return undefined', function(){
var res = response();
assert(null == res.length);
})
})
})
})

102
test/response/redirect.js Normal file
View file

@ -0,0 +1,102 @@
var context = require('../context');
describe('ctx.redirect(url)', function(){
it('should redirect to the given url', function(){
var ctx = context();
ctx.redirect('http://google.com');
ctx.response.header.location.should.equal('http://google.com');
ctx.status.should.equal(302);
})
describe('with "back"', function(){
it('should redirect to Referrer', function(){
var ctx = context();
ctx.req.headers.referrer = '/login';
ctx.redirect('back');
ctx.response.header.location.should.equal('/login');
})
it('should redirect to Referer', function(){
var ctx = context();
ctx.req.headers.referer = '/login';
ctx.redirect('back');
ctx.response.header.location.should.equal('/login');
})
it('should default to alt', function(){
var ctx = context();
ctx.redirect('back', '/index.html');
ctx.response.header.location.should.equal('/index.html');
})
it('should default redirect to /', function(){
var ctx = context();
ctx.redirect('back');
ctx.response.header.location.should.equal('/');
})
})
describe('when html is accepted', function(){
it('should respond with html', function(){
var ctx = context();
var url = 'http://google.com';
ctx.header.accept = 'text/html';
ctx.redirect(url);
ctx.response.header['content-type'].should.equal('text/html; charset=utf-8');
ctx.body.should.equal('Redirecting to <a href="' + url + '">' + url + '</a>.');
})
it('should escape the url', function(){
var ctx = context();
var url = '<script>';
ctx.header.accept = 'text/html';
ctx.redirect(url);
url = escape(url);
ctx.response.header['content-type'].should.equal('text/html; charset=utf-8');
ctx.body.should.equal('Redirecting to <a href="' + url + '">' + url + '</a>.');
})
})
describe('when text is accepted', function(){
it('should respond with text', function(){
var ctx = context();
var url = 'http://google.com';
ctx.header.accept = 'text/plain';
ctx.redirect(url);
ctx.body.should.equal('Redirecting to ' + url + '.');
})
})
describe('when status is 301', function(){
it('should not change the status code', function(){
var ctx = context();
var url = 'http://google.com';
ctx.status = 301;
ctx.header.accept = 'text/plain';
ctx.redirect('http://google.com');
ctx.status.should.equal(301);
ctx.body.should.equal('Redirecting to ' + url + '.');
})
})
describe('when status is 304', function(){
it('should change the status code', function(){
var ctx = context();
var url = 'http://google.com';
ctx.status = 304;
ctx.header.accept = 'text/plain';
ctx.redirect('http://google.com');
ctx.status.should.equal(302);
ctx.body.should.equal('Redirecting to ' + url + '.');
})
})
})
function escape(html) {
return String(html)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

30
test/response/set.js Normal file
View file

@ -0,0 +1,30 @@
var context = require('../context');
describe('ctx.set(name, val)', function(){
it('should set a field value', function(){
var ctx = context();
ctx.set('x-foo', 'bar');
ctx.response.header['x-foo'].should.equal('bar');
})
it('should coerce to a string', function(){
var ctx = context();
ctx.set('x-foo', 5);
ctx.response.header['x-foo'].should.equal('5');
})
})
describe('ctx.set(object)', function(){
it('should set multiple fields', function(){
var ctx = context();
ctx.set({
foo: '1',
bar: '2'
});
ctx.response.header.foo.should.equal('1');
ctx.response.header.bar.should.equal('2');
})
})

70
test/response/status.js Normal file
View file

@ -0,0 +1,70 @@
var response = require('../response');
var request = require('supertest');
var assert = require('assert');
var koa = require('../..');
describe('res.status=', function(){
describe('when a status string', function(){
describe('and valid', function(){
it('should set the status', function(){
var res = response();
res.status = 'forbidden';
res.status.should.equal(403);
})
it('should be case-insensitive', function(){
var res = response();
res.status = 'ForBidden';
res.status.should.equal(403);
})
})
describe('and invalid', function(){
it('should throw', function(){
var res = response();
var err;
try {
res.status = 'maru';
} catch (e) {
err = e;
}
assert(err);
})
})
})
function strip(status) {
it('should strip content related header fields', function(done){
var app = koa();
app.use(function(next){
return function *(){
this.body = { foo: 'bar' };
this.set('Content-Type', 'application/json');
this.set('Content-Length', '15');
this.set('Transfer-Encoding', 'chunked');
this.status = status;
assert(null == this.response.header['content-type']);
assert(null == this.response.header['content-length']);
assert(null == this.response.header['transfer-encoding']);
}
});
request(app.listen())
.get('/')
.expect(status)
.end(done);
})
}
describe('when 204', function(){
strip(204);
})
describe('when 304', function(){
strip(304);
})
})

38
test/response/type.js Normal file
View file

@ -0,0 +1,38 @@
var context = require('../context');
var assert = require('assert');
describe('ctx.type=', function(){
describe('with a mime', function(){
it('should set the Content-Type', function(){
var ctx = context();
ctx.type = 'text/plain';
ctx.response.header['content-type'].should.equal('text/plain');
})
})
describe('with an extension', function(){
it('should lookup the mime', function(){
var ctx = context();
ctx.type = 'json';
ctx.response.header['content-type'].should.equal('application/json');
})
})
})
describe('ctx.type', function(){
describe('with no Content-Type', function(){
it('should return null', function(){
var ctx = context();
assert(null == ctx.type);
})
})
describe('with a Content-Type', function(){
it('should return the mime', function(){
var ctx = context();
ctx.req.headers['content-type'] = 'text/html; charset=utf8';
ctx.type.should.equal('text/html');
})
})
})

32
test/response/vary.js Normal file
View file

@ -0,0 +1,32 @@
var context = require('../context');
describe('ctx.vary(field)', function(){
describe('when Vary is not set', function(){
it('should set it', function(){
var ctx = context();
ctx.vary('Accept');
ctx.response.header.vary.should.equal('Accept');
})
})
describe('when Vary is set', function(){
it('should append', function(){
var ctx = context();
ctx.vary('Accept');
ctx.vary('Accept-Encoding');
ctx.response.header.vary.should.equal('Accept, Accept-Encoding');
})
})
describe('when Vary already contains the value', function(){
it('should not append', function(){
var ctx = context();
ctx.vary('Accept');
ctx.vary('Accept-Encoding');
ctx.vary('Accept');
ctx.vary('Accept-Encoding');
ctx.response.header.vary.should.equal('Accept, Accept-Encoding');
})
})
})