diff --git a/Makefile b/Makefile index 93ba33c..a86a7ee 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,9 @@ test: @NODE_ENV=test ./node_modules/.bin/mocha \ --require should \ --harmony-generators \ + test/context/* \ + test/request/* \ + test/response/* \ --bail bench: diff --git a/Readme.md b/Readme.md index aa5abe2..173fe5a 100644 --- a/Readme.md +++ b/Readme.md @@ -26,7 +26,7 @@ alias node='node --harmony-generators' ## Community - - [API](docs/api.md) documentation + - [API](docs/api/index.md) documentation - [Middleware](https://github.com/koajs/koa/wiki) list - [Wiki](https://github.com/koajs/koa/wiki) - [G+ Community](https://plus.google.com/communities/101845768320796750641) diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 7cb4379..0000000 --- a/docs/api.md +++ /dev/null @@ -1,661 +0,0 @@ -## Application - - A Koa application is not a 1-to-1 representation of an HTTP server, - as one or more Koa applications may be mounted together to form larger - applications, with a single HTTP server. - - The following is a useless Koa application bound to port `3000`: - -```js -var koa = require('koa'); -var app = koa(); -app.listen(3000); -``` - - The `app.listen(...)` method is simply sugar for the following: - -```js -var http = require('http'); -var koa = require('koa'); -var app = koa(); -http.createServer(app.callback()).listen(3000); -``` - - This means you can spin up the same application as both HTTP and HTTPS, - or on multiple addresses: - -```js -var http = require('http'); -var koa = require('koa'); -var app = koa(); -http.createServer(app.callback()).listen(3000); -http.createServer(app.callback()).listen(3001); -``` - -### Settings - - Application settings are properties on the `app` instance, currently - the following are supported: - - - `app.name` optionally give your application a name - - `app.env` defaulting to the __NODE_ENV__ or "development" - - `app.proxy` when true proxy header fields will be trusted - - `app.subdomainOffset` offset of `.subdomains` to ignore [2] - - `app.jsonSpaces` default JSON response spaces [2] - - `app.outputErrors` output err.stack to stderr [false in "test" environment] - -### app.listen(...) - - Create and return an HTTP server, passing the given arguments to - `Server#listen()`. These arguments are documented on [nodejs.org](http://nodejs.org/api/http.html#http_server_listen_port_hostname_backlog_callback). - -### app.callback() - - Return a callback function suitable for the `http.createServer()` - method to handle a request. - -### app.use(function) - - Add the given middleware function to this application. See [Middleware](#middleware) for - more information. - -## Context - - A Koa Context encapsulates node's `request` and `response` objects - into a single object which provides many helpful methods for writing - web applications and APIs. - - These operations are used so frequently in HTTP server development - that they are added at this level, instead of a higher level framework, - which would force middlware to re-implement this common functionality. - - A `Context` is created _per_ request, and is referenced in middleware - as the receiver, or the `this` variable. - -### ctx.req - - Node's `request` object. - -### ctx.res - - Node's `response` object. - -### ctx.app - - Application instance reference. - -### ctx.header - - Request header object. - -### ctx.responseHeader - - Response header object. - -### ctx.method - - Request method. - -### ctx.method= - - Set request method, useful for implementing middleware - such as `methodOverride()`. - -### ctx.status - - Get response status. - -### ctx.status= - - Set response status via numeric code or case-insensitive string: - - - 100 "continue" - - 101 "switching protocols" - - 102 "processing" - - 200 "ok" - - 201 "created" - - 202 "accepted" - - 203 "non-authoritative information" - - 204 "no content" - - 205 "reset content" - - 206 "partial content" - - 207 "multi-status" - - 300 "multiple choices" - - 301 "moved permanently" - - 302 "moved temporarily" - - 303 "see other" - - 304 "not modified" - - 305 "use proxy" - - 307 "temporary redirect" - - 400 "bad request" - - 401 "unauthorized" - - 402 "payment required" - - 403 "forbidden" - - 404 "not found" - - 405 "method not allowed" - - 406 "not acceptable" - - 407 "proxy authentication required" - - 408 "request time-out" - - 409 "conflict" - - 410 "gone" - - 411 "length required" - - 412 "precondition failed" - - 413 "request entity too large" - - 414 "request-uri too large" - - 415 "unsupported media type" - - 416 "requested range not satisfiable" - - 417 "expectation failed" - - 418 "i'm a teapot" - - 422 "unprocessable entity" - - 423 "locked" - - 424 "failed dependency" - - 425 "unordered collection" - - 426 "upgrade required" - - 428 "precondition required" - - 429 "too many requests" - - 431 "request header fields too large" - - 500 "internal server error" - - 501 "not implemented" - - 502 "bad gateway" - - 503 "service unavailable" - - 504 "gateway time-out" - - 505 "http version not supported" - - 506 "variant also negotiates" - - 507 "insufficient storage" - - 509 "bandwidth limit exceeded" - - 510 "not extended" - - 511 "network authentication required" - - __NOTE__: don't worry too much about memorizing these strings, - if you have a typo an error will be thrown, displaying this list - so you can make a correction. - -### ctx.length= - - Set response Content-Length to the given value. - -### ctx.length - - Return request Content-Length as a number when present, or undefined. - -### ctx.responseLength - - Return response Content-Length as a number when present, or deduce - from `ctx.body` when possible, or undefined. - -### ctx.body - - Get response body. When `ctx.body` is `null` and `ctx.status` is still - 200 it is considered a 404. This is to prevent the developer from manually - specifying `this.status = 200` on every response. - -### ctx.body= - - Set response body to one of the following: - - - `string` written - - `Buffer` written - - `Stream` piped - - `Object` json-stringified - - `null` no content response - -#### String - - The Content-Type is defaulted to text/html or text/plain, both with - a default charset of utf-8. The Content-Length field is also set. - -#### Buffer - - The Content-Type is defaulted to application/octet-stream, and Content-Length - is also set. - -#### Stream - - The Content-Type is defaulted to application/octet-stream. - -#### Object - - The Content-Type is defaulted to application/json. - -#### Notes - - To alter the JSON response formatting use the `app.jsonSpaces` - setting, for example to compress JSON responses set: - -```js -app.jsonSpaces = 0; -``` - -### ctx.get(field) - - Get a request header field value with case-insensitive `field`. - -```js -var etag = this.get('If-None-Match'); -``` - -### ctx.set(field, value) - - Set response header `field` to `value`: - -```js -this.set('Cache-Control', 'no-cache'); -``` - -### ctx.set(fields) - - Set several response header `fields` with an object: - -```js -this.set({ - 'Etag': '1234', - 'Last-Modified': date -}); -``` - -### ctx.type - - Get request `Content-Type` void of parameters such as "charset". - -```js -var ct = this.type; -// => "image/png" -``` - -### ctx.type= - - Set response `Content-Type` via mime string or file extension. - -```js -this.type = 'text/plain; charset=utf-8'; -this.type = 'image/png'; -this.type = '.png'; -this.type = 'png'; -``` - - Note: when appropriate a `charset` is selected for you, for - example `ctx.type = 'html'` will default to "utf-8", however - when explicitly defined in full as `ctx.type = 'text/html'` - no charset is assigned. - -### ctx.url - - Get request URL. - -### ctx.url= - - Set request URL, useful for url rewrites. - -### ctx.path - - Get request pathname. - -### ctx.path= - - Set request pathname and retain query-string when present. - -### ctx.query - - Get parsed query-string, returning an empty object when no - query-string is present. Note that this getter does _not_ - support nested parsing. - - For example "color=blue&size=small": - -```js -{ - color: 'blue', - size: 'small' -} -``` - -### ctx.query= - - Set query-string to the given object. Note that this - setter does _not_ support nested objects. - -```js -this.query = { next: '/login' }; -``` - -### ctx.querystring - - Get raw query string void of `?`. - -### ctx.querystring= - - Set raw query string. - -### ctx.host - - Get host void of port number when present. Supports `X-Forwarded-Host` - when `app.proxy` is __true__, otherwise `Host` is used. - -### ctx.fresh - - Check if a request cache is "fresh", aka the contents have not changed. This - method is for cache negotiation between `If-None-Match` / `ETag`, and `If-Modified-Since` and `Last-Modified`. It should be referenced after setting one or more of these response headers. - -```js -this.set('ETag', '123'); - -// cache is ok -if (this.fresh) { - this.status = 304; - return; -} - -// cache is stale -// fetch new data -this.body = yield db.find('something'); -``` - -### ctx.stale - - Inverse of `ctx.fresh`. - -### ctx.protocol - - Return request protocol, "https" or "http". Supports `X-Forwarded-Proto` - when `app.proxy` is __true__. - -### ctx.secure - - Shorthand for `this.protocol == "https"` to check if a requset was - issued via TLS. - -### ctx.ip - - Request remote address. Supports `X-Forwarded-For` when `app.proxy` - is __true__. - -### ctx.ips - - When `X-Forwarded-For` is present and `app.proxy` is enabled an array - of these ips is returned, ordered from upstream -> downstream. When disabled - an empty array is returned. - -### ctx.subdomains - - Return subdomains as an array. - - Subdomains are the dot-separated parts of the host before the main domain of - the app. By default, the domain of the app is assumed to be the last two - parts of the host. This can be changed by setting `app.subdomainOffset`. - - For example, if the domain is "tobi.ferrets.example.com": - If `app.subdomainOffset` is not set, this.subdomains is `["ferrets", "tobi"]`. - If `app.subdomainOffset` is 3, this.subdomains is `["tobi"]`. - -### ctx.is(type) - - Check if the incoming request contains the `Content-Type` - header field, and it contains the give mime `type`. - -```js -// With Content-Type: text/html; charset=utf-8 -this.is('html'); -this.is('.html'); -this.is('text/html'); -this.is('text/*'); -// => true - -// When Content-Type is application/json -this.is('json'); -this.is('.json'); -this.is('application/json'); -this.is('application/*'); -// => true - -this.is('html'); -// => false -``` - -### ctx.redirect(url, [alt]) - - Perform a 302 redirect to `url`. - - The string "back" is special-cased - to provide Referrer support, when Referrer - is not present `alt` or "/" is used. - -```js -this.redirect('back'); -this.redirect('back', '/index.html'); -this.redirect('/login'); -this.redirect('http://google.com'); -``` - - To alter the default status of `302` or the response - body simply re-assign after this call: - -```js -this.redirect('/cart'); -this.status = 301; -this.body = 'Redirecting to shopping cart'; -``` - -### ctx.attachment([filename]) - - Set `Content-Disposition` to "attachment" to signal the client - to prompt for download. Optionally specify the `filename` of the - download. - -### ctx.accepts(types) - - Check if the given `type(s)` is acceptable, returning - the best match when true, otherwise `undefined`, in which - case you should respond with 406 "Not Acceptable". - - The `type` value may be one or more mime type string - such as "application/json", the extension name - such as "json", or an array `["json", "html", "text/plain"]`. When a list or array is given the _best_ match, if any is returned. - -```js -// Accept: text/html -this.accepts('html'); -// => "html" - -// Accept: text/*, application/json -this.accepts('html'); -// => "html" -this.accepts('text/html'); -// => "text/html" -this.accepts('json', 'text'); -// => "json" -this.accepts('application/json'); -// => "application/json" - -// Accept: text/*, application/json -this.accepts('image/png'); -this.accepts('png'); -// => undefined - -// Accept: text/*;q=.5, application/json -this.accepts(['html', 'json']); -this.accepts('html', 'json'); -// => "json" -``` - - You may call `this.accepts()` as may times as you like, - or use a switch: - -```js -switch (this.accepts('json', 'html', 'text')) { - case 'json': break; - case 'html': break; - case 'text': break; -} -``` - -### ctx.acceptsEncodings(encodings) - - Check if `encodings` are acceptable, returning - the best match when true, otherwise `undefined`. - -```js -// Accept-Encoding: gzip -this.acceptsEncodings('gzip', 'deflate'); -// => "gzip" - -this.acceptsEncodings(['gzip', 'deflate']); -// => "gzip" -``` - - When no arguments are given all accepted encodings - are returned as an array: - -```js -// Accept-Encoding: gzip, deflate -this.acceptsEncodings(); -// => ["gzip", "deflate"] -``` - -### ctx.acceptsCharsets(charsets) - - Check if `charsets` are acceptable, returning - the best match when true, otherwise `undefined`. - -```js -// Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5 -this.acceptsCharsets('utf-8', 'utf-7'); -// => "utf-8" - -this.acceptsCharsets(['utf-7', 'utf-8']); -// => "utf-8" -``` - - When no arguments are given all accepted charsets - are returned as an array: - -```js -// Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5 -this.acceptsCharsets(); -// => ["utf-8", "utf-7", "iso-8859-1"] -``` - -### ctx.acceptsLanguages(langs) - - Check if `langs` are acceptable, returning - the best match when true, otherwise `undefined`. - -```js -// Accept-Language: en;q=0.8, es, pt -this.acceptsLanguages('es', 'en'); -// => "es" - -this.acceptsLanguages(['en', 'es']); -// => "es" -``` - - When no arguments are given all accepted languages - are returned as an array: - -```js -// Accept-Language: en;q=0.8, es, pt -this.acceptsLanguages(); -// => ["es", "pt", "en"] -``` - -### ctx.headerSent - - Check if a response header has already been sent. Useful for seeing - if the client may be notified on error. - -### ctx.cookies.get(name, [options]) - - Get cookie `name` with `options`: - - - `signed` the cookie requested should be signed - -### ctx.cookies.set(name, value, [options]) - - Set cookie `name` to `value` with `options`: - - - `signed` sign the cookie value - - `expires` a `Date` for cookie expiration - - `path` cookie path, `/'` by default - - `domain` cookie domain - - `secure` secure cookie - - `httpOnly` server-accessible cookie, __true__ by default - -### ctx.socket - - Request socket object. - -### ctx.error(msg, [status]) - - Helper method to throw an error with a `.status` property - that will allow Koa to respond appropriately. The following - combinations are allowed: - -```js -this.error(403) -this.error('name required', 400) -this.error('something exploded') -``` - - For example `this.error('name required', 400)` is requivalent to: - -```js -var err = new Error('name required'); -err.status = 400; -throw err; -``` - - Note that these are user-level errors and are flagged with - `err.expose` meaning the messages are appropriate for - client responses, which is typically not the case for - error messages since you do not want to leak failure - details. - -## Error Handling - - By default outputs all errors to stderr unless __NODE_ENV__ is "test". To perform custom error-handling logic such as centralized logging you - can add an "error" event listener: - -```js -app.on('error', function(err){ - log.error('server error', err); -}); -``` - - If an error in the req/res cycle and it is _not_ possible to respond to the client, the `Context` instance is also passed: - -```js -app.on('error', function(err){ - log.error('server error', err); -}); -``` - - When an error occurs _and_ it is still possible to respond to the client, aka no data has been written to the socket, Koa will respond - appropriately with a 500 "Internal Server Error". In either case - an app-level "error" is emitted for logging purposes. - -## Notes - -### HEAD Support - - Koa's upstream response middleware supports __HEAD__ for you, - however expensive requests would benefit from custom handling. For - example instead of reading a file into memory and piping it to the - client, you may wish to `stat()` and set the `Content-*` header fields - appropriately to bypass the read. - -### Socket Errors - - Node http servers emit a "clientError" event when a socket error occurs. You'll probably want to delegate this to your - Koa handler by doing the following, in order to centralize - logging: - -```js -var app = koa(); -var srv = app.listen(3000); -srv.on('clientError', function(err){ - app.emit('error', err); -}); -``` - -# License - - MIT diff --git a/docs/api/context.md b/docs/api/context.md new file mode 100644 index 0000000..7efde3e --- /dev/null +++ b/docs/api/context.md @@ -0,0 +1,132 @@ + +# Context + + A Koa Context encapsulates node's `request` and `response` objects + into a single object which provides many helpful methods for writing + web applications and APIs. + + Many accesors and methods simply delegate to their `ctx.request` or `ctx.response` + equivalents for convenience, and are otherwise identical. + + These operations are used so frequently in HTTP server development + that they are added at this level, instead of a higher level framework, + which would force middlware to re-implement this common functionality. + + A `Context` is created _per_ request, and is referenced in middleware + as the receiver, or the `this` identifier. + +## Request aliases + + The following accessors and alias [Request](request.md) equivalents: + + - `ctx.header` + - `ctx.method` + - `ctx.method=` + - `ctx.url` + - `ctx.url=` + - `ctx.path` + - `ctx.path=` + - `ctx.query` + - `ctx.query=` + - `ctx.querystring` + - `ctx.querystring=` + - `ctx.length` + - `ctx.host` + - `ctx.fresh` + - `ctx.stale` + - `ctx.socket` + - `ctx.protocol` + - `ctx.secure` + - `ctx.ip` + - `ctx.ips` + - `ctx.subdomains` + - `ctx.is()` + - `ctx.accepts()` + - `ctx.acceptsEncodings()` + - `ctx.acceptsCharsets()` + - `ctx.acceptsLanguages()` + - `ctx.get()` + +## Response aliases + + The following accessors and alias [Response](response.md) equivalents: + + - `ctx.body` + - `ctx.body=` + - `ctx.status` + - `ctx.status=` + - `ctx.length=` + - `ctx.type` + - `ctx.type=` + - `ctx.headerSent` + - `ctx.redirect()` + - `ctx.attachment()` + - `ctx.set()` + +## API + + `Context` specific methods and accessors. + +### ctx.req + + Node's `request` object. + +### ctx.res + + Node's `response` object. + +### ctx.request + + A koa `Request` object. + +### ctx.response + + A koa `Response` object. + +### ctx.app + + Application instance reference. + +### ctx.cookies.get(name, [options]) + + Get cookie `name` with `options`: + + - `signed` the cookie requested should be signed + +### ctx.cookies.set(name, value, [options]) + + Set cookie `name` to `value` with `options`: + + - `signed` sign the cookie value + - `expires` a `Date` for cookie expiration + - `path` cookie path, `/'` by default + - `domain` cookie domain + - `secure` secure cookie + - `httpOnly` server-accessible cookie, __true__ by default + +### ctx.error(msg, [status]) + + Helper method to throw an error with a `.status` property + that will allow Koa to respond appropriately. The following + combinations are allowed: + +```js +this.error(403) +this.error('name required', 400) +this.error('something exploded') +``` + + For example `this.error('name required', 400)` is requivalent to: + +```js +var err = new Error('name required'); +err.status = 400; +throw err; +``` + + Note that these are user-level errors and are flagged with + `err.expose` meaning the messages are appropriate for + client responses, which is typically not the case for + error messages since you do not want to leak failure + details. + \ No newline at end of file diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..5d0f4bf --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,119 @@ +## Application + + A Koa application is not a 1-to-1 representation of an HTTP server, + as one or more Koa applications may be mounted together to form larger + applications, with a single HTTP server. + + The following is a useless Koa application bound to port `3000`: + +```js +var koa = require('koa'); +var app = koa(); +app.listen(3000); +``` + + The `app.listen(...)` method is simply sugar for the following: + +```js +var http = require('http'); +var koa = require('koa'); +var app = koa(); +http.createServer(app.callback()).listen(3000); +``` + + This means you can spin up the same application as both HTTP and HTTPS, + or on multiple addresses: + +```js +var http = require('http'); +var koa = require('koa'); +var app = koa(); +http.createServer(app.callback()).listen(3000); +http.createServer(app.callback()).listen(3001); +``` + +### Settings + + Application settings are properties on the `app` instance, currently + the following are supported: + + - `app.name` optionally give your application a name + - `app.env` defaulting to the __NODE_ENV__ or "development" + - `app.proxy` when true proxy header fields will be trusted + - `app.subdomainOffset` offset of `.subdomains` to ignore [2] + - `app.jsonSpaces` default JSON response spaces [2] + - `app.outputErrors` output err.stack to stderr [false in "test" environment] + +### app.listen(...) + + Create and return an HTTP server, passing the given arguments to + `Server#listen()`. These arguments are documented on [nodejs.org](http://nodejs.org/api/http.html#http_server_listen_port_hostname_backlog_callback). + +### app.callback() + + Return a callback function suitable for the `http.createServer()` + method to handle a request. + +### app.use(function) + + Add the given middleware function to this application. See [Middleware](#middleware) for + more information. + +## Handling Requests + + Koa requests are manipulated using a `Context` object containing both a Koa `Request` and `Response` object. For more information on these view: + + - [Context](context.md) + - [Request](request.md) + - [Response](response.md) + +## Error Handling + + By default outputs all errors to stderr unless __NODE_ENV__ is "test". To perform custom error-handling logic such as centralized logging you + can add an "error" event listener: + +```js +app.on('error', function(err){ + log.error('server error', err); +}); +``` + + If an error in the req/res cycle and it is _not_ possible to respond to the client, the `Context` instance is also passed: + +```js +app.on('error', function(err){ + log.error('server error', err); +}); +``` + + When an error occurs _and_ it is still possible to respond to the client, aka no data has been written to the socket, Koa will respond + appropriately with a 500 "Internal Server Error". In either case + an app-level "error" is emitted for logging purposes. + +## Notes + +### HEAD Support + + Koa's upstream response middleware supports __HEAD__ for you, + however expensive requests would benefit from custom handling. For + example instead of reading a file into memory and piping it to the + client, you may wish to `stat()` and set the `Content-*` header fields + appropriately to bypass the read. + +### Socket Errors + + Node http servers emit a "clientError" event when a socket error occurs. You'll probably want to delegate this to your + Koa handler by doing the following, in order to centralize + logging: + +```js +var app = koa(); +var srv = app.listen(3000); +srv.on('clientError', function(err){ + app.emit('error', err); +}); +``` + +# License + + MIT diff --git a/docs/api/request.md b/docs/api/request.md new file mode 100644 index 0000000..c0553c9 --- /dev/null +++ b/docs/api/request.md @@ -0,0 +1,309 @@ + +# Request + + A Koa `Request` object is an abstraction on top of node's vanilla request object, + providing additional functionality that is useful for every day HTTP server + development. + +## API + +### req.header + + Request header object. + +### req.method + + Request method. + +### req.method= + + Set request method, useful for implementing middleware + such as `methodOverride()`. + +### req.length + + Return request Content-Length as a number when present, or undefined. + +### req.type + + Get request `Content-Type` void of parameters such as "charset". + +```js +var ct = this.type; +// => "image/png" +``` + +### req.url + + Get request URL. + +### req.url= + + Set request URL, useful for url rewrites. + +### req.path + + Get request pathname. + +### req.path= + + Set request pathname and retain query-string when present. + +### req.query + + Get parsed query-string, returning an empty object when no + query-string is present. Note that this getter does _not_ + support nested parsing. + + For example "color=blue&size=small": + +```js +{ + color: 'blue', + size: 'small' +} +``` + +### req.query= + + Set query-string to the given object. Note that this + setter does _not_ support nested objects. + +```js +this.query = { next: '/login' }; +``` + +### req.querystring + + Get raw query string void of `?`. + +### req.querystring= + + Set raw query string. + +### req.host + + Get host void of port number when present. Supports `X-Forwarded-Host` + when `app.proxy` is __true__, otherwise `Host` is used. + +### req.fresh + + Check if a request cache is "fresh", aka the contents have not changed. This + method is for cache negotiation between `If-None-Match` / `ETag`, and `If-Modified-Since` and `Last-Modified`. It should be referenced after setting one or more of these response headers. + +```js +this.set('ETag', '123'); + +// cache is ok +if (this.fresh) { + this.status = 304; + return; +} + +// cache is stale +// fetch new data +this.body = yield db.find('something'); +``` + +### req.stale + + Inverse of `req.fresh`. + +### req.protocol + + Return request protocol, "https" or "http". Supports `X-Forwarded-Proto` + when `app.proxy` is __true__. + +### req.secure + + Shorthand for `this.protocol == "https"` to check if a requset was + issued via TLS. + +### req.ip + + Request remote address. Supports `X-Forwarded-For` when `app.proxy` + is __true__. + +### req.ips + + When `X-Forwarded-For` is present and `app.proxy` is enabled an array + of these ips is returned, ordered from upstream -> downstream. When disabled + an empty array is returned. + +### req.subdomains + + Return subdomains as an array. + + Subdomains are the dot-separated parts of the host before the main domain of + the app. By default, the domain of the app is assumed to be the last two + parts of the host. This can be changed by setting `app.subdomainOffset`. + + For example, if the domain is "tobi.ferrets.example.com": + If `app.subdomainOffset` is not set, this.subdomains is `["ferrets", "tobi"]`. + If `app.subdomainOffset` is 3, this.subdomains is `["tobi"]`. + +### req.is(type) + + Check if the incoming request contains the `Content-Type` + header field, and it contains the give mime `type`. + +```js +// With Content-Type: text/html; charset=utf-8 +this.is('html'); +this.is('.html'); +this.is('text/html'); +this.is('text/*'); +// => true + +// When Content-Type is application/json +this.is('json'); +this.is('.json'); +this.is('application/json'); +this.is('application/*'); +// => true + +this.is('html'); +// => false +``` + +### req.accepts(types) + + Check if the given `type(s)` is acceptable, returning + the best match when true, otherwise `undefined`, in which + case you should respond with 406 "Not Acceptable". + + The `type` value may be one or more mime type string + such as "application/json", the extension name + such as "json", or an array `["json", "html", "text/plain"]`. When a list or array is given the _best_ match, if any is returned. + +```js +// Accept: text/html +this.accepts('html'); +// => "html" + +// Accept: text/*, application/json +this.accepts('html'); +// => "html" +this.accepts('text/html'); +// => "text/html" +this.accepts('json', 'text'); +// => "json" +this.accepts('application/json'); +// => "application/json" + +// Accept: text/*, application/json +this.accepts('image/png'); +this.accepts('png'); +// => undefined + +// Accept: text/*;q=.5, application/json +this.accepts(['html', 'json']); +this.accepts('html', 'json'); +// => "json" +``` + + You may call `this.accepts()` as may times as you like, + or use a switch: + +```js +switch (this.accepts('json', 'html', 'text')) { + case 'json': break; + case 'html': break; + case 'text': break; +} +``` + +### req.acceptsEncodings(encodings) + + Check if `encodings` are acceptable, returning + the best match when true, otherwise `undefined`. + +```js +// Accept-Encoding: gzip +this.acceptsEncodings('gzip', 'deflate'); +// => "gzip" + +this.acceptsEncodings(['gzip', 'deflate']); +// => "gzip" +``` + + When no arguments are given all accepted encodings + are returned as an array: + +```js +// Accept-Encoding: gzip, deflate +this.acceptsEncodings(); +// => ["gzip", "deflate"] +``` + +### req.acceptsCharsets(charsets) + + Check if `charsets` are acceptable, returning + the best match when true, otherwise `undefined`. + +```js +// Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5 +this.acceptsCharsets('utf-8', 'utf-7'); +// => "utf-8" + +this.acceptsCharsets(['utf-7', 'utf-8']); +// => "utf-8" +``` + + When no arguments are given all accepted charsets + are returned as an array: + +```js +// Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5 +this.acceptsCharsets(); +// => ["utf-8", "utf-7", "iso-8859-1"] +``` + +### req.acceptsLanguages(langs) + + Check if `langs` are acceptable, returning + the best match when true, otherwise `undefined`. + +```js +// Accept-Language: en;q=0.8, es, pt +this.acceptsLanguages('es', 'en'); +// => "es" + +this.acceptsLanguages(['en', 'es']); +// => "es" +``` + + When no arguments are given all accepted languages + are returned as an array: + +```js +// Accept-Language: en;q=0.8, es, pt +this.acceptsLanguages(); +// => ["es", "pt", "en"] +``` + +### req.error(msg, [status]) + + Helper method to throw an error with a `.status` property + that will allow Koa to respond appropriately. The following + combinations are allowed: + +```js +this.error(403) +this.error('name required', 400) +this.error('something exploded') +``` + + For example `this.error('name required', 400)` is requivalent to: + +```js +var err = new Error('name required'); +err.status = 400; +throw err; +``` + + Note that these are user-level errors and are flagged with + `err.expose` meaning the messages are appropriate for + client responses, which is typically not the case for + error messages since you do not want to leak failure + details. diff --git a/docs/api/response.md b/docs/api/response.md new file mode 100644 index 0000000..d588a67 --- /dev/null +++ b/docs/api/response.md @@ -0,0 +1,221 @@ + +# Response + + A Koa `Response` object is an abstraction on top of node's vanilla response object, + providing additional functionality that is useful for every day HTTP server + development. + +## API + +### res.header + + Response header object. + +### res.status + + Get response status. + +### res.status= + + Set response status via numeric code or case-insensitive string: + + - 100 "continue" + - 101 "switching protocols" + - 102 "processing" + - 200 "ok" + - 201 "created" + - 202 "accepted" + - 203 "non-authoritative information" + - 204 "no content" + - 205 "reset content" + - 206 "partial content" + - 207 "multi-status" + - 300 "multiple choices" + - 301 "moved permanently" + - 302 "moved temporarily" + - 303 "see other" + - 304 "not modified" + - 305 "use proxy" + - 307 "temporary redirect" + - 400 "bad request" + - 401 "unauthorized" + - 402 "payment required" + - 403 "forbidden" + - 404 "not found" + - 405 "method not allowed" + - 406 "not acceptable" + - 407 "proxy authentication required" + - 408 "request time-out" + - 409 "conflict" + - 410 "gone" + - 411 "length required" + - 412 "precondition failed" + - 413 "request entity too large" + - 414 "request-uri too large" + - 415 "unsupported media type" + - 416 "requested range not satisfiable" + - 417 "expectation failed" + - 418 "i'm a teapot" + - 422 "unprocessable entity" + - 423 "locked" + - 424 "failed dependency" + - 425 "unordered collection" + - 426 "upgrade required" + - 428 "precondition required" + - 429 "too many requests" + - 431 "request header fields too large" + - 500 "internal server error" + - 501 "not implemented" + - 502 "bad gateway" + - 503 "service unavailable" + - 504 "gateway time-out" + - 505 "http version not supported" + - 506 "variant also negotiates" + - 507 "insufficient storage" + - 509 "bandwidth limit exceeded" + - 510 "not extended" + - 511 "network authentication required" + + __NOTE__: don't worry too much about memorizing these strings, + if you have a typo an error will be thrown, displaying this list + so you can make a correction. + +### res.length= + + Set response Content-Length to the given value. + +### res.length + + Return response Content-Length as a number when present, or deduce + from `res.body` when possible, or undefined. + +### res.body + + Get response body. When `res.body` is `null` and `res.status` is still + 200 it is considered a 404. This is to prevent the developer from manually + specifying `this.status = 200` on every response. + +### res.body= + + Set response body to one of the following: + + - `string` written + - `Buffer` written + - `Stream` piped + - `Object` json-stringified + - `null` no content response + +#### String + + The Content-Type is defaulted to text/html or text/plain, both with + a default charset of utf-8. The Content-Length field is also set. + +#### Buffer + + The Content-Type is defaulted to application/octet-stream, and Content-Length + is also set. + +#### Stream + + The Content-Type is defaulted to application/octet-stream. + +#### Object + + The Content-Type is defaulted to application/json. + +#### Notes + + To alter the JSON response formatting use the `app.jsonSpaces` + setting, for example to compress JSON responses set: + +```js +app.jsonSpaces = 0; +``` + +### res.get(field) + + Get a response header field value with case-insensitive `field`. + +```js +var etag = this.get('ETag'); +``` + +### res.set(field, value) + + Set response header `field` to `value`: + +```js +this.set('Cache-Control', 'no-cache'); +``` + +### res.set(fields) + + Set several response header `fields` with an object: + +```js +this.set({ + 'Etag': '1234', + 'Last-Modified': date +}); +``` + +### res.type + + Get response `Content-Type` void of parameters such as "charset". + +```js +var ct = this.type; +// => "image/png" +``` + +### res.type= + + Set response `Content-Type` via mime string or file extension. + +```js +this.type = 'text/plain; charset=utf-8'; +this.type = 'image/png'; +this.type = '.png'; +this.type = 'png'; +``` + + Note: when appropriate a `charset` is selected for you, for + example `res.type = 'html'` will default to "utf-8", however + when explicitly defined in full as `res.type = 'text/html'` + no charset is assigned. + +### res.redirect(url, [alt]) + + Perform a 302 redirect to `url`. + + The string "back" is special-cased + to provide Referrer support, when Referrer + is not present `alt` or "/" is used. + +```js +this.redirect('back'); +this.redirect('back', '/index.html'); +this.redirect('/login'); +this.redirect('http://google.com'); +``` + + To alter the default status of `302` or the response + body simply re-assign after this call: + +```js +this.redirect('/cart'); +this.status = 301; +this.body = 'Redirecting to shopping cart'; +``` + +### res.attachment([filename]) + + Set `Content-Disposition` to "attachment" to signal the client + to prompt for download. Optionally specify the `filename` of the + download. + +### res.headerSent + + Check if a response header has already been sent. Useful for seeing + if the client may be notified on error. + diff --git a/lib/context.js b/lib/context.js index ebdc9b5..5d25ffb 100644 --- a/lib/context.js +++ b/lib/context.js @@ -1,22 +1,13 @@ + /** * Module dependencies. */ var debug = require('debug')('koa:context'); -var Negotiator = require('negotiator'); -var statuses = require('./status'); +var Request = require('./request'); +var Response = require('./response'); var Cookies = require('cookies'); -var qs = require('querystring'); -var Stream = require('stream'); -var fresh = require('fresh'); var http = require('http'); -var path = require('path'); -var mime = require('mime'); -var basename = path.basename; -var extname = path.extname; -var url = require('url'); -var parse = url.parse; -var stringify = url.format; /** * Expose `Context`. @@ -31,11 +22,13 @@ module.exports = Context; */ function Context(app, req, res){ - this.cookies = new Cookies(req, res); this.onerror = this.onerror.bind(this); + this.cookies = new Cookies(req, res); this.app = app; this.req = req; this.res = res; + this.request = new Request(this); + this.response = new Response(this); } /** @@ -45,600 +38,335 @@ function Context(app, req, res){ Context.prototype = { /** - * Return request header. - * - * @return {Object} - * @api public + * Delegate to Request#header. */ get header() { - return this.req.headers; + return this.request.header; }, /** - * Return response header. - * - * @return {Object} - * @api public - */ - - get responseHeader() { - // TODO: wtf - return this.res._headers || {}; - }, - - /** - * Return response status string. - * - * @return {String} - * @api public - */ - - get statusString() { - return http.STATUS_CODES[this.status]; - }, - - /** - * Get request URL. - * - * @return {String} - * @api public + * Delegate to Request#url. */ get url() { - return this.req.url; + return this.request.url; }, /** - * Set request URL. - * - * @api public + * Delegate to Request#url=. */ set url(val) { - this.req.url = val; + this.request.url = val; }, /** - * Get request method. - * - * @return {String} - * @api public + * Delegate to Request#method. */ + get method() { - return this.req.method; + return this.request.method; }, /** - * Set request method. - * - * @param {String} val - * @api public + * Delegate to Request#method=. */ set method(val) { - this.req.method = val; + this.request.method = val; }, /** - * Get response status code. - * - * @return {Number} - * @api public + * Delegate to Response#status. */ - get status() { - return this.res.statusCode; - }, - get statusCode() { - return this.res.statusCode; + get status() { + return this.response.status; }, /** - * Set response status code. - * - * @param {Number|String} val - * @api public + * Delegate to Response#status=. */ set status(val) { - if ('string' == typeof val) { - var n = statuses[val.toLowerCase()]; - if (!n) throw new Error(statusError(val)); - val = n; - } - - this.res.statusCode = val; - - var noContent = 304 == this.status || 204 == this.status; - if (noContent && this.body) this.body = null; - }, - - set statusCode(val) { - this.status = val; + this.response.status = val; }, /** - * Get response body. - * - * @return {Mixed} - * @api public + * Delegate to Response#body. */ get body() { - return this._body; + return this.response.body; }, /** - * Set response body. - * - * @param {String|Buffer|Object|Stream} val - * @api public + * Delegate to Response#body=. */ set body(val) { - this._body = val; - - // no content - if (null == val) { - var s = this.status; - this.status = 304 == s ? 304 : 204; - this.res.removeHeader('Content-Type'); - this.res.removeHeader('Content-Length'); - this.res.removeHeader('Transfer-Encoding'); - return; - } - - // set the content-type only if not yet set - var setType = !this.responseHeader['content-type']; - - // string - if ('string' == typeof val) { - if (setType) this.type = ~val.indexOf('<') ? 'html' : 'text'; - this.length = Buffer.byteLength(val); - return; - } - - // buffer - if (Buffer.isBuffer(val)) { - if (setType) this.type = 'bin'; - this.length = val.length; - return; - } - - // stream - if (val instanceof Stream) { - if (setType) this.type = 'bin'; - return; - } - - // json - this.type = 'json'; + this.response.body = val; }, /** - * Get request pathname. - * - * @return {String} - * @api public + * Delegate to Request#path. */ get path() { - var c = this._pathcache = this._pathcache || {}; - return c[this.url] || (c[this.url] = parse(this.url).pathname); + return this.request.path; }, /** - * Set pathname, retaining the query-string when present. - * - * @param {String} path - * @api public + * Delegate to Request#path=. */ - set path(path) { - var url = parse(this.url); - url.pathname = path; - this.url = stringify(url); + set path(val) { + this.request.path = val; }, /** - * Get parsed query-string. - * - * @return {Object} - * @api public + * Delegate to Request#query. */ get query() { - var str = this.querystring; - if (!str) return {}; - - var c = this._querycache = this._querycache || {}; - return c[str] || (c[str] = qs.parse(str)); + return this.request.query; }, /** - * Set query-string as an object. - * - * @param {Object} obj - * @api public + * Delegate to Request#query=. */ - set query(obj) { - this.querystring = qs.stringify(obj); + set query(val) { + this.request.query = val; }, /** - * Get query string. - * - * @return {String} - * @api public + * Delegate to Request#querystring. */ get querystring() { - var c = this._qscache = this._qscache || {}; - return c[this.url] || (c[this.url] = parse(this.url).query || ''); + return this.request.querystring; }, /** - * Set querystring. - * - * @param {String} str - * @api public + * Delegate to Request#querystring=. */ - set querystring(str) { - var url = parse(this.url); - url.search = str; - this.url = stringify(url); + set querystring(val) { + this.request.querystring = val; }, /** - * Parse the "Host" header field hostname - * and support X-Forwarded-Host when a - * proxy is enabled. - * - * @return {String} - * @api public + * Delegate to Request#host. */ 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]; + return this.request.host; }, /** - * Check if the request is fresh, aka - * Last-Modified and/or the ETag - * still match. - * - * @return {Boolean} - * @api public + * Delegate to Request#fresh. */ 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; + return this.request.fresh; }, /** - * Check if the request is stale, aka - * "Last-Modified" and / or the "ETag" for the - * resource has changed. - * - * @return {Boolean} - * @api public + * Delegate to Request#stale. */ get stale() { - return !this.fresh; + return this.request.stale; }, /** - * Check if the request is idempotent. - * - * @return {Boolean} - * @api public + * Delegate to Request#idempotent. */ get idempotent() { - return 'GET' == this.method - || 'HEAD' == this.method; + return this.request.idempotent; }, /** - * Return the request socket. - * - * @return {Connection} - * @api public + * Delegate to Request#socket. */ get socket() { - // TODO: TLS - return this.req.socket; + return this.request.socket; }, /** - * Return parsed Content-Length when present. - * - * @return {Number} - * @api public + * Delegate to Request#length. */ get length() { - var len = this.get('Content-Length'); - if (null == len) return; - return ~~len; + return this.request.length; }, /** - * Set Content-Length field to `n`. - * - * @param {Number} n - * @api public + * Delegate to Request#length. */ - set length(n) { - this.set('Content-Length', n); + set length(val) { + this.response.length = val; }, /** - * 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 + * Delegate to Request#protocol. */ 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]; + return this.request.protocol; }, /** - * Short-hand for: - * - * this.protocol == 'https' - * - * @return {Boolean} - * @api public + * Delegate to Request#secure. */ get secure() { - return 'https' == this.protocol; + return this.request.secure; }, /** - * Return the remote address, or when - * `app.proxy` is `true` return - * the upstream addr. - * - * @return {String} - * @api public + * Delegate to Request#ip. */ get ip() { - return this.ips[0] || this.connection.remoteAddress; + return this.request.ip; }, /** - * 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 + * Delegate to Request#ips. */ get ips() { - var proxy = this.app.proxy; - var val = this.get('X-Forwarded-For'); - return proxy && val - ? val.split(/ *, */) - : []; + return this.request.ips; }, /** - * 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 + * Delegate to Request#subdomains. */ get subdomains() { - var offset = this.app.subdomainOffset; - return (this.host || '') - .split('.') - .reverse() - .slice(offset); + return this.request.subdomains; }, /** - * Check if the given `type(s)` is acceptable, returning - * the best match when true, otherwise `undefined`, in which - * case you should respond with 406 "Not Acceptable". - * - * The `type` value may be a single mime type string - * such as "application/json", the extension name - * such as "json" or an array `["json", "html", "text/plain"]`. When a list - * or array is given the _best_ match, if any is returned. - * - * Examples: - * - * // Accept: text/html - * this.accepts('html'); - * // => "html" - * - * // Accept: text/*, application/json - * this.accepts('html'); - * // => "html" - * this.accepts('text/html'); - * // => "text/html" - * this.accepts('json', 'text'); - * // => "json" - * this.accepts('application/json'); - * // => "application/json" - * - * // Accept: text/*, application/json - * this.accepts('image/png'); - * this.accepts('png'); - * // => undefined - * - * // Accept: text/*;q=.5, application/json - * this.accepts(['html', 'json']); - * this.accepts('html', 'json'); - * // => "json" - * - * @param {String|Array} type(s)... - * @return {String|Array|Boolean} - * @api public - */ - - accepts: function(types){ - if (!Array.isArray(types)) types = [].slice.call(arguments); - var n = new Negotiator(this.req); - if (!types.length) return n.preferredMediaTypes(); - var mimes = types.map(extToMime); - var accepts = n.preferredMediaTypes(mimes); - var first = accepts[0]; - if (!first) return false; - return types[mimes.indexOf(first)]; - }, - - /** - * Return accepted encodings or best fit based on `encodings`. - * - * Given `Accept-Encoding: gzip, deflate` - * an array sorted by quality is returned: - * - * ['gzip', 'deflate'] - * - * @param {String|Array} encoding(s)... - * @return {String|Array} - * @api public - */ - - acceptsEncodings: function(encodings) { - if (!Array.isArray(encodings)) encodings = [].slice.call(arguments); - var n = new Negotiator(this.req); - if (!encodings.length) return n.preferredEncodings(); - return n.preferredEncodings(encodings)[0]; - }, - - /** - * Return accepted charsets or best fit based on `charsets`. - * - * Given `Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5` - * an array sorted by quality is returned: - * - * ['utf-8', 'utf-7', 'iso-8859-1'] - * - * @param {String|Array} charset(s)... - * @return {String|Array} - * @api public - */ - - acceptsCharsets: function(charsets) { - if (!Array.isArray(charsets)) charsets = [].slice.call(arguments); - var n = new Negotiator(this.req); - if (!charsets.length) return n.preferredCharsets(); - return n.preferredCharsets(charsets)[0]; - }, - - /** - * Return accepted languages or best fit based on `langs`. - * - * Given `Accept-Language: en;q=0.8, es, pt` - * an array sorted by quality is returned: - * - * ['es', 'pt', 'en'] - * - * @param {String|Array} lang(s)... - * @return {Array|String} - * @api public - */ - - acceptsLanguages: function(langs) { - if (!Array.isArray(langs)) langs = [].slice.call(arguments); - var n = new Negotiator(this.req); - if (!langs.length) return n.preferredLanguages(); - return n.preferredLanguages(langs)[0]; - }, - - /** - * Check if a header has been written to the socket. - * - * @return {Boolean} - * @api public + * Delegate to Response#headerSent. */ get headerSent() { - return this.res.headersSent; + return this.response.headerSent; }, /** - * Alias of `.headerSent` - * - * @return {Boolean} - * @api public + * Delegate to Response#type=. */ - get headersSent() { - return this.res.headersSent; + set type(val) { + this.response.type = val; }, + /** + * Delegate to Request#type. + */ + + get type() { + return this.request.type; + }, + + /** + * Delegate to Request#accepts(). + */ + + accepts: function() { + return this.request.accepts.apply(this.request, arguments); + }, + + /** + * Delegate to Request#acceptsCharsets(). + */ + + acceptsCharsets: function() { + return this.request.acceptsCharsets.apply(this.request, arguments); + }, + + /** + * Delegate to Request#acceptsEncodings(). + */ + + acceptsEncodings: function() { + return this.request.acceptsEncodings.apply(this.request, arguments); + }, + + /** + * Delegate to Request#acceptsLanguages(). + */ + + acceptsLanguages: function() { + return this.request.acceptsLanguages.apply(this.request, arguments); + }, + + /** + * Delegate to Response#vary(). + */ + + vary: function() { + return this.response.vary.apply(this.response, arguments); + }, + + /** + * Delegate to Request#is(). + */ + + is: function() { + return this.request.is.apply(this.request, arguments); + }, + + /** + * Delegate to Response#append(). + */ + + append: function() { + return this.response.append.apply(this.response, arguments); + }, + + /** + * Delegate to Request#get(). + */ + + get: function() { + return this.request.get.apply(this.request, arguments); + }, + + /** + * Delegate to Response#set(). + */ + + set: function() { + return this.response.set.apply(this.response, arguments); + }, + + /** + * Delegate to Response#redirect(). + */ + + redirect: function() { + return this.response.redirect.apply(this.response, arguments); + }, + + /** + * Delegate to Response#attachment(). + */ + + attachment: function() { + return this.response.attachment.apply(this.response, arguments); + }, + /** * Throw an error with `msg` and optional `status` * defaulting to 500. Note that these are user-level @@ -706,315 +434,5 @@ Context.prototype = { var msg = err.expose ? err.message : code; this.status = err.status; this.res.end(msg); - }, - - /** - * 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); - if (!~[300, 301, 302, 303, 305, 307].indexOf(this.status)) 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); - var cs = mime.charsets.lookup(type); - if (cs) type += '; charset=' + cs.toLowerCase(); - } - - this.set('Content-Type', type); - }, - - /** - * Return the request mime type void of - * parameters such as "charset". - * - * @return {String} - * @api public - */ - - get type() { - var type = this.get('Content-Type'); - if (!type) return; - return type.split(';')[0]; - }, - - /** - * Return 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, '>'); -} diff --git a/lib/request.js b/lib/request.js new file mode 100644 index 0000000..18f0f75 --- /dev/null +++ b/lib/request.js @@ -0,0 +1,612 @@ + +/** + * Module dependencies. + */ + +var debug = require('debug')('koa:context'); +var Negotiator = require('negotiator'); +var statuses = require('./status'); +var Cookies = require('cookies'); +var qs = require('querystring'); +var Stream = require('stream'); +var fresh = require('fresh'); +var http = require('http'); +var path = require('path'); +var mime = require('mime'); +var basename = path.basename; +var extname = path.extname; +var url = require('url'); +var parse = url.parse; +var stringify = url.format; + +/** + * Expose `Request`. + */ + +module.exports = Request; + +/** + * Initialize a new Request. + * + * @api private + */ + +function Request(ctx){ + this.app = ctx.app; + this.req = ctx.req; + this.ctx = ctx; +} + +/** + * Prototype. + */ + +Request.prototype = { + + /** + * Return request header. + * + * @return {Object} + * @api public + */ + + get header() { + return this.req.headers; + }, + + /** + * Get request URL. + * + * @return {String} + * @api public + */ + + get url() { + return this.req.url; + }, + + /** + * Set request URL. + * + * @api public + */ + + set url(val) { + this.req.url = val; + }, + + /** + * Get request method. + * + * @return {String} + * @api public + */ + + get method() { + return this.req.method; + }, + + /** + * Set request method. + * + * @param {String} val + * @api public + */ + + set method(val) { + this.req.method = val; + }, + + /** + * Get request pathname. + * + * @return {String} + * @api public + */ + + get path() { + var c = this._pathcache = this._pathcache || {}; + return c[this.url] || (c[this.url] = parse(this.url).pathname); + }, + + /** + * Set pathname, retaining the query-string when present. + * + * @param {String} path + * @api public + */ + + set path(path) { + var url = parse(this.url); + url.pathname = path; + this.url = stringify(url); + }, + + /** + * Get parsed query-string. + * + * @return {Object} + * @api public + */ + + get query() { + var str = this.querystring; + if (!str) return {}; + + var c = this._querycache = this._querycache || {}; + return c[str] || (c[str] = qs.parse(str)); + }, + + /** + * Set query-string as an object. + * + * @param {Object} obj + * @api public + */ + + set query(obj) { + this.querystring = qs.stringify(obj); + }, + + /** + * Get query string. + * + * @return {String} + * @api public + */ + + get querystring() { + var c = this._qscache = this._qscache || {}; + return c[this.url] || (c[this.url] = parse(this.url).query || ''); + }, + + /** + * Set querystring. + * + * @param {String} str + * @api public + */ + + set querystring(str) { + var url = parse(this.url); + url.search = str; + this.url = stringify(url); + }, + + /** + * Parse the "Host" header field hostname + * and support X-Forwarded-Host when a + * proxy is enabled. + * + * @return {String} + * @api public + */ + + get host() { + var proxy = this.app.proxy; + var host = proxy && this.get('X-Forwarded-Host'); + host = host || this.get('Host'); + if (!host) return; + return host.split(/\s*,\s*/)[0].split(':')[0]; + }, + + /** + * Check if the request is fresh, aka + * Last-Modified and/or the ETag + * still match. + * + * @return {Boolean} + * @api public + */ + + get fresh() { + var method = this.method; + var s = this.ctx.status; + + // GET or HEAD for weak freshness validation only + if ('GET' != method && 'HEAD' != method) return false; + + // 2xx or 304 as per rfc2616 14.26 + if ((s >= 200 && s < 300) || 304 == s) { + return fresh(this.header, this.ctx.response.header); + } + + return false; + }, + + /** + * Check if the request is stale, aka + * "Last-Modified" and / or the "ETag" for the + * resource has changed. + * + * @return {Boolean} + * @api public + */ + + get stale() { + return !this.fresh; + }, + + /** + * Check if the request is idempotent. + * + * @return {Boolean} + * @api public + */ + + get idempotent() { + return 'GET' == this.method + || 'HEAD' == this.method; + }, + + /** + * Return the request socket. + * + * @return {Connection} + * @api public + */ + + get socket() { + // TODO: TLS + return this.req.socket; + }, + + /** + * Return parsed Content-Length when present. + * + * @return {Number} + * @api public + */ + + get length() { + var len = this.get('Content-Length'); + if (null == len) return; + return ~~len; + }, + + /** + * Return the protocol string "http" or "https" + * when requested with TLS. When the proxy setting + * is enabled the "X-Forwarded-Proto" header + * field will be trusted. If you're running behind + * a reverse proxy that supplies https for you this + * may be enabled. + * + * @return {String} + * @api public + */ + + get protocol() { + var proxy = this.app.proxy; + if (this.socket.encrypted) return 'https'; + if (!proxy) return 'http'; + var proto = this.get('X-Forwarded-Proto') || 'http'; + return proto.split(/\s*,\s*/)[0]; + }, + + /** + * Short-hand for: + * + * this.protocol == 'https' + * + * @return {Boolean} + * @api public + */ + + get secure() { + return 'https' == this.protocol; + }, + + /** + * Return the remote address, or when + * `app.proxy` is `true` return + * the upstream addr. + * + * @return {String} + * @api public + */ + + get ip() { + return this.ips[0] || this.connection.remoteAddress; + }, + + /** + * When `app.proxy` is `true`, parse + * the "X-Forwarded-For" ip address list. + * + * For example if the value were "client, proxy1, proxy2" + * you would receive the array `["client", "proxy1", "proxy2"]` + * where "proxy2" is the furthest down-stream. + * + * @return {Array} + * @api public + */ + + get ips() { + var proxy = this.app.proxy; + var val = this.get('X-Forwarded-For'); + return proxy && val + ? val.split(/ *, */) + : []; + }, + + /** + * Return subdomains as an array. + * + * Subdomains are the dot-separated parts of the host before the main domain of + * the app. By default, the domain of the app is assumed to be the last two + * parts of the host. This can be changed by setting `app.subdomainOffset`. + * + * For example, if the domain is "tobi.ferrets.example.com": + * If `app.subdomainOffset` is not set, this.subdomains is `["ferrets", "tobi"]`. + * If `app.subdomainOffset` is 3, this.subdomains is `["tobi"]`. + * + * @return {Array} + * @api public + */ + + get subdomains() { + var offset = this.app.subdomainOffset; + return (this.host || '') + .split('.') + .reverse() + .slice(offset); + }, + + /** + * Check if the given `type(s)` is acceptable, returning + * the best match when true, otherwise `undefined`, in which + * case you should respond with 406 "Not Acceptable". + * + * The `type` value may be a single mime type string + * such as "application/json", the extension name + * such as "json" or an array `["json", "html", "text/plain"]`. When a list + * or array is given the _best_ match, if any is returned. + * + * Examples: + * + * // Accept: text/html + * this.accepts('html'); + * // => "html" + * + * // Accept: text/*, application/json + * this.accepts('html'); + * // => "html" + * this.accepts('text/html'); + * // => "text/html" + * this.accepts('json', 'text'); + * // => "json" + * this.accepts('application/json'); + * // => "application/json" + * + * // Accept: text/*, application/json + * this.accepts('image/png'); + * this.accepts('png'); + * // => undefined + * + * // Accept: text/*;q=.5, application/json + * this.accepts(['html', 'json']); + * this.accepts('html', 'json'); + * // => "json" + * + * @param {String|Array} type(s)... + * @return {String|Array|Boolean} + * @api public + */ + + accepts: function(types){ + if (!Array.isArray(types)) types = [].slice.call(arguments); + var n = new Negotiator(this.req); + if (!types.length) return n.preferredMediaTypes(); + var mimes = types.map(extToMime); + var accepts = n.preferredMediaTypes(mimes); + var first = accepts[0]; + if (!first) return false; + return types[mimes.indexOf(first)]; + }, + + /** + * Return accepted encodings or best fit based on `encodings`. + * + * Given `Accept-Encoding: gzip, deflate` + * an array sorted by quality is returned: + * + * ['gzip', 'deflate'] + * + * @param {String|Array} encoding(s)... + * @return {String|Array} + * @api public + */ + + acceptsEncodings: function(encodings) { + if (!Array.isArray(encodings)) encodings = [].slice.call(arguments); + var n = new Negotiator(this.req); + if (!encodings.length) return n.preferredEncodings(); + return n.preferredEncodings(encodings)[0]; + }, + + /** + * Return accepted charsets or best fit based on `charsets`. + * + * Given `Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5` + * an array sorted by quality is returned: + * + * ['utf-8', 'utf-7', 'iso-8859-1'] + * + * @param {String|Array} charset(s)... + * @return {String|Array} + * @api public + */ + + acceptsCharsets: function(charsets) { + if (!Array.isArray(charsets)) charsets = [].slice.call(arguments); + var n = new Negotiator(this.req); + if (!charsets.length) return n.preferredCharsets(); + return n.preferredCharsets(charsets)[0]; + }, + + /** + * Return accepted languages or best fit based on `langs`. + * + * Given `Accept-Language: en;q=0.8, es, pt` + * an array sorted by quality is returned: + * + * ['es', 'pt', 'en'] + * + * @param {String|Array} lang(s)... + * @return {Array|String} + * @api public + */ + + acceptsLanguages: function(langs) { + if (!Array.isArray(langs)) langs = [].slice.call(arguments); + var n = new Negotiator(this.req); + if (!langs.length) return n.preferredLanguages(); + return n.preferredLanguages(langs)[0]; + }, + + /** + * Vary on `field`. + * + * @param {String} field + * @api public + */ + + vary: function(field){ + this.append('Vary', field); + }, + + /** + * Check if the incoming request contains the "Content-Type" + * header field, and it contains the give mime `type`. + * + * Examples: + * + * // With Content-Type: text/html; charset=utf-8 + * this.is('html'); + * this.is('text/html'); + * this.is('text/*'); + * // => true + * + * // When Content-Type is application/json + * this.is('json'); + * this.is('application/json'); + * this.is('application/*'); + * // => true + * + * this.is('html'); + * // => false + * + * @param {String} type + * @return {Boolean} + * @api public + */ + + is: function(type){ + var ct = this.type; + if (!ct) return false; + ct = ct.split(';')[0]; + + // extension given + if (!~type.indexOf('/')) type = mime.lookup(type); + + // type or subtype match + if (~type.indexOf('*')) { + type = type.split('/'); + ct = ct.split('/'); + if ('*' == type[0] && type[1] == ct[1]) return true; + if ('*' == type[1] && type[0] == ct[0]) return true; + return false; + } + + // exact match + return type == ct; + }, + + /** + * Return the request mime type void of + * parameters such as "charset". + * + * @return {String} + * @api public + */ + + get type() { + var type = this.get('Content-Type'); + if (!type) return; + return type.split(';')[0]; + }, + + /** + * Return request header. + * + * The `Referrer` header field is special-cased, + * both `Referrer` and `Referer` are interchangeable. + * + * Examples: + * + * this.get('Content-Type'); + * // => "text/plain" + * + * this.get('content-type'); + * // => "text/plain" + * + * this.get('Something'); + * // => undefined + * + * @param {String} name + * @return {String} + * @api public + */ + + get: function(name){ + var req = this.req; + switch (name = name.toLowerCase()) { + case 'referer': + case 'referrer': + return req.headers.referrer || req.headers.referer; + default: + return req.headers[name]; + } + }, + + /** + * Inspect implementation. + * + * TODO: add tests + * + * @return {Object} + * @api public + */ + + inspect: function(){ + return this.toJSON(); + }, + + /** + * Return JSON representation. + * + * @return {Object} + * @api public + */ + + toJSON: function(){ + return { + method: this.method, + header: this.header + } + } +}; + +/** + * Convert extnames to mime. + * + * @param {String} type + * @return {String} + * @api private + */ + +function extToMime(type) { + if (~type.indexOf('/')) return type; + return mime.lookup(type); +} diff --git a/lib/response.js b/lib/response.js new file mode 100644 index 0000000..479e7fc --- /dev/null +++ b/lib/response.js @@ -0,0 +1,433 @@ + +/** + * Module dependencies. + */ + +var debug = require('debug')('koa:response'); +var Negotiator = require('negotiator'); +var statuses = require('./status'); +var Cookies = require('cookies'); +var qs = require('querystring'); +var Stream = require('stream'); +var fresh = require('fresh'); +var http = require('http'); +var path = require('path'); +var mime = require('mime'); +var basename = path.basename; +var extname = path.extname; +var url = require('url'); +var parse = url.parse; +var stringify = url.format; + +/** + * Expose `Response`. + */ + +module.exports = Response; + +/** + * Initialize a new Response. + * + * @api private + */ + +function Response(ctx){ + this.res = ctx.res; + this.ctx = ctx; +} + +/** + * Prototype. + */ + +Response.prototype = { + + /** + * Return response header. + * + * @return {Object} + * @api public + */ + + get header() { + // TODO: wtf + return this.res._headers || {}; + }, + + /** + * Return response status string. + * + * @return {String} + * @api public + */ + + get statusString() { + return http.STATUS_CODES[this.status]; + }, + + /** + * Get response status code. + * + * @return {Number} + * @api public + */ + + get status() { + return this.res.statusCode; + }, + + /** + * Set response status code. + * + * @param {Number|String} val + * @api public + */ + + set status(val) { + if ('string' == typeof val) { + var n = statuses[val.toLowerCase()]; + if (!n) throw new Error(statusError(val)); + val = n; + } + + this.res.statusCode = val; + + var noContent = 304 == this.status || 204 == this.status; + if (noContent && this.body) this.body = null; + }, + + /** + * Get response body. + * + * @return {Mixed} + * @api public + */ + + get body() { + return this._body; + }, + + /** + * Set response body. + * + * @param {String|Buffer|Object|Stream} val + * @api public + */ + + set body(val) { + this._body = val; + + // no content + if (null == val) { + var s = this.status; + this.status = 304 == s ? 304 : 204; + this.res.removeHeader('Content-Type'); + this.res.removeHeader('Content-Length'); + this.res.removeHeader('Transfer-Encoding'); + return; + } + + // set the content-type only if not yet set + var setType = !this.header['content-type']; + + // string + if ('string' == typeof val) { + if (setType) this.type = ~val.indexOf('<') ? 'html' : 'text'; + this.length = Buffer.byteLength(val); + return; + } + + // buffer + if (Buffer.isBuffer(val)) { + if (setType) this.type = 'bin'; + this.length = val.length; + return; + } + + // stream + if (val instanceof Stream) { + if (setType) this.type = 'bin'; + return; + } + + // json + this.type = 'json'; + }, + + /** + * Set Content-Length field to `n`. + * + * @param {Number} n + * @api public + */ + + set length(n) { + this.set('Content-Length', n); + }, + + /** + * Return parsed response Content-Length when present. + * + * @return {Number} + * @api public + */ + + get length() { + var len = this.header['content-length']; + var body = this.body; + + if (null == len) { + if (!body) return; + if ('string' == typeof body) return Buffer.byteLength(body); + return body.length; + } + + return ~~len; + }, + + /** + * Check if a header has been written to the socket. + * + * @return {Boolean} + * @api public + */ + + get headerSent() { + return this.res.headersSent; + }, + + /** + * Vary on `field`. + * + * @param {String} field + * @api public + */ + + vary: function(field){ + this.append('Vary', field); + }, + + /** + * Perform a 302 redirect to `url`. + * + * The string "back" is special-cased + * to provide Referrer support, when Referrer + * is not present `alt` or "/" is used. + * + * Examples: + * + * this.redirect('back'); + * this.redirect('back', '/index.html'); + * this.redirect('/login'); + * this.redirect('http://google.com'); + * + * @param {String} url + * @param {String} alt + * @api public + */ + + redirect: function(url, alt){ + if ('back' == url) url = this.ctx.get('Referrer') || alt || '/'; + this.set('Location', url); + if (!~[300, 301, 302, 303, 305, 307].indexOf(this.status)) this.status = 302; + + // html + if (this.ctx.accepts('html')) { + url = escape(url); + this.type = 'text/html; charset=utf-8'; + this.body = 'Redirecting to ' + 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); + var cs = mime.charsets.lookup(type); + if (cs) type += '; charset=' + cs.toLowerCase(); + } + + this.set('Content-Type', type); + }, + + /** + * Return the request mime type void of + * parameters such as "charset". + * + * @return {String} + * @api public + */ + + get type() { + var type = this.get('Content-Type'); + if (!type) return; + return type.split(';')[0]; + }, + + /** + * Return response header. + * + * Examples: + * + * this.get('Content-Type'); + * // => "text/plain" + * + * this.get('content-type'); + * // => "text/plain" + * + * @param {String} name + * @return {String} + * @api public + */ + + get: function(name){ + return this.header[name.toLowerCase()]; + }, + + /** + * Set header `field` to `val`, or pass + * an object of header fields. + * + * Examples: + * + * this.set('Foo', ['bar', 'baz']); + * this.set('Accept', 'application/json'); + * this.set({ Accept: 'text/plain', 'X-API-Key': 'tobi' }); + * + * @param {String|Object|Array} field + * @param {String} val + * @api public + */ + + set: function(field, val){ + if (2 == arguments.length) { + if (Array.isArray(val)) val = val.map(String); + else val = String(val); + this.res.setHeader(field, val); + } else { + for (var key in field) { + this.set(key, field[key]); + } + } + }, + + /** + * Append `val` to header `field`. + * + * @param {String} field + * @param {String} val + * @api public + */ + + append: function(field, val){ + field = field.toLowerCase(); + var header = this.header; + var list = header[field]; + + // not set + if (!list) return this.set(field, val); + + // append + list = list.split(/ *, */); + if (!~list.indexOf(val)) list.push(val); + this.set(field, list.join(', ')); + }, + + /** + * Inspect implementation. + * + * TODO: add tests + * + * @return {Object} + * @api public + */ + + inspect: function(){ + var o = this.toJSON(); + o.body = this.body; + o.statusString = this.statusString; + return o; + }, + + /** + * Return JSON representation. + * + * @return {Object} + * @api public + */ + + toJSON: function(){ + return { + status: this.status, + header: this.header + } + } +}; + +/** + * Return status error message. + * + * @param {String} val + * @return {String} + * @api private + */ + +function statusError(val) { + var s = 'invalid status string "' + val + '", try:\n\n'; + + Object.keys(statuses).forEach(function(name){ + var n = statuses[name]; + s += ' - ' + n + ' "' + name + '"\n'; + }); + + return s; +} + +/** + * Escape special characters in the given string of html. + * + * @param {String} html + * @return {String} + * @api private + */ + +function escape(html) { + return String(html) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} diff --git a/test/context.js b/test/context.js index 1b1f499..2494012 100644 --- a/test/context.js +++ b/test/context.js @@ -13,872 +13,4 @@ function context(req, res) { return ctx; } -describe('ctx.body=', function(){ - describe('when Content-Type is set', function(){ - it('should not override', function(){ - var ctx = context(); - ctx.type = 'png'; - ctx.body = new Buffer('something'); - assert('image/png' == ctx.responseHeader['content-type']); - }) - - describe('when body is an object', function(){ - it('should override as json', function(){ - var ctx = context(); - - ctx.body = 'hey'; - assert('text/html; charset=utf-8' == ctx.responseHeader['content-type']); - - ctx.body = { foo: 'bar' }; - assert('application/json' == ctx.responseHeader['content-type']); - }) - }) - - it('should override length', function(){ - var ctx = context(); - ctx.type = 'html'; - ctx.body = 'something'; - ctx.responseLength.should.equal(9); - }) - }) - - describe('when a string is given', function(){ - it('should default to text', function(){ - var ctx = context(); - ctx.body = 'Tobi'; - assert('text/plain; charset=utf-8' == ctx.responseHeader['content-type']); - }) - - it('should set length', function(){ - var ctx = context(); - ctx.body = 'Tobi'; - assert('4' == ctx.responseHeader['content-length']); - }) - }) - - describe('when an html string is given', function(){ - it('should default to html', function(){ - var ctx = context(); - ctx.body = '

Tobi

'; - assert('text/html; charset=utf-8' == ctx.responseHeader['content-type']); - }) - - it('should set length', function(){ - var string = '

Tobi

'; - var ctx = context(); - ctx.body = string; - assert.equal(ctx.responseLength, Buffer.byteLength(string)); - }) - - it('should set length when body is overriden', function(){ - var string = '

Tobi

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