From 9e167c5ed9c2411723a444ff28b2ccb23817de88 Mon Sep 17 00:00:00 2001 From: TJ Holowaychuk Date: Sat, 17 Aug 2013 00:15:57 -0700 Subject: [PATCH] Initial commit --- .gitignore | 1 + .npmignore | 5 + LICENSE | 22 + Makefile | 11 + Readme.md | 75 ++++ benchmarks/Makefile | 13 + benchmarks/middleware.js | 28 ++ benchmarks/run | 16 + docs/api.md | 511 ++++++++++++++++++++++ examples/compose.js | 58 +++ examples/negotiation.js | 87 ++++ examples/route.js | 68 +++ examples/simple.js | 59 +++ examples/streams.js | 15 + index.js | 2 + lib/application.js | 171 ++++++++ lib/context.js | 907 +++++++++++++++++++++++++++++++++++++++ lib/status.js | 17 + package.json | 42 ++ test/application.js | 255 +++++++++++ test/context.js | 649 ++++++++++++++++++++++++++++ 21 files changed, 3012 insertions(+) create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 Readme.md create mode 100644 benchmarks/Makefile create mode 100644 benchmarks/middleware.js create mode 100755 benchmarks/run create mode 100644 docs/api.md create mode 100644 examples/compose.js create mode 100644 examples/negotiation.js create mode 100644 examples/route.js create mode 100644 examples/simple.js create mode 100644 examples/streams.js create mode 100644 index.js create mode 100644 lib/application.js create mode 100644 lib/context.js create mode 100644 lib/status.js create mode 100644 package.json create mode 100644 test/application.js create mode 100644 test/context.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..a8a6f4c --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +docs +benchmarks +examples +test +Makefile diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..36075a3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2009-2011 TJ Holowaychuk + +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. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..93ba33c --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ + +test: + @NODE_ENV=test ./node_modules/.bin/mocha \ + --require should \ + --harmony-generators \ + --bail + +bench: + @$(MAKE) -C benchmarks + +.PHONY: test bench diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..173db71 --- /dev/null +++ b/Readme.md @@ -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 diff --git a/benchmarks/Makefile b/benchmarks/Makefile new file mode 100644 index 0000000..baf0d6f --- /dev/null +++ b/benchmarks/Makefile @@ -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 diff --git a/benchmarks/middleware.js b/benchmarks/middleware.js new file mode 100644 index 0000000..300b892 --- /dev/null +++ b/benchmarks/middleware.js @@ -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); diff --git a/benchmarks/run b/benchmarks/run new file mode 100755 index 0000000..d1ad6d4 --- /dev/null +++ b/benchmarks/run @@ -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 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..d44effd --- /dev/null +++ b/docs/api.md @@ -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 diff --git a/examples/compose.js b/examples/compose.js new file mode 100644 index 0000000..e953640 --- /dev/null +++ b/examples/compose.js @@ -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); diff --git a/examples/negotiation.js b/examples/negotiation.js new file mode 100644 index 0000000..9cb0865 --- /dev/null +++ b/examples/negotiation.js @@ -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 = '' + this.body.name + ''; + return; + } + + // accepts html + if (this.accepts('html')) { + this.type = 'html'; + this.body = '

' + this.body.name + '

'; + 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); diff --git a/examples/route.js b/examples/route.js new file mode 100644 index 0000000..aed2cd9 --- /dev/null +++ b/examples/route.js @@ -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); diff --git a/examples/simple.js b/examples/simple.js new file mode 100644 index 0000000..73049dc --- /dev/null +++ b/examples/simple.js @@ -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); diff --git a/examples/streams.js b/examples/streams.js new file mode 100644 index 0000000..3b9f005 --- /dev/null +++ b/examples/streams.js @@ -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); diff --git a/index.js b/index.js new file mode 100644 index 0000000..c1dfcc2 --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ + +module.exports = require('./lib/application'); diff --git a/lib/application.js b/lib/application.js new file mode 100644 index 0000000..6423e1c --- /dev/null +++ b/lib/application.js @@ -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'); +} diff --git a/lib/context.js b/lib/context.js new file mode 100644 index 0000000..05b90de --- /dev/null +++ b/lib/context.js @@ -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 ' + url + '.'; + 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, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} \ No newline at end of file diff --git a/lib/status.js b/lib/status.js new file mode 100644 index 0000000..a9b879b --- /dev/null +++ b/lib/status.js @@ -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; +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..66dcd30 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/test/application.js b/test/application.js new file mode 100644 index 0000000..77307e3 --- /dev/null +++ b/test/application.js @@ -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); + }) + }) +}) \ No newline at end of file diff --git a/test/context.js b/test/context.js new file mode 100644 index 0000000..4219a6f --- /dev/null +++ b/test/context.js @@ -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 ' + url + '.'); + }) + + it('should escape the url', function(){ + var ctx = context(); + var url = '