├── index.js ├── test ├── views │ └── index.html └── index.js ├── .travis.yml ├── .gitignore ├── package.json ├── LICENSE ├── lib └── index.js └── README.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); -------------------------------------------------------------------------------- /test/views/index.html: -------------------------------------------------------------------------------- 1 | {{data}} -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 0.10 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-negotiator", 3 | "version": "2.0.1", 4 | "description": "Provides support for content negotiation in Hapi", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node ./node_modules/lab/bin/lab -c" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/felipeleusin/hapi-negotiator" 12 | }, 13 | "keywords": [ 14 | "http", 15 | "content negotiation", 16 | "accept", 17 | "accept-language", 18 | "accept-encoding", 19 | "accept-charset", 20 | "hapi" 21 | ], 22 | "author": "Felipe Leusin (felipe.leusin@gmail.com)", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "code": "^1.2.0", 26 | "handlebars": "^2.0.0", 27 | "hapi": "^8.1.0", 28 | "hapi-auth-basic": "^2.0.0", 29 | "lab": "^5.0.1", 30 | "negotiator": "^0.4.9", 31 | "sinon": "^1.10.3" 32 | }, 33 | "dependencies": { 34 | "boom": "^2.6.1", 35 | "hoek": "^2.11.0", 36 | "negotiator": "^0.5.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Felipe Leusin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var Negotiator = require('negotiator'); 2 | var Hoek = require('hoek'); 3 | var Boom = require('boom'); 4 | 5 | exports.register = function (plugin,options,next) { 6 | 7 | var defaults = { 8 | mediaTypes: { 9 | 'application/json': true, 10 | 'text/plain': true, 11 | } 12 | }; 13 | 14 | var config = Hoek.applyToDefaults(defaults, options); 15 | 16 | plugin.ext('onPostHandler', function (request, reply) { 17 | var requestSettings = request.route.settings.plugins['hapi-negotiator']; 18 | 19 | if (requestSettings === false) { 20 | return reply.continue(); 21 | } 22 | 23 | requestSettings = Hoek.applyToDefaults(config, requestSettings || {}); 24 | var requestNegotiator = new Negotiator(request.raw.req); 25 | var selectedMediaType = requestNegotiator.mediaType(Object.keys(requestSettings.mediaTypes)); 26 | var selectedOption = requestSettings.mediaTypes[selectedMediaType]; 27 | 28 | if (!selectedOption) { 29 | reply(Boom.notAcceptable()); 30 | } 31 | else if (selectedOption === true) 32 | { 33 | reply.continue(); 34 | } 35 | else if (typeof selectedOption === 'function') 36 | { 37 | selectedOption(request, reply); 38 | } 39 | else if (selectedOption.method) 40 | { 41 | selectedOption.args.push(request.response.source); 42 | reply[selectedOption.method].apply(reply, selectedOption.args); 43 | } 44 | else 45 | { 46 | // Not really sure I just reply or just let it fail 47 | // Log something?? 48 | reply.continue(); 49 | } 50 | }); 51 | 52 | next(); 53 | }; 54 | 55 | exports.register.attributes = { 56 | pkg: require('../package.json') 57 | }; 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Hapi Content Negotiation 2 | =============== 3 | 4 | [![Build Status](https://travis-ci.org/felipeleusin/hapi-negotiator.svg?branch=master)](https://travis-ci.org/felipeleusin/hapi-negotiator) [![Dependency Status](https://david-dm.org/felipeleusin/hapi-negotiator.svg)](https://david-dm.org/felipeleusin/hapi-negotiator) 5 | 6 | [**Hapi**](https://github.com/spumko/hapi) Content Negotiation Plugin 7 | 8 | This project enhances content negotiation capabilities of Hapi. It's a wrapper around [**Negotiator**](https://github.com/jshttp/negotiator) module. 9 | 10 | ## Purpose 11 | 12 | When you have the same route but wants it to respond json or a html view depending on the Accept header. 13 | 14 | ## Options 15 | 16 | The plugin supports the following options: 17 | 18 | - `mediaTypes` - (optional) a object containing a combination of global media types where the key is the media type name and the value is one of the following: 19 | - `function (request, reply)` - the function is called receiving the request and the reply 20 | - `true` - perform the default `reply()`. Effective no-op. 21 | - `false` - throws Boom.notAccepted() error 22 | - `{ method, args }` - invoke the `method` on the reply method passing an args array. The args array is appended the default reply object. 23 | 24 | 25 | ## Example 26 | You have to configure the plugin for each route, so if you want a route to respond with a view named index when accept is text/html, notAccepted when image/jpeg and the regular json reply when application/vnd.project+json you should configure it as 27 | 28 | ```javascript 29 | server.route({ 30 | method: 'GET', 31 | path: '/', 32 | config: { 33 | plugins: { 34 | 'hapi-negotiator': { 35 | mediaTypes: { 36 | 'text/html': { method: 'view', args: ['index'] }, 37 | 'image/jpeg': false, 38 | 'application/vnd.project+json': true, 39 | } 40 | } 41 | } 42 | } 43 | }); 44 | ``` 45 | 46 | ## Todo 47 | 48 | - Improve documentation 49 | - Include negotiation for Accept Language. Maybe combine with one of the localization modules? 50 | 51 | ## Comments/Suggestions 52 | 53 | Feel free to open an issue. 54 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | var Lab = require('lab'); 2 | var Code = require('code'); 3 | var Hapi = require('hapi'); 4 | var Path = require('path'); 5 | var lab = exports.lab = Lab.script(); 6 | var expect = Code.expect; 7 | var before = lab.before; 8 | var after = lab.after; 9 | var describe = lab.describe; 10 | var it = lab.it; 11 | 12 | var sinon = require('sinon'); 13 | 14 | describe('AcceptHeader', function () { 15 | var data = { data: 'test', success: true }; 16 | 17 | var defaultHandler = function (request, reply) { 18 | reply(data); 19 | }; 20 | 21 | var customNegotiation = sinon.spy(function (request, reply) { 22 | reply('Ignore Everything!'); 23 | }); 24 | 25 | var server = new Hapi.Server({debug: false}); 26 | server.connection({ port: 80 }); 27 | 28 | before(function (done) { 29 | server.register([{ 30 | register: require('../'), 31 | options: {} 32 | }, 33 | { 34 | register: require('hapi-auth-basic') 35 | }], function (err) { 36 | expect(err).to.not.exist; 37 | 38 | server.auth.strategy('simple', 'basic', { 39 | validateFunc: function (user, pass, cb) { 40 | return cb(null, false); 41 | } 42 | }); 43 | 44 | server.route([ 45 | { 46 | method: 'GET', 47 | path: '/', 48 | config: { 49 | plugins: { 50 | 'hapi-negotiator': { 51 | mediaTypes: { 52 | 'text/html': { method: 'view', args: ['index'] } 53 | } 54 | } 55 | }, 56 | }, 57 | handler: defaultHandler 58 | }, 59 | { 60 | method: 'GET', 61 | path: '/json', 62 | handler: defaultHandler 63 | }, 64 | { 65 | method: 'GET', 66 | path: '/ignore', 67 | config: { 68 | plugins: { 69 | 'hapi-negotiator': false 70 | }, 71 | }, 72 | handler: defaultHandler 73 | }, 74 | { 75 | method: 'GET', 76 | path: '/custom', 77 | config: { 78 | plugins: { 79 | 'hapi-negotiator': { 80 | mediaTypes: { 81 | 'text/html': customNegotiation 82 | } 83 | } 84 | }, 85 | }, 86 | handler: defaultHandler 87 | }, 88 | { 89 | method: 'GET', 90 | path: '/fail', 91 | config: { 92 | plugins: { 93 | 'hapi-negotiator': { 94 | mediaTypes: { 95 | 'text/html': { wrong: 'param' } 96 | } 97 | } 98 | }, 99 | }, 100 | handler: defaultHandler 101 | }, 102 | { 103 | method: 'GET', 104 | path: '/auth', 105 | config: { 106 | auth: 'simple', 107 | plugins: { 108 | 'hapi-negotiator': { 109 | mediaTypes: { 110 | 'text/html': customNegotiation 111 | } 112 | } 113 | } 114 | }, 115 | handler: defaultHandler 116 | } 117 | ]); 118 | 119 | server.views({ 120 | engines: { 121 | html: require('handlebars') 122 | }, 123 | path: Path.join(__dirname, 'views') 124 | }); 125 | 126 | done(); 127 | }); 128 | }); 129 | 130 | after(function (done) { 131 | server = null; 132 | done(); 133 | }); 134 | 135 | it('returns correct type based on priority', function (done) { 136 | var request = { 137 | url: '/', headers: { 'Accept' : 'text/html,application/json;q=0.9,text/plain;q=0.1' } 138 | }; 139 | server.inject(request, function(res) { 140 | expect(res.headers['content-type']).to.include('text/html'); 141 | expect(res.statusCode).to.equal(200); 142 | done(); 143 | }); 144 | }); 145 | 146 | it('returns 406 Not Acceptable when type is not found', function (done) { 147 | var request = { 148 | url: '/json', headers: { 'Accept' : 'text/html' } 149 | }; 150 | server.inject(request, function(res) { 151 | expect(res.statusCode).to.equal(406); 152 | done(); 153 | }); 154 | }); 155 | 156 | it('returns same reply if mediaType is set to true', function (done) { 157 | var request = { 158 | url: '/json', headers: { 'Accept' : 'application/json' } 159 | }; 160 | server.inject(request, function(res) { 161 | expect(res.statusCode).to.equal(200); 162 | expect(res.result).to.equal(data); 163 | done(); 164 | }); 165 | }); 166 | 167 | it('skips validation when set to false', function (done) { 168 | var request = { 169 | url: '/ignore', headers: { 'Accept' : 'text/html' } 170 | }; 171 | server.inject(request, function(res) { 172 | expect(res.statusCode).to.equal(200); 173 | done(); 174 | }); 175 | }); 176 | 177 | it('returns executes the function if mediaType is one', function (done) { 178 | var request = { 179 | url: '/custom', headers: { 'Accept' : 'text/html' } 180 | }; 181 | server.inject(request, function(res) { 182 | expect(res.statusCode).to.equal(200); 183 | expect(customNegotiation.called).to.be.true; 184 | done(); 185 | }); 186 | }); 187 | 188 | it('fails silently on improper route configuration', function (done) { 189 | var request = { 190 | url: '/fail', headers: { 'Accept' : 'text/html' } 191 | }; 192 | server.inject(request, function(res) { 193 | expect(res.statusCode).to.equal(200); 194 | done(); 195 | }); 196 | }); 197 | it('does not ignore authentication errors when using custom negotiation', function (done) { 198 | var request = { 199 | url: '/auth', headers: { 'Accept' : 'text/html' } 200 | }; 201 | server.inject(request, function (res) { 202 | expect(res.statusCode).to.equal(401); 203 | done(); 204 | }); 205 | }); 206 | }); --------------------------------------------------------------------------------