diff --git a/.eslintrc.yml b/.eslintrc.yml index a1c8ab8..31e4f2b 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1 +1,3 @@ extends: koa +rules: + operator-linebreak: [error, before] diff --git a/lib/accepts.js b/lib/accepts.js new file mode 100644 index 0000000..0f68167 --- /dev/null +++ b/lib/accepts.js @@ -0,0 +1,79 @@ +const getMimetype = require('./getmimetype'); + +module.exports = function accepts(ctx, type, ask) { + if (!ctx._accept) { + ctx._accept = {}; + } + if (!ctx._accept[type]) { + let types = ctx.req.headers[type]; + let quality = 9999; // Little bit of a hack :) + if (types) { + types = types.split(',') + .map(x => { + x = x.trim(); + let q = quality--; + if (x.indexOf('q=') >= 0) { + q = parseFloat(x.substr(x.indexOf('q=') + 2)) || 1; + x = x.substr(0, x.indexOf(';')); + } + return [x, q]; + }) + .sort((a, b) => b[1] - a[1]) + .map(x => x[0]); + } else { + types = []; + } + + if (type === 'accept-encoding') { + types.push('identity'); + } + + ctx._accept[type] = types; + } + + let can = ctx._accept[type]; + + // If empty argument, return all supported can + if (ask.length === 0) { + return can; + } + + // If no supported was sent, return the first ask item + if (!can.length) { + return ask[0]; + } + + let parsed = ask.slice(); + + if (type === 'accept') { + for (let t = 0; t < parsed.length; t++) { + parsed[t] = getMimetype(parsed[t]) || parsed[t]; + } + } + + // Loop over the supported can, returning the first + // matching ask type. + for (let i = 0; i < can.length; i++) { + for (let t = 0; t < parsed.length; t++) { + // Check if we allow root checking (application/*) + if (type === 'accept') { + let allowRoot = can[i].indexOf('/*') >= 0; + + // Big if :) + if (can[i] === '*/*' + || can[i].indexOf(parsed[t]) >= 0 + || (allowRoot + && parsed[t].indexOf('/') >= 0 + && can[i].split('/')[0] === parsed[t].split('/')[0] + )) { + return ask[t]; + } + } else { + if (can[i] === parsed[t]) { + return ask[t]; + } + } + } + } + return false; +}; diff --git a/lib/context.js b/lib/context.js index 21fa2fd..d021c8a 100644 --- a/lib/context.js +++ b/lib/context.js @@ -195,6 +195,10 @@ delegate(proto, 'response') */ delegate(proto, 'request') + .method('acceptsLanguages') + .method('acceptsEncodings') + .method('acceptsCharsets') + .method('accepts') .method('get') .method('is') .access('querystring') diff --git a/lib/getmimetype.js b/lib/getmimetype.js new file mode 100644 index 0000000..72a65f2 --- /dev/null +++ b/lib/getmimetype.js @@ -0,0 +1,25 @@ +module.exports = function getMimetype(type, includeCharset) { + let charset = includeCharset ? '; charset=utf-8' : ''; + + if (type.indexOf('json') >= 0 || type.indexOf('css.map') >= 0 || type.indexOf('js.map') >= 0) { + return 'application/json' + charset; + } else if (type.indexOf('html') >= 0) { + return 'text/html' + charset; + } else if (type.indexOf('css') >= 0) { + return 'text/css' + charset; + } else if (type.indexOf('js') >= 0 || type.indexOf('javascript') >= 0) { + return 'application/javascript' + charset; + } else if (type.indexOf('png') >= 0) { + return 'image/png'; + } else if (type.indexOf('jpg') >= 0) { + return 'image/jpeg'; + } else if (type.indexOf('jpeg') >= 0) { + return 'image/jpeg'; + } else if (type.indexOf('gif') >= 0) { + return 'image/gif'; + } else if (type.indexOf('text') >= 0 || type.indexOf('txt') >= 0) { + return 'text/plain' + charset; + } else if (type.indexOf('bin') >= 0) { + return 'application/octet-stream'; + } +}; diff --git a/lib/request.js b/lib/request.js index 2eb529e..cdcbc2d 100644 --- a/lib/request.js +++ b/lib/request.js @@ -13,6 +13,7 @@ const qs = require('querystring'); const typeis = require('type-is'); const fresh = require('fresh'); const util = require('util'); +const accepts = require('./accepts'); const IP = Symbol('context#ip'); @@ -472,6 +473,164 @@ module.exports = { .slice(offset); }, + /** + * Get accept object. + * Lazily memoized. + * + * @return {Object} + * @api private + */ + accept(type, ) { + if (!this._accept) { + let types = this.req.headers.accept; + if (types) { + types = types.split(',') + .map(x => { + x = x.trim(); + let q = 1; + if (x.indexOf('q=') >= 0) { + q = parseFloat(x.substr(x.indexOf('q=') + 2)) || 1; + x = x.substr(0, x.indexOf(';')); + } + return [x, q]; + }) + .sort((a, b) => b[1] - a[1]) + .map(x => x[0]); + } else { + types = []; + } + + this._accept = { + types: types + }; + } + return this._accept; + }, + + /** + * Check if the given `type(s)` is acceptable, returning + * the best match when true, otherwise `false`, 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'); + * // => false + * + * // Accept: text/*;q=.5, application/json + * this.accepts(['html', 'json']); + * this.accepts('html', 'json'); + * // => "json" + * + * @param {String|Array} type(s)... + * @return {String|Array|false} + * @api public + */ + + accepts(...args) { + let types = [...args]; + + // If passed an array, grab it + if (types[0] instanceof Array) { + types = types[0]; + } + + return accepts(this, 'accept', types); + }, + + /** + * 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(...args) { + let types = [...args]; + + // If passed an array, grab it + if (types[0] instanceof Array) { + types = types[0]; + } + + return accepts(this, 'accept-encoding', types); + }, + + /** + * 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(...args) { + let types = [...args]; + + // If passed an array, grab it + if (types[0] instanceof Array) { + types = types[0]; + } + + return accepts(this, 'accept-charset', types); + }, + + /** + * 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(...args) { + let types = [...args]; + + // If passed an array, grab it + if (types[0] instanceof Array) { + types = types[0]; + } + + return accepts(this, 'accept-language', types); + }, + /** * Check if the incoming request contains the "Content-Type" * header field, and it contains any of the give mime `type`s. diff --git a/lib/response.js b/lib/response.js index 7195246..40fffdd 100644 --- a/lib/response.js +++ b/lib/response.js @@ -13,6 +13,7 @@ const typeis = require('type-is').is; const statuses = require('./statuses'); const assert = require('assert'); const extname = require('path').extname; +const getMimetype = require('./getmimetype'); const util = require('util'); /** @@ -344,28 +345,13 @@ module.exports = { type += '; charset=utf-8'; } this.set('Content-Type', type); - } else if (type.indexOf('json') >= 0 || type.indexOf('css.map') >= 0 || type.indexOf('js.map') >= 0) { - this.set('Content-Type', 'application/json; charset=utf-8'); - } else if (type.indexOf('html') >= 0) { - this.set('Content-Type', 'text/html; charset=utf-8'); - } else if (type.indexOf('css') >= 0) { - this.set('Content-Type', 'text/css; charset=utf-8'); - } else if (type.indexOf('js') >= 0 || type.indexOf('javascript') >= 0) { - this.set('Content-Type', 'application/javascript; charset=utf-8'); - } else if (type.indexOf('png') >= 0) { - this.set('Content-Type', 'image/png'); - } else if (type.indexOf('jpg') >= 0) { - this.set('Content-Type', 'image/jpeg'); - } else if (type.indexOf('jpeg') >= 0) { - this.set('Content-Type', 'image/jpeg'); - } else if (type.indexOf('gif') >= 0) { - this.set('Content-Type', 'image/gif'); - } else if (type.indexOf('text') >= 0) { - this.set('Content-Type', 'text/plain; charset=utf-8'); - } else if (type.indexOf('bin') >= 0) { - this.set('Content-Type', 'application/octet-stream'); } else { - this.remove('Content-Type'); + let mimetype = getMimetype(type, true); + if (mimetype) { + this.set('Content-Type', mimetype); + } else { + this.remove('Content-Type'); + } } }, diff --git a/test/request/accepts.js b/test/request/accepts.js new file mode 100644 index 0000000..784b578 --- /dev/null +++ b/test/request/accepts.js @@ -0,0 +1,94 @@ + +'use strict'; + +const assert = require('assert'); +const context = require('../helpers/context'); + +describe('ctx.accepts(types)', () => { + describe('with no arguments', () => { + describe('when Accept is populated', () => { + it('should return all accepted types', () => { + const ctx = context(); + ctx.req.headers.accept = 'application/*;q=0.2, image/jpeg;q=0.8, text/html, text/plain'; + assert.deepEqual(ctx.accepts(), ['text/html', 'text/plain', 'image/jpeg', 'application/*']); + }); + }); + }); + + describe('with no valid types', () => { + describe('when Accept is populated', () => { + it('should return false', () => { + const ctx = context(); + ctx.req.headers.accept = 'application/*;q=0.2, image/jpeg;q=0.8, text/html, text/plain'; + assert.equal(ctx.accepts('image/png', 'image/tiff'), false); + }); + }); + + describe('when Accept is not populated', () => { + it('should return the first type', () => { + const ctx = context(); + assert.equal(ctx.accepts('text/html', 'text/plain', 'image/jpeg', 'application/*'), 'text/html'); + }); + }); + }); + + describe('when extensions are given', () => { + it('should convert to mime types', () => { + const ctx = context(); + ctx.req.headers.accept = 'text/plain, text/html'; + assert.equal(ctx.accepts('html'), 'html'); + assert.equal(ctx.accepts('.html'), '.html'); + assert.equal(ctx.accepts('txt'), 'txt'); + assert.equal(ctx.accepts('.txt'), '.txt'); + assert.equal(ctx.accepts('png'), false); + }); + }); + + describe('when an array is given', () => { + it('should return the first match', () => { + const ctx = context(); + ctx.req.headers.accept = 'text/plain, text/html'; + assert.equal(ctx.accepts(['png', 'text', 'html']), 'text'); + assert.equal(ctx.accepts(['png', 'html']), 'html'); + }); + }); + + describe('when multiple arguments are given', () => { + it('should return the first match', () => { + const ctx = context(); + ctx.req.headers.accept = 'text/plain, text/html'; + assert.equal(ctx.accepts('png', 'text', 'html'), 'text'); + assert.equal(ctx.accepts('png', 'html'), 'html'); + }); + }); + + describe('when present in Accept as an exact match', () => { + it('should return the type', () => { + const ctx = context(); + ctx.req.headers.accept = 'text/plain, text/html'; + assert.equal(ctx.accepts('text/html'), 'text/html'); + assert.equal(ctx.accepts('text/plain'), 'text/plain'); + }); + }); + + describe('when present in Accept as a type match', () => { + it('should return the type', () => { + const ctx = context(); + ctx.req.headers.accept = 'application/json, */*'; + assert.equal(ctx.accepts('text/html'), 'text/html'); + assert.equal(ctx.accepts('text/plain'), 'text/plain'); + assert.equal(ctx.accepts('image/png'), 'image/png'); + }); + }); + + describe('when present in Accept as a subtype match', () => { + it('should return the type', () => { + const ctx = context(); + ctx.req.headers.accept = 'application/json, text/*'; + assert.equal(ctx.accepts('text/html'), 'text/html'); + assert.equal(ctx.accepts('text/plain'), 'text/plain'); + assert.equal(ctx.accepts('image/png'), false); + assert.equal(ctx.accepts('png'), false); + }); + }); +}); diff --git a/test/request/acceptsCharsets.js b/test/request/acceptsCharsets.js new file mode 100644 index 0000000..0f09200 --- /dev/null +++ b/test/request/acceptsCharsets.js @@ -0,0 +1,52 @@ + +'use strict'; + +const assert = require('assert'); +const context = require('../helpers/context'); + +describe('ctx.acceptsCharsets()', () => { + describe('with no arguments', () => { + describe('when Accept-Charset is populated', () => { + it('should return accepted types', () => { + const ctx = context(); + ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5'; + assert.deepEqual(ctx.acceptsCharsets(), ['utf-8', 'utf-7', 'iso-8859-1']); + }); + }); + }); + + describe('with multiple arguments', () => { + describe('when Accept-Charset is populated', () => { + describe('if any types match', () => { + it('should return the best fit', () => { + const ctx = context(); + ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5'; + assert.equal(ctx.acceptsCharsets('utf-7', 'utf-8'), 'utf-8'); + }); + }); + + describe('if no types match', () => { + it('should return false', () => { + const ctx = context(); + ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5'; + assert.equal(ctx.acceptsCharsets('utf-16'), false); + }); + }); + }); + + describe('when Accept-Charset is not populated', () => { + it('should return the first type', () => { + const ctx = context(); + assert.equal(ctx.acceptsCharsets('utf-7', 'utf-8'), 'utf-7'); + }); + }); + }); + + describe('with an array', () => { + it('should return the best fit', () => { + const ctx = context(); + ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5'; + assert.equal(ctx.acceptsCharsets(['utf-7', 'utf-8']), 'utf-8'); + }); + }); +}); diff --git a/test/request/acceptsEncodings.js b/test/request/acceptsEncodings.js new file mode 100644 index 0000000..13a5da8 --- /dev/null +++ b/test/request/acceptsEncodings.js @@ -0,0 +1,43 @@ + +'use strict'; + +const assert = require('assert'); +const context = require('../helpers/context'); + +describe('ctx.acceptsEncodings()', () => { + describe('with no arguments', () => { + describe('when Accept-Encoding is populated', () => { + it('should return accepted types', () => { + const ctx = context(); + ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2'; + assert.deepEqual(ctx.acceptsEncodings(), ['gzip', 'compress', 'identity']); + assert.equal(ctx.acceptsEncodings('gzip', 'compress'), 'gzip'); + }); + }); + + describe('when Accept-Encoding is not populated', () => { + it('should return identity', () => { + const ctx = context(); + assert.deepEqual(ctx.acceptsEncodings(), ['identity']); + assert.equal(ctx.acceptsEncodings('gzip', 'deflate', 'identity'), 'identity'); + }); + }); + }); + + describe('with multiple arguments', () => { + it('should return the best fit', () => { + const ctx = context(); + ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2'; + assert.equal(ctx.acceptsEncodings('compress', 'gzip'), 'gzip'); + assert.equal(ctx.acceptsEncodings('gzip', 'compress'), 'gzip'); + }); + }); + + describe('with an array', () => { + it('should return the best fit', () => { + const ctx = context(); + ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2'; + assert.equal(ctx.acceptsEncodings(['compress', 'gzip']), 'gzip'); + }); + }); +}); diff --git a/test/request/acceptsLanguages.js b/test/request/acceptsLanguages.js new file mode 100644 index 0000000..7c84160 --- /dev/null +++ b/test/request/acceptsLanguages.js @@ -0,0 +1,52 @@ + +'use strict'; + +const assert = require('assert'); +const context = require('../helpers/context'); + +describe('ctx.acceptsLanguages(langs)', () => { + describe('with no arguments', () => { + describe('when Accept-Language is populated', () => { + it('should return accepted types', () => { + const ctx = context(); + ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt'; + assert.deepEqual(ctx.acceptsLanguages(), ['es', 'pt', 'en']); + }); + }); + }); + + describe('with multiple arguments', () => { + describe('when Accept-Language is populated', () => { + describe('if any types types match', () => { + it('should return the best fit', () => { + const ctx = context(); + ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt'; + assert.equal(ctx.acceptsLanguages('es', 'en'), 'es'); + }); + }); + + describe('if no types match', () => { + it('should return false', () => { + const ctx = context(); + ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt'; + assert.equal(ctx.acceptsLanguages('fr', 'au'), false); + }); + }); + }); + + describe('when Accept-Language is not populated', () => { + it('should return the first type', () => { + const ctx = context(); + assert.equal(ctx.acceptsLanguages('es', 'en'), 'es'); + }); + }); + }); + + describe('with an array', () => { + it('should return the best fit', () => { + const ctx = context(); + ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt'; + assert.equal(ctx.acceptsLanguages(['es', 'en']), 'es'); + }); + }); +});