Initial commit

master
TJ Holowaychuk 2013-08-17 00:15:57 -07:00
commit 9e167c5ed9
21 changed files with 3012 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

5
.npmignore Normal file
View File

@ -0,0 +1,5 @@
docs
benchmarks
examples
test
Makefile

22
LICENSE Normal file
View File

@ -0,0 +1,22 @@
(The MIT License)
Copyright (c) 2009-2011 TJ Holowaychuk <tj@vision-media.ca>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

11
Makefile Normal file
View File

@ -0,0 +1,11 @@
test:
@NODE_ENV=test ./node_modules/.bin/mocha \
--require should \
--harmony-generators \
--bail
bench:
@$(MAKE) -C benchmarks
.PHONY: test bench

75
Readme.md Normal file
View File

@ -0,0 +1,75 @@
![koa middleware framework for nodejs](https://dsz91cxz97a03.cloudfront.net/uXIzgVnPWG-150x150.png)
Expressive middleware for node.js using generators via [co](https://github.com/visionmedia/co)
to make writing web applications and REST APIs more enjoyable to write.
Koa provides a useful set of methods that make day to day web application and API design much
faster, and less error-prone than "raw" nodejs. Many of these utilities were extracted from [Express](https://github.com/visionmedia/express),
however moving them to this layer allows middleware developers to avoid boilerplate and refrain from re-implementing
many of these features, sometimes incorrectly or incomplete.
Only methods that are common to nearly all HTTP servers are integrated directly into Koa's small ~400 SLOC codebase. No middleware
are bundled with koa. If you prefer to only define a single dependency for common middleware, much like Connect, you may use
[koa-common](https://github.com/koajs/common).
## Installation
```
$ npm install koa
```
To use Koa you must be running __node 0.11.4__ or higher for generator support, and must run node(1)
with the `--harmony-generators` flag. If you don't like typing this, add an alias to your shell profile:
```
alias node='node --harmony-generators'
```
## Community
- [API](docs/api.md) documentation
- [Middleware](https://github.com/koajs/koa/wiki/Middleware) list
- [Wiki](https://github.com/koajs/koa/wiki)
- [G+ Community](https://plus.google.com/communities/101845768320796750641)
- [Mailing list](https://groups.google.com/forum/#!forum/koajs)
- __#koajs__ on freenode
## Example
```js
var koa = require('koa');
var app = koa();
// logger
app.use(function(next){
return function *(){
var start = new Date;
yield next;
var ms = new Date - start;
console.log('%s %s - %s', this.method, this.url, ms);
}
});
// response
app.use(function(next){
return function *(){
yield next;
this.body = 'Hello World';
}
});
app.listen(3000);
```
## Running tests
```
$ make test
```
# License
MIT

13
benchmarks/Makefile Normal file
View File

@ -0,0 +1,13 @@
all:
@./run 1 middleware
@./run 5 middleware
@./run 10 middleware
@./run 15 middleware
@./run 20 middleware
@./run 30 middleware
@./run 50 middleware
@./run 100 middleware
@echo
.PHONY: all

28
benchmarks/middleware.js Normal file
View File

@ -0,0 +1,28 @@
var http = require('http');
var koa = require('..');
var app = koa();
// number of middleware
var n = parseInt(process.env.MW || '1', 10);
console.log(' %s middleware', n);
while (n--) {
app.use(function(next){
return function *(){
yield next;
}
});
}
var body = new Buffer('Hello World');
app.use(function(next){
return function *(){
yield next;
this.body = body;
}
});
http.createServer(app.callback()).listen(3000);

16
benchmarks/run Executable file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
echo
MW=$1 node --harmony-generators $2 &
pid=$!
sleep 2
wrk http://localhost:3000/ \
-r 2000 \
-c 50 \
-t 4 \
| grep 'Requests/sec' \
| awk '{ print " " $2 }'
kill $pid

511
docs/api.md Normal file
View File

@ -0,0 +1,511 @@
## 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.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 [true in "development"]
### 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 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
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
When a Koa application is created it injects
a middleware named `respond`, which handles
each of these `ctx.body` values. The `Content-Length`
header field is set when possible, and objects are
passed through `JSON.stringify()`.
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 = 'image/png';
this.type = '.png';
this.type = 'png';
```
__NOTE__: when `ctx.body` is an object the content-type is set for you.
### 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 using [qs](https://github.com/visionmedia/node-querystring). For example with the url "/shoes?page=2&sort=asc&filters[color]=blue"
`this.query` would be the following object:
```js
{
page: '2',
sort: 'asc',
filters: {
color: 'blue'
}
}
```
__NOTE__: this property returns `{}` when no query-string is present.
### ctx.query=
Set query-string to the given object.
```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.auth
Parses basic auth credentials and returns an object with `.username` and `.password`. It's recommended to use `ctx.secure` to ensure basic auth is only
used over HTTPS.
### 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.accept(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.accepted
Return accepted mime types ordered by quality.
### ctx.acceptedEncodings
Return accepted content encodings ordered by quality.
### ctx.acceptedCharsets
Return accepted charsets ordered by quality.
### ctx.acceptedLanguages
Return accepted languages ordered by quality.
### ctx.headerSent
Check if a response header has already been sent. Useful for seeing
if the client may be notified on error.
### ctx.socket
Request socket object.
## 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.
# License
MIT

58
examples/compose.js Normal file
View File

@ -0,0 +1,58 @@
var compose = require('koa-compose');
var http = require('http');
var koa = require('..');
var app = koa();
var calls = [];
// x-response-time
function responseTime(){
return function responseTime(next){
return function *(){
var start = new Date;
yield next;
var ms = new Date - start;
this.set('X-Response-Time', ms + 'ms');
}
}
}
// logger
function logger(){
return function logger(next){
return function *(){
var start = new Date;
yield next;
var ms = new Date - start;
console.log('%s %s - %s', this.method, this.url, ms);
}
}
}
// response
function respond() {
return function respond(next){
return function *(){
yield next;
if ('/' != this.url) return;
this.body = 'Hello World';
}
}
}
// composed middleware
function all() {
return compose([
responseTime(),
logger(),
respond()
]);
}
app.use(all());
http.createServer(app.callback()).listen(3000);

87
examples/negotiation.js Normal file
View File

@ -0,0 +1,87 @@
var koa = require('..');
var fs = require('fs');
var app = koa();
var tobi = {
_id: '123',
name: 'tobi',
species: 'ferret'
};
var loki = {
_id: '321',
name: 'loki',
species: 'ferret'
};
var users = {
tobi: tobi,
loki: loki
};
// content negotiation middleware.
// note that you should always check for
// presence of a body, and sometimes you
// may want to check the type, as it may
// be a stream, buffer, string, etc.
app.use(function(next){
return function *(){
yield next;
// responses vary on accepted type
this.vary('Accept');
this.status = 'bad request';
// no body? nothing to format, early return
if (!this.body) return;
// accepts json, koa handles this for us,
// so just return
if (this.accepts('json')) return;
// accepts xml
if (this.accepts('xml')) {
this.type = 'xml';
this.body = '<name>' + this.body.name + '</name>';
return;
}
// accepts html
if (this.accepts('html')) {
this.type = 'html';
this.body = '<h1>' + this.body.name + '</h1>';
return;
}
// default to text
this.body = this.body.name;
}
});
// filter responses, in this case remove ._id
// since it's private
app.use(function(next){
return function *(){
yield next;
if (!this.body) return;
delete this.body._id;
}
});
// try $ GET /tobi
// try $ GET /loki
app.use(function(){
return function *(){
var name = this.path.slice(1);
var user = users[name];
this.body = user;
}
});
app.listen(3000);

68
examples/route.js Normal file
View File

@ -0,0 +1,68 @@
var http = require('http');
var koa = require('..');
var app = koa();
var data;
// logger
app.use(function(next){
return function *(){
var start = new Date;
yield next;
var ms = new Date - start;
console.log('%s %s - %s', this.method, this.url, ms);
}
});
// response
app.use(get('/', function *(){
if (data) {
this.body = data;
} else {
this.body = 'POST to /';
}
}));
app.use(post('/', function *(){
data = yield buffer(this);
this.status = 201;
this.body = 'created';
}));
function buffer(ctx) {
return function(done){
var buf = '';
ctx.req.setEncoding('utf8');
ctx.req.on('data', function(chunk){
buf += chunk;
});
ctx.req.on('end', function(){
done(null, buf);
});
};
}
function get(path, fn) {
return route('GET', path, fn);
}
function post(path, fn) {
return route('POST', path, fn);
}
function route(method, path, fn) {
return function(next){
return function *() {
var match = method == this.method && this.path == path;
if (match) return yield fn;
yield next;
}
}
}
http.createServer(app.callback()).listen(3000);

59
examples/simple.js Normal file
View File

@ -0,0 +1,59 @@
var http = require('http');
var koa = require('..');
var app = koa();
// x-response-time
app.use(function(next){
return function *(){
var start = new Date;
yield next;
var ms = new Date - start;
this.set('X-Response-Time', ms + 'ms');
}
});
// logger
app.use(function(next){
return function *(){
var start = new Date;
yield next;
var ms = new Date - start;
console.log('%s %s - %s', this.method, this.url, ms);
}
});
// content-length
app.use(function(next){
return function *(){
yield next;
this.set('Content-Length', Buffer.byteLength(this.body));
}
});
// response
app.use(function(next){
return function *(){
yield next;
if ('/' != this.url) return;
this.status = 200;
this.body = 'Hello World';
}
});
// custom 404 handler
app.use(function(next){
return function *(){
yield next;
this.status = 404;
this.body = 'Sorry cannot find that!';
console.log(this);
}
});
http.createServer(app.callback()).listen(3000);

15
examples/streams.js Normal file
View File

@ -0,0 +1,15 @@
var koa = require('..');
var fs = require('fs');
var app = koa();
// try GET /streams.js
app.use(function(){
return function *(){
var path = __dirname + this.path;
this.body = fs.createReadStream(path);
}
});
app.listen(3000);

2
index.js Normal file
View File

@ -0,0 +1,2 @@
module.exports = require('./lib/application');

171
lib/application.js Normal file
View File

@ -0,0 +1,171 @@
/**
* Module dependencies.
*/
var debug = require('debug')('koa:app');
var Emitter = require('events').EventEmitter;
var compose = require('koa-compose');
var Context = require('./context');
var Stream = require('stream');
var http = require('http');
var co = require('co');
/**
* Application prototype.
*/
var app = Application.prototype;
/**
* Expose `Application`.
*/
exports = module.exports = Application;
/**
* Initialize a new `Application`.
*
* @api public
*/
function Application() {
if (!(this instanceof Application)) return new Application;
this.env = process.env.NODE_ENV || 'development';
this.outputErrors = 'development' == this.env;
this.subdomainOffset = 2;
this.poweredBy = true;
this.jsonSpaces = 2;
this.middleware = [];
}
/**
* Inherit from `Emitter.prototype`.
*/
Application.prototype.__proto__ = Emitter.prototype;
/**
* Shorthand for:
*
* http.createServer(app.callback()).listen(...)
*
* @param {Mixed} ...
* @return {Server}
* @api public
*/
app.listen = function(){
var server = http.createServer(this.callback());
return server.listen.apply(server, arguments);
};
/**
* Use the given middleware `fn`.
*
* @param {Function} fn
* @return {Application} self
* @api public
*/
app.use = function(fn){
debug('use %s', fn.name || 'unnamed');
this.middleware.push(fn);
return this;
};
/**
* Return a request handler callback
* for node's native htto server.
*
* @return {Function}
* @api public
*/
app.callback = function(){
var mw = [respond].concat(this.middleware);
var fn = compose(mw)(downstream);
var self = this;
return function(req, res){
var ctx = new Context(self, req, res);
function done(err) {
if (err) ctx.error(err);
}
co.call(ctx, function *(){
yield fn;
}, done);
}
};
/**
* Response middleware.
*/
function respond(next){
return function *(){
yield next;
var res = this.res;
var body = this.body;
var head = 'HEAD' == this.method;
var ignore = 204 == this.status || 304 == this.status;
// 404
if (null == body && 200 == this.status) {
this.status = 404;
}
// ignore body
if (ignore) return res.end();
// status body
if (null == body) {
this.set('Content-Type', 'text/plain');
body = http.STATUS_CODES[this.status];
}
// Buffer body
if (Buffer.isBuffer(body)) {
var ct = this.responseHeader['content-type'];
if (!ct) this.set('Content-Type', 'application/octet-stream');
this.set('Content-Length', body.length);
if (head) return res.end();
return res.end(body);
}
// string body
if ('string' == typeof body) {
var ct = this.responseHeader['content-type'];
if (!ct) this.set('Content-Type', 'text/plain; charset=utf-8');
this.set('Content-Length', Buffer.byteLength(body));
if (head) return res.end();
return res.end(body);
}
// Stream body
if (body instanceof Stream) {
body.on('error', this.error.bind(this));
if (head) return res.end();
return body.pipe(res);
}
// body: json
body = JSON.stringify(body, null, this.app.jsonSpaces);
this.set('Content-Length', body.length);
this.set('Content-Type', 'application/json');
if (head) return res.end();
res.end(body);
}
}
/**
* Default downstream middleware.
*/
function *downstream() {
this.status = 200;
if (this.app.poweredBy) this.set('X-Powered-By', 'koa');
}

907
lib/context.js Normal file
View File

@ -0,0 +1,907 @@
/**
* Module dependencies.
*/
var debug = require('debug')('koa:context');
var Negotiator = require('negotiator');
var statuses = require('./status');
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 qs = require('qs');
var parse = url.parse;
var stringify = url.format;
var ctx = Context.prototype;
/**
* Expose `Context`.
*/
module.exports = Context;
/**
* Initialize a new Context with raw node
* request and response objects.
*
* @param {Application} app
* @param {Request} req
* @param {Request} res
* @api public
*/
function Context(app, req, res) {
this.app = app;
this.req = req;
this.res = res;
}
/**
* Prototype.
*/
Context.prototype = {
/**
* Return request header.
*
* @return {Object}
* @api public
*/
get header() {
return this.req.headers;
},
/**
* Return response header.
*
* @return {Object}
* @api public
*/
get responseHeader() {
// TODO: wtf
return this.res._headers || {};
},
/**
* 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];
if (!n) throw new Error(statusError(val));
val = n;
}
this.res.statusCode = val;
},
/**
* Return response status string.
*
* @return {String}
* @api public
*/
get statusString() {
return http.STATUS_CODES[this.status];
},
/**
* 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() {
// TODO: memoize
return 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 {};
return 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() {
return 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.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.responseHeader);
}
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 parsed response Content-Length when present.
*
* @return {Number}
* @api public
*/
get responseLength() {
var len = this.responseHeader['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;
},
/**
* 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 basic auth credentials.
*
* Examples:
*
* // http://tobi:hello@example.com
* this.auth
* // => { username: 'tobi', password: 'hello' }
*
* @return {Object} or undefined
* @api public
*/
get auth() {
// missing
var auth = this.get('Authorization');
if (!auth) return;
// malformed
var parts = auth.split(' ');
if ('basic' != parts[0].toLowerCase()) return;
if (!parts[1]) return;
auth = parts[1];
// credentials
auth = new Buffer(auth, 'base64').toString().match(/^([^:]*):(.*)$/);
if (!auth) return;
return { username: auth[1], password: auth[2] };
},
/**
* 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}
* @api public
*/
accepts: function(types){
// TODO: memoize
if (!Array.isArray(types)) types = [].slice.call(arguments);
var normalized = types.map(extToMime);
var n = new Negotiator(this.req);
var accepts = n.preferredMediaTypes(normalized);
var first = accepts[0];
if (!first) return false;
return types[normalized.indexOf(first)];
},
/**
* Return accepted encodings.
*
* Given `Accept-Encoding: gzip, deflate`
* an array sorted by quality is returned:
*
* ['gzip', 'deflate']
*
* @return {Array}
* @api public
*/
get acceptedEncodings() {
var n = new Negotiator(this.req);
return n.preferredEncodings();
},
/**
* Return accepted 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']
*
* @return {Array}
* @api public
*/
get acceptedCharsets() {
var n = new Negotiator(this.req);
return n.preferredCharsets();
},
/**
* Return accepted languages.
*
* Given `Accept-Language: en;q=0.8, es, pt`
* an array sorted by quality is returned:
*
* ['es', 'pt', 'en']
*
* @return {Array}
* @api public
*/
get acceptedLanguages() {
var n = new Negotiator(this.req);
return n.preferredLanguages();
},
/**
* Return accepted media types.
*
* Given `Accept: application/*;q=0.2, image/jpeg;q=0.8, text/html`
* an array sorted by quality is returned:
*
* ['text/html', 'image/jpeg', 'application/*']
*
* @return {Array}
* @api public
*/
get accepted() {
var n = new Negotiator(this.req);
return n.preferredMediaTypes();
},
/**
* Check if a header has been written to the socket.
*
* @return {Boolean}
* @api public
*/
get headerSent() {
return this.res.headersSent;
},
/**
* Default error handling.
*
* @param {Error} err
* @api private
*/
error: function(err){
if (this.app.outputErrors) console.error(err.stack);
// TODO: change name
// TODO: header sent check
this.status = 500;
this.res.end('Internal Server Error');
},
/**
* 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;
},
/**
* 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.get('Referrer') || alt || '/';
this.set('Location', url);
this.status = 302;
// html
if (this.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);
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 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];
}
},
/**
* 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.responseHeader;
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 {
method: this.method,
status: this.status,
header: this.header,
responseHeader: this.responseHeader
}
}
};
/**
* Convert extnames to mime.
*
* @param {String} type
* @return {String}
* @api private
*/
function extToMime(type) {
if (~type.indexOf('/')) return type;
return mime.lookup(type);
}
/**
* 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;');
}

17
lib/status.js Normal file
View File

@ -0,0 +1,17 @@
/**
* Module dependencies.
*/
var http = require('http');
var codes = http.STATUS_CODES;
/**
* Produce exports[STATUS] = CODE map.
*/
Object.keys(codes).forEach(function(code){
var n = ~~code;
var s = codes[n].toLowerCase();
exports[s] = n;
});

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "koa",
"version": "0.0.1",
"description": "Koa web app framework",
"main": "index.js",
"directories": {
"example": "examples"
},
"scripts": {
"test": "make test"
},
"repository": "",
"keywords": [
"web",
"app",
"http",
"application",
"framework",
"middleware",
"rack"
],
"author": "",
"license": "MIT",
"dependencies": {
"co": "1.5.1",
"debug": "*",
"mime": "1.2.10",
"qs": "0.6.5",
"fresh": "0.2.0",
"negotiator": "0.2.7",
"koa-compose": "1.0.0"
},
"devDependencies": {
"bytes": "*",
"should": "1.2.2",
"mocha": "1.12.0",
"supertest": "0.7.1"
},
"engines": {
"node": "> 0.11.4"
}
}

255
test/application.js Normal file
View File

@ -0,0 +1,255 @@
var request = require('supertest');
var assert = require('assert');
var http = require('http');
var koa = require('..');
var fs = require('fs');
describe('app.use(fn)', function(){
it('should compose middleware', function(done){
var app = koa();
var calls = [];
app.use(function(next){
return function *(){
calls.push(1);
yield next;
calls.push(6);
}
});
app.use(function(next){
return function *(){
calls.push(2);
yield next;
calls.push(5);
}
});
app.use(function(next){
return function *(){
calls.push(3);
yield next;
calls.push(4);
}
});
var server = http.createServer(app.callback());
request(server)
.get('/')
.end(function(err){
if (err) return done(err);
calls.should.eql([1,2,3,4,5,6]);
done();
});
})
})
describe('app.respond', function(){
describe('when HEAD is used', function(){
it('should not respond with the body', function(done){
var app = koa();
app.use(function(next){
return function *(){
this.body = 'Hello';
}
});
var server = http.createServer(app.callback());
request(server)
.head('/')
.expect(200)
.end(function(err, res){
if (err) return done(err);
res.should.have.header('Content-Length', '5');
assert(0 == res.text.length);
done();
});
})
})
describe('when no middleware are present', function(){
it('should 404', function(done){
var app = koa();
var server = http.createServer(app.callback());
request(server)
.get('/')
.expect(404, done);
})
})
describe('when .body is missing', function(){
it('should respond with the associated status message', function(done){
var app = koa();
app.use(function(next){
return function *(){
this.status = 400;
this.body = null;
}
});
var server = http.createServer(app.callback());
request(server)
.get('/')
.expect(400)
.expect('Bad Request', done);
})
})
describe('when .body is a string', function(){
it('should respond', function(done){
var app = koa();
app.use(function(next){
return function *(){
this.body = 'Hello';
}
});
var server = http.createServer(app.callback());
request(server)
.get('/')
.expect('Hello', done);
})
})
describe('when .body is a Buffer', function(){
it('should respond', function(done){
var app = koa();
app.use(function(next){
return function *(){
this.body = new Buffer('Hello');
}
});
var server = http.createServer(app.callback());
request(server)
.get('/')
.expect('Hello', done);
})
})
describe('when .body is a Stream', function(){
it('should respond', function(done){
var app = koa();
app.use(function(next){
return function *(){
this.body = fs.createReadStream('package.json');
this.set('Content-Type', 'application/json');
}
});
var server = http.createServer(app.callback());
request(server)
.get('/')
.expect('Content-Type', 'application/json')
.end(function(err, res){
if (err) return done(err);
var pkg = require('../package');
res.body.should.eql(pkg);
done();
});
})
})
describe('when .body is an Object', function(){
it('should respond with json', function(done){
var app = koa();
app.use(function(next){
return function *(){
this.body = { hello: 'world' };
}
});
var server = http.createServer(app.callback());
request(server)
.get('/')
.expect('Content-Type', 'application/json')
.expect('{\n "hello": "world"\n}', done);
})
describe('when app.jsonSpaces is altered', function(){
it('should reflect in the formatting', function(done){
var app = koa();
app.jsonSpaces = 0;
app.use(function(next){
return function *(){
this.body = { hello: 'world' };
}
});
var server = http.createServer(app.callback());
request(server)
.get('/')
.expect('Content-Type', 'application/json')
.expect('{"hello":"world"}', done);
})
})
})
describe('when an error occurs', function(){
it('should respond with 500', function(done){
var app = koa();
app.use(function(next){
return function *(){
throw new Error('boom!');
}
});
var server = http.createServer(app.callback());
request(server)
.get('/')
.expect(500, 'Internal Server Error')
.end(done);
})
it('should be catchable', function(done){
var app = koa();
app.use(function(next){
return function *(){
try {
yield next;
this.body = 'Hello';
} catch (err) {
error = err;
this.body = 'Got error';
}
}
});
app.use(function(next){
return function *(){
throw new Error('boom!');
this.body = 'Oh no';
}
});
var server = http.createServer(app.callback());
request(server)
.get('/')
.expect(200, 'Got error')
.end(done);
})
})
})

649
test/context.js Normal file
View File

@ -0,0 +1,649 @@
var Context = require('../lib/context');
var assert = require('assert');
var koa = require('..');
var fs = require('fs');
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;
}
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);
ctx.body = {};
assert(null == ctx.responseLength);
})
})
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.auth', function(){
it('should parse basic auth', function(){
var ctx = context();
ctx.header.authorization = 'basic Zm9vOmJhcg==';
ctx.auth.username.should.equal('foo');
ctx.auth.password.should.equal('bar');
})
})
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);
})
})
describe('and invalid', function(){
it('should throw', function(){
var ctx = context();
var err;
try {
ctx.status = 'maru';
} catch (e) {
err = e;
}
assert(err);
})
})
})
})
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.accepted', function(){
describe('when Accept is populated', function(){
it('should return accepted types', function(){
var ctx = context();
ctx.req.headers.accept = 'application/*;q=0.2, image/jpeg;q=0.8, text/html, text/plain';
ctx.accepted.should.eql(['text/plain', 'text/html', 'image/jpeg', 'application/*']);
})
})
describe('when Accept is not populated', function(){
it('should return an empty array', function(){
var ctx = context();
ctx.accepted.should.eql([]);
})
})
})
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 types', function(){
it('should return false', function(){
var ctx = context();
ctx.accepts('').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.acceptedLanguages', 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.acceptedLanguages.should.eql(['pt', 'es', 'en']);
})
})
describe('when Accept-Language is not populated', function(){
it('should return an empty array', function(){
var ctx = context();
ctx.acceptedLanguages.should.eql([]);
})
})
})
describe('ctx.acceptedCharsets', 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.acceptedCharsets.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.acceptedCharsets.should.eql([]);
})
})
})
describe('ctx.acceptedEncodings', 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.acceptedEncodings.should.eql(['gzip', 'compress', 'identity']);
})
})
describe('when Accept-Encoding is not populated', function(){
it('should return identity', function(){
var ctx = context();
ctx.acceptedEncodings.should.eql(['identity']);
})
})
})
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 + '.');
})
})
})
function escape(html) {
return String(html)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}