├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── example ├── README.md └── index.js ├── index.js ├── package.json └── test └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore Mac DS_Store files 2 | .DS_Store 3 | **/.DS_Store 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | coverage.* 18 | 19 | # Dependency directory 20 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 21 | node_modules 22 | 23 | # Optional npm cache directory 24 | .npm 25 | 26 | # Optional REPL history 27 | .node_repl_history 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 8 5 | - 10 6 | 7 | env: 8 | - HAPI_VERSION="17" 9 | - HAPI_VERSION="18" 10 | 11 | install: 12 | - npm install --no-package-lock 13 | - npm install hapi@$HAPI_VERSION --no-package-lock 14 | 15 | sudo: false 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hapi-api-version 2 | 3 | __Lead Maintainer: [Tim Costa](https://github.com/timcosta)__ 4 | 5 | [![Build Status](https://travis-ci.org/p-meier/hapi-api-version.svg?branch=master)](https://travis-ci.org/p-meier/hapi-api-version) 6 | 7 | An API versioning plugin for [hapi](http://hapijs.com/) v17 onwards. 8 | 9 | ## Features / Goals 10 | 11 | - Supports versioning via `accept` and custom header (default `api-version`) as described on [troyhunt.com](http://www.troyhunt.com/2014/02/your-api-versioning-is-wrong-which-is.html) 12 | - 100% test coverage 13 | - Easy to use and flexible 14 | - Follows the [hapi coding conventions](http://hapijs.com/styleguide) 15 | - Allows to follow the DRY principle 16 | 17 | ## Requirements 18 | 19 | Runs with Node >=8 and hapi >=17 which is tested with Travis CI. 20 | 21 | ## Installation 22 | 23 | ``` 24 | npm install --save hapi-api-version 25 | ``` 26 | 27 | ## Usage 28 | 29 | Register it with the server: 30 | 31 | ```javascript 32 | 'use strict'; 33 | 34 | const Hapi = require('hapi'); 35 | 36 | const init = async function () { 37 | try { 38 | const server = new Hapi.server({ port: 3000 }); 39 | await server.register({ 40 | plugin: require('hapi-api-version'), 41 | options: { 42 | validVersions: [1, 2], 43 | defaultVersion: 2, 44 | vendorName: 'mysuperapi' 45 | } 46 | }) 47 | await server.start(); 48 | console.log('Server running at:', server.info.uri); 49 | } 50 | catch (err) { 51 | console.error(err); 52 | process.exit(1); 53 | } 54 | } 55 | init(); 56 | 57 | ``` 58 | 59 | Time to add some routes... 60 | 61 | There are typically two common use cases which this plugin is designed to address. 62 | 63 | #### Unversioned routes 64 | 65 | This is the type of routes which never change regardless of the api version. The route definition and the handler stay the same. 66 | 67 | ```javascript 68 | server.route({ 69 | method: 'GET', 70 | path:'/loginStatus', 71 | handler: function (request, h) { 72 | 73 | const loggedIn = ...; 74 | 75 | return { 76 | loggedIn: loggedIn 77 | }; 78 | } 79 | }); 80 | ``` 81 | 82 | #### Versioned routes 83 | 84 | This is the type of routes which actually change. 85 | 86 | ##### Handler only 87 | 88 | In simple cases where just the handler differs you could use this approach. 89 | 90 | ```javascript 91 | const usersVersion1 = [{ 92 | name: 'Peter Miller' 93 | }]; 94 | 95 | const usersVersion2 = [{ 96 | firtname: 'Peter', 97 | lastname: 'Miller' 98 | }]; 99 | 100 | server.route({ 101 | method: 'GET', 102 | path: '/users', 103 | handler: function (request, h) { 104 | 105 | const version = request.pre.apiVersion; 106 | 107 | if (version === 1) { 108 | return usersVersion1; 109 | } 110 | 111 | return usersVersion2; 112 | } 113 | }); 114 | ``` 115 | 116 | ##### Different route definitions per version 117 | 118 | Sometimes it is required to change not just the handler but also the route definition itself. 119 | 120 | ```javascript 121 | const usersVersion1 = [{ 122 | name: 'Peter Miller' 123 | }]; 124 | 125 | const usersVersion2 = [{ 126 | firtname: 'Peter', 127 | lastname: 'Miller' 128 | }]; 129 | 130 | server.route({ 131 | method: 'GET', 132 | path: '/v1/users', 133 | handler: function (request, h) { 134 | 135 | return usersVersion1; 136 | }, 137 | config: { 138 | response: { 139 | schema: Joi.array().items( 140 | Joi.object({ 141 | name: Joi.string().required() 142 | }) 143 | ) 144 | } 145 | } 146 | }); 147 | 148 | server.route({ 149 | method: 'GET', 150 | path: '/v2/users', 151 | handler: function (request, h) { 152 | 153 | return usersVersion2; 154 | }, 155 | config: { 156 | response: { 157 | schema: Joi.array().items( 158 | Joi.object({ 159 | firtname: Joi.string().required(), 160 | lastname: Joi.string().required() 161 | }) 162 | ) 163 | } 164 | } 165 | 166 | }); 167 | ``` 168 | 169 | Note the different schemas for response validation here. 170 | 171 | The user still sends a request to `/users` and the plugin rewrites it internally to either `/v1/users` or `/v2/users` based on the requested version. 172 | 173 | ## Example 174 | 175 | A complete working example with routes can be found in the `example` folder. 176 | 177 | ## Documentation 178 | 179 | **hapi-api-version** works internally with rewriting urls. The process is very simple: 180 | 181 | 1. Check if an `accept` header OR a custom header (default `api-version`) is present and extract the version 182 | 2. If a version was extracted check if it is valid, otherwise respond with a status code `400` 183 | 3. If no version was extracted (e.g. no headers sent) use the default version 184 | 4. Check if a versioned route (like `/v2/users`) exists -> if so rewrite the url from `/users` to `/v2/users`, otherwise do nothing 185 | 186 | ### Options 187 | 188 | The options for the plugin are validated on plugin registration. 189 | 190 | - `validVersions` (required) is an array of integer values. Specifies all valid api versions you support. Anything else will be considered invalid and the plugin responds with a status code `400`. 191 | - `defaultVersion` (required) is an integer that is included in `validVersions`. Defines which version to use if no headers are sent. 192 | - `vendorName` (required) is a string. Defines the vendor name used in the `accept` header. 193 | - `versionHeader` (optional) is a string. Defines the name of the custom header to use. Per default this is `api-version`. 194 | - `passiveMode` (optional) is a boolean. Allows to bypass when no headers are supplied. Useful when you serve other content like documentation and reduces overhead on processing those. 195 | - `basePath` (optional) is a string. In case we have a base path different from `/` (example: `/api/`). Per default this is `/`. 196 | 197 | ### Getting the requested API version in the handler 198 | 199 | You can get the API version requested by the user (or maybe the default version if nothing was requested) in the handler. It is stored in `request.pre.apiVersion`. 200 | 201 | ### Headers 202 | 203 | The headers must have a specific format to be correctly recognized and processed by the plugin. 204 | 205 | ##### Accept header 206 | 207 | ``` 208 | Accept: application/vnd.mysuperapi.v2+json 209 | ``` 210 | 211 | Here `mysuperapi` is what was specified in options as `vendorName`. If the vendor name does not match, the default version will be used instead. 212 | 213 | ##### Custom header 214 | 215 | ``` 216 | api-version: 2 217 | ``` 218 | 219 | Here `api-version` is the default name of the custom header. It can be specified in the options via `versionHeader`. 220 | 221 | ## Running the tests 222 | 223 | [lab](https://github.com/hapijs/lab) is used for all tests. Make sure you install it globally before running the tests: 224 | 225 | ``` 226 | npm install -g lab 227 | ``` 228 | 229 | Now just execute the tests: 230 | 231 | ``` 232 | npm test 233 | ``` 234 | 235 | To see the coverage report in html just execute: 236 | 237 | ``` 238 | npm run test-coverage 239 | ``` 240 | 241 | After this the html report can be found in `coverage/coverage.html`. 242 | 243 | ## License 244 | 245 | Apache-2.0 246 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Testing the example project with curl 2 | 3 | First start the server: 4 | 5 | ``` 6 | node example/index.js 7 | ``` 8 | 9 | ### Get the default version (which is version 2 in this case) 10 | 11 | ``` 12 | curl localhost:3000/version 13 | curl localhost:3000/users 14 | ``` 15 | 16 | ### Get version 1 via `accept` header 17 | 18 | ``` 19 | curl -H "accept: application/vnd.mysuperapi.v1+json" localhost:3000/version 20 | curl -H "accept: application/vnd.mysuperapi.v1+json" localhost:3000/users 21 | ``` 22 | 23 | ### Get version 1 via `api-version` header 24 | 25 | ``` 26 | curl -H "api-version: 1" localhost:3000/version 27 | curl -H "api-version: 1" localhost:3000/users 28 | ``` 29 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hapi = require('@hapi/hapi'); 4 | const Joi = require('@hapi/joi'); 5 | 6 | const init = async function () { 7 | 8 | try { 9 | const server = new Hapi.server({ port: 3000 }); 10 | await server.register({ 11 | plugin: require('hapi-api-version'), 12 | options: { 13 | validVersions: [1, 2], 14 | defaultVersion: 2, 15 | vendorName: 'mysuperapi' 16 | } 17 | }); 18 | // Add a route - handler and route definition is the same for all versions 19 | server.route({ 20 | method: 'GET', 21 | path: '/version', 22 | handler: function (request, h) { 23 | 24 | // Return the api-version which was requested 25 | return { 26 | version: request.pre.apiVersion 27 | }; 28 | } 29 | }); 30 | 31 | const usersVersion1 = [{ 32 | name: 'Peter Miller' 33 | }]; 34 | 35 | const usersVersion2 = [{ 36 | firtname: 'Peter', 37 | lastname: 'Miller' 38 | }]; 39 | 40 | // Add a versioned route - which is actually two routes with prefix '/v1' and '/v2'. Not only the 41 | // handlers are different, but also the route defintion itself (like here with response validation). 42 | server.route({ 43 | method: 'GET', 44 | path: '/v1/users', 45 | handler: function (request, h) { 46 | 47 | return usersVersion1; 48 | }, 49 | config: { 50 | response: { 51 | schema: Joi.array().items( 52 | Joi.object({ 53 | name: Joi.string().required() 54 | }) 55 | ) 56 | } 57 | } 58 | }); 59 | 60 | server.route({ 61 | method: 'GET', 62 | path: '/v2/users', 63 | handler: function (request, h) { 64 | 65 | return usersVersion2; 66 | }, 67 | config: { 68 | response: { 69 | schema: Joi.array().items( 70 | Joi.object({ 71 | firtname: Joi.string().required(), 72 | lastname: Joi.string().required() 73 | }) 74 | ) 75 | } 76 | } 77 | 78 | }); 79 | 80 | // Add a versioned route - This is a simple version of the '/users' route where just the handlers 81 | // differ and even those just a little. It maybe is the preferred option if just the formatting of 82 | // the response differs between versions. 83 | 84 | server.route({ 85 | method: 'GET', 86 | path: '/users/simple', 87 | handler: function (request, h) { 88 | 89 | const version = request.pre.apiVersion; 90 | 91 | if (version === 1) { 92 | return usersVersion1; 93 | } 94 | 95 | return usersVersion2; 96 | } 97 | }); 98 | // Start the server 99 | await server.start(); 100 | console.log('Server running at:', server.info.uri); 101 | } 102 | catch (err) { 103 | console.error(err); 104 | process.exit(1); 105 | } 106 | }; 107 | 108 | init(); 109 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Boom = require('@hapi/boom'); 4 | const Hoek = require('@hapi/hoek'); 5 | const Joi = require('@hapi/joi'); 6 | const MediaType = require('media-type'); 7 | 8 | const Package = require('./package'); 9 | 10 | const internals = { 11 | optionsSchema: Joi.object({ 12 | validVersions: Joi.array().items(Joi.number().integer()).min(1).required(), 13 | defaultVersion: Joi.any().valid(Joi.ref('validVersions')).required(), 14 | vendorName: Joi.string().trim().min(1).required(), 15 | versionHeader: Joi.string().trim().min(1).default('api-version'), 16 | passiveMode: Joi.boolean().default(false), 17 | basePath: Joi.string().trim().min(1).default('/') 18 | }) 19 | }; 20 | 21 | const _extractVersionFromCustomHeader = function (request, options) { 22 | 23 | const apiVersionHeader = request.headers[options.versionHeader]; 24 | 25 | if ((/^[0-9]+$/).test(apiVersionHeader)) { 26 | return parseInt(apiVersionHeader); 27 | } 28 | 29 | return null; 30 | }; 31 | 32 | const _extractVersionFromAcceptHeader = function (request, options) { 33 | 34 | const acceptHeader = request.headers.accept; 35 | const media = MediaType.fromString(acceptHeader); 36 | 37 | if (media.isValid() && (/^vnd.[a-z][a-z0-9.!#$&^_-]{0,126}\.v[0-9]+$/i).test(media.subtype)) { 38 | 39 | const vendorFacets = media.subtypeFacets.slice(1, media.subtypeFacets.length - 1); 40 | const vendorName = vendorFacets.join('.'); 41 | 42 | if (vendorName !== options.vendorName) { 43 | return null; 44 | } 45 | 46 | const version = media.subtypeFacets[media.subtypeFacets.length - 1].slice(1); 47 | 48 | return parseInt(version); 49 | } 50 | 51 | return null; 52 | }; 53 | 54 | const _extractVersionFromPath = function (request, options) { 55 | 56 | const versionedPathMatch = request.path.match(/^\/v(\d+)/); 57 | if (versionedPathMatch) { 58 | return parseInt(versionedPathMatch[1]); 59 | } 60 | 61 | return null; 62 | }; 63 | 64 | //Set a response header containing the version number 65 | const _addVersionToResponseHeader = function (request, requestedVersion, options) { 66 | 67 | const headerName = options.versionHeader; 68 | const response = request.response; 69 | 70 | if (request.response.isBoom) { 71 | response.output.headers[headerName] = requestedVersion; 72 | } 73 | else { 74 | response.header(headerName, requestedVersion); 75 | } 76 | 77 | return; 78 | }; 79 | 80 | const _attemptPathDecoding = function (request) { 81 | 82 | try { 83 | decodeURI(request.path); 84 | } 85 | catch (err) { 86 | throw Boom.badRequest('Invalid path'); 87 | } 88 | }; 89 | 90 | exports.name = Package.name; 91 | 92 | exports.version = Package.version; 93 | 94 | exports.register = (server, options) => { 95 | 96 | const validateOptions = internals.optionsSchema.validate(options); 97 | if (validateOptions.error) { 98 | throw new Error(validateOptions.error); 99 | } 100 | 101 | //Use the validated and maybe converted values from Joi 102 | options = validateOptions.value; 103 | 104 | server.ext('onRequest', (request, h) => { 105 | // Surface any URI decoding errors before calling server.match 106 | _attemptPathDecoding(request); 107 | 108 | //First check for custom header 109 | let requestedVersion = _extractVersionFromCustomHeader(request, options); 110 | 111 | //If no version check accept header 112 | if (typeof requestedVersion !== 'number') { 113 | requestedVersion = _extractVersionFromAcceptHeader(request, options); 114 | } 115 | 116 | //If no version check route path 117 | if (typeof requestedVersion !== 'number') { 118 | requestedVersion = _extractVersionFromPath(request, options); 119 | } 120 | 121 | //If passive mode skips the rest for non versioned routes 122 | if (options.passiveMode === true && typeof requestedVersion !== 'number') { 123 | return h.continue; 124 | } 125 | 126 | //If there was a version by now check if it is valid 127 | if (typeof requestedVersion === 'number' && !Hoek.contain(options.validVersions, requestedVersion)) { 128 | return Boom.badRequest('Invalid api-version! Valid values: ' + options.validVersions.join()); 129 | } 130 | 131 | //If there was no version by now use the default version 132 | if (typeof requestedVersion !== 'number') { 133 | requestedVersion = options.defaultVersion; 134 | } 135 | 136 | const versionedPath = options.basePath + 'v' + requestedVersion + request.path.slice(options.basePath.length - 1); 137 | 138 | let method = request.method; 139 | if (request.method === 'options') { 140 | method = request.headers['access-control-request-method']; 141 | if (!method) { 142 | throw Boom.badRequest('The Access-Control-Request-Method header must be set for CORS requests.'); 143 | } 144 | } 145 | 146 | 147 | const route = server.match(method, versionedPath); 148 | 149 | if (route && route.path.indexOf(options.basePath + 'v' + requestedVersion + '/') === 0) { 150 | request.setUrl(options.basePath + 'v' + requestedVersion + request.path.slice(options.basePath.length - 1) + (request.url.search || '')); //required to preserve query parameters 151 | } 152 | 153 | //Set version for usage in handler 154 | request.pre.apiVersion = requestedVersion; 155 | 156 | return h.continue; 157 | }); 158 | 159 | server.ext('onPreResponse', (request, h) => { 160 | 161 | _addVersionToResponseHeader(request, request.pre.apiVersion, options); 162 | return h.continue; 163 | }); 164 | }; 165 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-api-version", 3 | "version": "2.3.1", 4 | "description": "An API versioning plugin for hapi.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "lab -a @hapi/code -t 100 -L -v --lint-fix", 8 | "test-coverage": "lab -c -a @hapi/code -r html -o coverage/coverage.html" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/p-meier/hapi-api-version.git" 13 | }, 14 | "keywords": [ 15 | "hapi", 16 | "hapijs", 17 | "api", 18 | "versioning" 19 | ], 20 | "author": "Patrick Meier", 21 | "license": "Apache-2.0", 22 | "engines": { 23 | "node": ">=8.12.0" 24 | }, 25 | "peerDependencies": { 26 | "@hapi/hapi": ">=17.x.x" 27 | }, 28 | "devDependencies": { 29 | "@hapi/hapi": "18.x.x", 30 | "@hapi/code": "5.x.x", 31 | "@hapi/lab": "19.x.x" 32 | }, 33 | "dependencies": { 34 | "@hapi/boom": "^7.4.2", 35 | "@hapi/hoek": "^8.0.2", 36 | "@hapi/joi": "^15.1.0", 37 | "media-type": "^0.3.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hapi = require('@hapi/hapi'); 4 | const Code = require('@hapi/code'); 5 | const Lab = require('@hapi/lab'); 6 | const Boom = require('@hapi/boom'); 7 | 8 | const lab = exports.lab = Lab.script(); 9 | 10 | const expect = Code.expect; 11 | 12 | const describe = lab.describe; 13 | const it = lab.it; 14 | const beforeEach = lab.beforeEach; 15 | let server; 16 | 17 | beforeEach(async () => { 18 | 19 | try { 20 | server = new Hapi.Server(); 21 | await server.start(); 22 | } 23 | catch (err) { 24 | console.error(err); 25 | process.exit(1); 26 | } 27 | }); 28 | 29 | describe('Plugin registration', () => { 30 | 31 | it('should throw error if no options are specified', (done) => { 32 | 33 | try { 34 | server.register({ 35 | plugin: require('../'), 36 | options: {} 37 | }).then(() => { 38 | }).catch((e) => expect(e).to.be.an.instanceof(Error)); 39 | } 40 | catch (e) { 41 | done(); 42 | } 43 | }); 44 | 45 | 46 | it('should fail if no options are specified', async () => { 47 | 48 | await expect(server.register({ 49 | register: require('../'), 50 | options: {} 51 | })).to.reject(Error, /Invalid plugin options/); 52 | }); 53 | 54 | it('should fail if no validVersions are specified', async () => { 55 | 56 | await expect(server.register({ 57 | register: require('../'), 58 | options: { 59 | defaultVersion: 1, 60 | vendorName: 'mysuperapi' 61 | } 62 | })).to.reject(Error, /Invalid plugin options/); 63 | }); 64 | 65 | it('should fail if validVersions is not an array', async () => { 66 | 67 | await expect(server.register({ 68 | register: require('../'), 69 | options: { 70 | validVersions: 1, 71 | defaultVersion: 1, 72 | vendorName: 'mysuperapi' 73 | } 74 | })).to.reject(Error, /Invalid plugin options/); 75 | 76 | }); 77 | 78 | it('should fail if validVersions is an empty array', async () => { 79 | 80 | await expect(server.register({ 81 | register: require('../'), 82 | options: { 83 | validVersions: [], 84 | defaultVersion: 1, 85 | vendorName: 'mysuperapi' 86 | } 87 | })).to.reject(Error, /Invalid plugin options/); 88 | }); 89 | 90 | it('should fail if validVersions contains non integer values', async () => { 91 | 92 | await expect(server.register({ 93 | register: require('../'), 94 | options: { 95 | validVersions: ['1', 2.2], 96 | defaultVersion: 1, 97 | vendorName: 'mysuperapi' 98 | } 99 | })).to.reject(Error, /Invalid plugin options/); 100 | }); 101 | 102 | it('should fail if no defaultVersion is specified', async () => { 103 | 104 | await expect(server.register({ 105 | register: require('../'), 106 | options: { 107 | validVersions: [1, 2], 108 | vendorName: 'mysuperapi' 109 | } 110 | })).to.reject(Error, /Invalid plugin options/); 111 | }); 112 | 113 | it('should fail if defaultVersion is not an integer', async () => { 114 | 115 | await expect(server.register({ 116 | register: require('../'), 117 | options: { 118 | validVersions: [1, 2], 119 | defaultVersion: '1', 120 | vendorName: 'mysuperapi' 121 | } 122 | })).to.reject(Error, /Invalid plugin options/); 123 | }); 124 | 125 | it('should fail if defaultVersion is not an element of validVersions', async () => { 126 | 127 | await expect(server.register({ 128 | register: require('../'), 129 | options: { 130 | validVersions: [1, 2], 131 | defaultVersion: 3, 132 | vendorName: 'mysuperapi' 133 | } 134 | })).to.reject(Error, /Invalid plugin options/); 135 | }); 136 | 137 | it('should fail if defaultVersion is not an element of validVersions', async () => { 138 | 139 | await expect(server.register({ 140 | register: require('../'), 141 | options: { 142 | validVersions: [1, 2], 143 | defaultVersion: 3, 144 | vendorName: 'mysuperapi' 145 | } 146 | })).to.reject(Error, /Invalid plugin options/); 147 | }); 148 | 149 | it('should fail if no vendorName is specified', async () => { 150 | 151 | await expect(server.register({ 152 | register: require('../'), 153 | options: { 154 | validVersions: [1, 2], 155 | defaultVersion: 1 156 | } 157 | })).to.reject(Error, /Invalid plugin options/); 158 | }); 159 | 160 | it('should fail if vendorName is not a string', async () => { 161 | 162 | await expect(server.register({ 163 | register: require('../'), 164 | options: { 165 | validVersions: [1, 2], 166 | defaultVersion: 1, 167 | vendorName: 33 168 | } 169 | })).to.reject(Error, /Invalid plugin options/); 170 | }); 171 | 172 | it('should fail if passiveMode is not a boolean', async () => { 173 | 174 | await expect(server.register({ 175 | register: require('../'), 176 | options: { 177 | validVersions: [1, 2], 178 | defaultVersion: 1, 179 | vendorName: 33, 180 | passiveMode: [] 181 | } 182 | })).to.reject(Error, /Invalid plugin options/); 183 | }); 184 | 185 | it('should succeed if all required options are provided correctly', async () => { 186 | 187 | await expect(server.register({ 188 | register: require('../'), 189 | options: { 190 | validVersions: [1, 2], 191 | defaultVersion: 1, 192 | vendorName: 'mysuperapi' 193 | } 194 | })).to.reject(Error, /Invalid plugin options/); 195 | }); 196 | 197 | it('should succeed if all options are provided correctly', async () => { 198 | 199 | await expect(server.register({ 200 | register: require('../'), 201 | options: { 202 | validVersions: [1, 2], 203 | defaultVersion: 1, 204 | vendorName: 'mysuperapi', 205 | versionHeader: 'myversion', 206 | passiveMode: true 207 | } 208 | })).to.reject(Error, /Invalid plugin options/); 209 | }); 210 | }); 211 | 212 | describe('Versioning', () => { 213 | 214 | beforeEach(async () => { 215 | 216 | await server.register({ 217 | plugin: require('../'), 218 | options: { 219 | validVersions: [0, 1, 2], 220 | defaultVersion: 1, 221 | vendorName: 'mysuperapi' 222 | } 223 | }); 224 | }); 225 | 226 | describe(' -> basic versioning', () => { 227 | 228 | beforeEach(() => { 229 | 230 | server.route({ 231 | method: 'GET', 232 | path: '/unversioned', 233 | handler: function (request, h) { 234 | 235 | const response = { 236 | version: request.pre.apiVersion, 237 | data: 'unversioned' 238 | }; 239 | 240 | return response; 241 | } 242 | }); 243 | 244 | server.route({ 245 | method: 'GET', 246 | path: '/v0/versioned', 247 | handler: function (request, h) { 248 | 249 | const response = { 250 | version: 0, 251 | data: 'versioned' 252 | }; 253 | 254 | return response; 255 | } 256 | }); 257 | 258 | server.route({ 259 | method: 'GET', 260 | path: '/v1/versioned', 261 | handler: function (request, h) { 262 | 263 | const response = { 264 | version: request.pre.apiVersion, 265 | data: 'versioned' 266 | }; 267 | 268 | return response; 269 | } 270 | }); 271 | 272 | server.route({ 273 | method: 'GET', 274 | path: '/v2/versioned', 275 | handler: function (request, h) { 276 | 277 | const response = { 278 | version: request.pre.apiVersion, 279 | data: 'versioned' 280 | }; 281 | 282 | return response; 283 | } 284 | }); 285 | }); 286 | 287 | it('returns version 2 if custom header is valid', async () => { 288 | 289 | const response = await server.inject({ 290 | method: 'GET', 291 | url: '/versioned', 292 | headers: { 293 | 'api-version': '2' 294 | } 295 | }); 296 | expect(response.statusCode).to.equal(200); 297 | expect(response.result.version).to.equal(2); 298 | expect(response.result.data).to.equal('versioned'); 299 | }); 300 | 301 | it('returns version 0 if custom header is valid', async () => { 302 | 303 | const response = await server.inject({ 304 | method: 'GET', 305 | url: '/versioned', 306 | headers: { 307 | 'api-version': '0' 308 | } 309 | }); 310 | expect(response.statusCode).to.equal(200); 311 | expect(response.result.version).to.equal(0); 312 | expect(response.result.data).to.equal('versioned'); 313 | }); 314 | 315 | it('returns version 2 if accept header is valid', async () => { 316 | 317 | const response = await server.inject({ 318 | method: 'GET', 319 | url: '/versioned', 320 | headers: { 321 | 'Accept': 'application/vnd.mysuperapi.v2+json' 322 | } 323 | }); 324 | expect(response.statusCode).to.equal(200); 325 | expect(response.result.version).to.equal(2); 326 | expect(response.result.data).to.equal('versioned'); 327 | }); 328 | 329 | it('sets pre.apiVersion properly', async () => { 330 | 331 | const response = await server.inject({ 332 | method: 'GET', 333 | url: '/v2/versioned' 334 | }); 335 | expect(response.statusCode).to.equal(200); 336 | expect(response.result.version).to.equal(2); 337 | expect(response.result.data).to.equal('versioned'); 338 | }); 339 | 340 | it('returns default version if no header is sent', async () => { 341 | 342 | const response = await server.inject({ 343 | method: 'GET', 344 | url: '/versioned' 345 | }); 346 | expect(response.statusCode).to.equal(200); 347 | expect(response.result.version).to.equal(1); 348 | expect(response.result.data).to.equal('versioned'); 349 | }); 350 | 351 | it('returns default version response header if no request header is sent', async () => { 352 | 353 | const response = await server.inject({ 354 | method: 'GET', 355 | url: '/versioned' 356 | }); 357 | expect(response.statusCode).to.equal(200); 358 | expect(response.result.version).to.equal(1); 359 | expect(response.result.data).to.equal('versioned'); 360 | expect(response.headers['api-version']).to.equal(1); 361 | }); 362 | 363 | it('returns version response header if response header is present', async () => { 364 | 365 | const response = await server.inject({ 366 | method: 'GET', 367 | url: '/versioned', 368 | headers: { 369 | 'api-version': '2' 370 | } 371 | }); 372 | expect(response.statusCode).to.equal(200); 373 | expect(response.result.version).to.equal(2); 374 | expect(response.result.data).to.equal('versioned'); 375 | expect(response.headers['api-version']).to.equal(2); 376 | }); 377 | 378 | it('returns default version if custom header is invalid', async () => { 379 | 380 | const response = await server.inject({ 381 | method: 'GET', 382 | url: '/versioned', 383 | headers: { 384 | 'api-version': 'asdf' 385 | } 386 | }); 387 | expect(response.statusCode).to.equal(200); 388 | expect(response.result.version).to.equal(1); 389 | expect(response.result.data).to.equal('versioned'); 390 | }); 391 | 392 | it('returns default version if custom header is null', async () => { 393 | 394 | const response = await server.inject({ 395 | method: 'GET', 396 | url: '/versioned', 397 | headers: { 398 | 'api-version': null 399 | } 400 | }); 401 | expect(response.statusCode).to.equal(200); 402 | expect(response.result.version).to.equal(1); 403 | expect(response.result.data).to.equal('versioned'); 404 | }); 405 | 406 | it('returns default version if accept header is invalid', async () => { 407 | 408 | const response = await server.inject({ 409 | method: 'GET', 410 | url: '/versioned', 411 | headers: { 412 | 'Accept': 'application/someinvalidapi.vasf+json' 413 | } 414 | }); 415 | expect(response.statusCode).to.equal(200); 416 | expect(response.result.version).to.equal(1); 417 | expect(response.result.data).to.equal('versioned'); 418 | }); 419 | 420 | it('returns default version if accept header has an invalid vendor-name', async () => { 421 | 422 | const response = await server.inject({ 423 | method: 'GET', 424 | url: '/versioned', 425 | headers: { 426 | 'Accept': 'application/vnd.someinvalidapi.v2+json' 427 | } 428 | }); 429 | expect(response.statusCode).to.equal(200); 430 | expect(response.result.version).to.equal(1); 431 | expect(response.result.data).to.equal('versioned'); 432 | }); 433 | 434 | it('returns a 400 if invalid api version is requested (not included in validVersions)', async () => { 435 | 436 | const response = await server.inject({ 437 | method: 'GET', 438 | url: '/versioned', 439 | headers: { 440 | 'api-version': '3' 441 | } 442 | }); 443 | expect(response.statusCode).to.equal(400); 444 | }); 445 | 446 | it('returns the same response for an unversioned route no matter what version is requested - version 1', async () => { 447 | 448 | const response = await server.inject({ 449 | method: 'GET', 450 | url: '/unversioned', 451 | headers: { 452 | 'api-version': '1' 453 | } 454 | }); 455 | expect(response.statusCode).to.equal(200); 456 | expect(response.result.version).to.equal(1); 457 | expect(response.result.data).to.equal('unversioned'); 458 | }); 459 | 460 | it('returns the same response for an unversioned route no matter what version is requested - version 2', async () => { 461 | 462 | const response = await server.inject({ 463 | method: 'GET', 464 | url: '/unversioned', 465 | headers: { 466 | 'api-version': '2' 467 | } 468 | }); 469 | expect(response.statusCode).to.equal(200); 470 | expect(response.result.version).to.equal(2); 471 | expect(response.result.data).to.equal('unversioned'); 472 | }); 473 | 474 | it('returns the same response for an unversioned route no matter what version is requested - no version (=default)', async () => { 475 | 476 | const response = await server.inject({ 477 | method: 'GET', 478 | url: '/unversioned' 479 | }); 480 | expect(response.statusCode).to.equal(200); 481 | expect(response.result.version).to.equal(1); 482 | expect(response.result.data).to.equal('unversioned'); 483 | }); 484 | }); 485 | 486 | it('preserves query parameters after url-rewrite', async () => { 487 | 488 | server.route({ 489 | method: 'GET', 490 | path: '/v1/versionedWithParams', 491 | handler: function (request, h) { 492 | 493 | const response = { 494 | params: request.query 495 | }; 496 | 497 | return response; 498 | } 499 | }); 500 | 501 | const response = await server.inject({ 502 | method: 'GET', 503 | url: '/versionedWithParams?test=1' 504 | }); 505 | expect(response.statusCode).to.equal(200); 506 | expect(response.result.params).to.equal({ 507 | test: '1' 508 | }); 509 | }); 510 | 511 | it('should work with CORS enabled', async () => { 512 | 513 | server.route({ 514 | method: 'GET', 515 | path: '/corstest', 516 | handler: function (request, h) { 517 | 518 | return 'Testing CORS!'; 519 | }, 520 | config: { 521 | cors: { 522 | origin: ['*'], 523 | headers: ['Accept', 'Authorization'] 524 | } 525 | } 526 | }); 527 | 528 | const response = await server.inject({ 529 | method: 'OPTIONS', 530 | url: '/corstest', 531 | headers: { 532 | 'Origin': 'http://www.example.com', 533 | 'Access-Control-Request-Method': 'GET', 534 | 'Access-Control-Request-Headers': 'accept, authorization' 535 | } 536 | }); 537 | expect(response.statusCode).to.equal(200); 538 | expect(response.headers).to.include({ 539 | 'access-control-allow-origin': 'http://www.example.com' 540 | }); 541 | 542 | expect(response.headers).to.include('access-control-allow-methods'); 543 | expect(response.headers['access-control-allow-methods'].split(',')).to.include('GET'); 544 | 545 | expect(response.headers).to.include('access-control-allow-headers'); 546 | expect(response.headers['access-control-allow-headers'].split(',')).to.include(['Accept', 'Authorization']); 547 | }); 548 | 549 | it('should 400 when an OPTIONS request has a malformed access-control-request-method header', async () => { 550 | 551 | server.route({ 552 | method: 'GET', 553 | path: '/corstest', 554 | handler: function (request, h) { 555 | 556 | return 'Testing CORS!'; 557 | }, 558 | config: { 559 | cors: { 560 | origin: ['*'], 561 | headers: ['Accept', 'Authorization'] 562 | } 563 | } 564 | }); 565 | 566 | const response = await server.inject({ 567 | method: 'OPTIONS', 568 | url: '/corstest', 569 | headers: { 570 | 'Origin': 'http://www.example.com', 571 | 'Access-Control-Request-Method': '' 572 | } 573 | }); 574 | expect(response.statusCode).to.equal(400); 575 | }); 576 | 577 | it('handles invalid request methods properly', async () => { 578 | 579 | const response = await server.inject({ 580 | method: 'FAKE', 581 | url: '/route' 582 | }); 583 | expect(response.statusCode).to.equal(404); 584 | }); 585 | }); 586 | 587 | describe(' -> vendor name ', () => { 588 | 589 | it('should accept non-alphanumeric characters', async () => { 590 | 591 | await server.register({ 592 | plugin: require('../'), 593 | options: { 594 | validVersions: [0, 1, 2], 595 | defaultVersion: 1, 596 | vendorName: 'my.super-Api!' 597 | } 598 | }); 599 | 600 | server.route({ 601 | method: 'GET', 602 | path: '/v2/versioned', 603 | handler: function (request, h) { 604 | 605 | const response = { 606 | version: 2, 607 | data: 'versioned' 608 | }; 609 | 610 | return response; 611 | } 612 | }); 613 | 614 | const response = await server.inject({ 615 | method: 'GET', 616 | url: '/versioned', 617 | headers: { 618 | 'Accept': 'application/vnd.my.super-Api!.v2+json' 619 | } 620 | }); 621 | expect(response.statusCode).to.equal(200); 622 | expect(response.result.version).to.equal(2); 623 | expect(response.result.data).to.equal('versioned'); 624 | }); 625 | 626 | it('should accept several period characters', async () => { 627 | 628 | await server.register({ 629 | plugin: require('../'), 630 | options: { 631 | validVersions: [0, 1, 10], 632 | defaultVersion: 1, 633 | vendorName: 'company.departmanet.project.api' 634 | } 635 | }); 636 | 637 | server.route({ 638 | method: 'GET', 639 | path: '/v10/versioned', 640 | handler: function (request, h) { 641 | 642 | const response = { 643 | version: 10, 644 | data: 'versioned' 645 | }; 646 | 647 | return response; 648 | } 649 | }); 650 | 651 | const response = await server.inject({ 652 | method: 'GET', 653 | url: '/versioned', 654 | headers: { 655 | 'Accept': 'application/vnd.company.departmanet.project.api.v10+json' 656 | } 657 | }); 658 | expect(response.statusCode).to.equal(200); 659 | expect(response.result.version).to.equal(10); 660 | expect(response.result.data).to.equal('versioned'); 661 | }); 662 | }); 663 | 664 | describe(' -> path parameters', () => { 665 | 666 | beforeEach(async () => { 667 | 668 | await server.register({ 669 | plugin: require('../'), 670 | options: { 671 | validVersions: [0, 1, 2], 672 | defaultVersion: 1, 673 | vendorName: 'mysuperapi' 674 | } 675 | }); 676 | server.route({ 677 | method: 'GET', 678 | path: '/unversioned/{catchAll*}', 679 | handler: function (request, h) { 680 | 681 | const response = { 682 | version: request.pre.apiVersion, 683 | data: 'unversionedCatchAll' 684 | }; 685 | 686 | return response; 687 | } 688 | }); 689 | 690 | server.route({ 691 | method: 'GET', 692 | path: '/v2/versioned/{catchAll*3}', 693 | handler: function (request, h) { 694 | 695 | const response = { 696 | version: request.pre.apiVersion, 697 | data: 'versionedCatchAll' 698 | }; 699 | 700 | return response; 701 | } 702 | }); 703 | 704 | 705 | server.route({ 706 | method: 'GET', 707 | path: '/unversioned/withPathParam/{unversionedPathParam}', 708 | handler: function (request, h) { 709 | 710 | const response = { 711 | version: request.pre.apiVersion, 712 | data: request.params.unversionedPathParam 713 | }; 714 | 715 | return response; 716 | } 717 | }); 718 | 719 | server.route({ 720 | method: 'GET', 721 | path: '/v1/versioned/withPathParam/{versionedPathParam}', 722 | handler: function (request, h) { 723 | 724 | const response = { 725 | version: request.pre.apiVersion, 726 | data: request.params.versionedPathParam 727 | }; 728 | 729 | return response; 730 | } 731 | }); 732 | 733 | server.route({ 734 | method: 'GET', 735 | path: '/v2/versioned/multiSegment/{segment*2}', 736 | handler: function (request, h) { 737 | 738 | const response = { 739 | version: request.pre.apiVersion, 740 | data: request.params.segment 741 | }; 742 | 743 | return response; 744 | } 745 | }); 746 | 747 | server.route({ 748 | method: 'GET', 749 | path: '/v2/versioned/optionalPathParam/{optional?}', 750 | handler: function (request, h) { 751 | 752 | const response = { 753 | version: request.pre.apiVersion, 754 | data: request.params.optional 755 | }; 756 | 757 | return response; 758 | } 759 | }); 760 | }); 761 | 762 | it('resolves unversioned catch all routes', async () => { 763 | 764 | const response = await server.inject({ 765 | method: 'GET', 766 | url: '/unversioned/catch/all/route' 767 | }); 768 | expect(response.statusCode).to.equal(200); 769 | expect(response.result.version).to.equal(1); 770 | expect(response.result.data).to.equal('unversionedCatchAll'); 771 | }); 772 | 773 | it('resolves versioned catch all routes', async () => { 774 | 775 | const apiVersion = 2; 776 | 777 | const response = await server.inject({ 778 | method: 'GET', 779 | url: '/versioned/catch/all/route', 780 | headers: { 781 | 'api-version': apiVersion 782 | } 783 | }); 784 | expect(response.statusCode).to.equal(200); 785 | expect(response.result.version).to.equal(apiVersion); 786 | expect(response.result.data).to.equal('versionedCatchAll'); 787 | }); 788 | 789 | it('resolves unversioned routes with path parameters', async () => { 790 | 791 | const pathParam = '123456789'; 792 | 793 | const response = await server.inject({ 794 | method: 'GET', 795 | url: '/unversioned/withPathParam/' + pathParam 796 | }); 797 | expect(response.statusCode).to.equal(200); 798 | expect(response.result.version).to.equal(1); 799 | expect(response.result.data).to.equal(pathParam); 800 | }); 801 | 802 | it('resolves versioned routes with path parameters', async () => { 803 | 804 | const pathParam = '123456789'; 805 | const apiVersion = 1; 806 | 807 | const response = await server.inject({ 808 | method: 'GET', 809 | url: '/versioned/withPathParam/' + pathParam, 810 | headers: { 811 | 'api-version': apiVersion 812 | } 813 | }); 814 | expect(response.statusCode).to.equal(200); 815 | expect(response.result.version).to.equal(apiVersion); 816 | expect(response.result.data).to.equal(pathParam); 817 | }); 818 | 819 | it('resolves multi segment path parameters', async () => { 820 | 821 | const apiVersion = 2; 822 | const pathParam = 'multi/segment'; 823 | 824 | const response = await server.inject({ 825 | method: 'GET', 826 | url: '/versioned/multiSegment/' + pathParam, 827 | headers: { 828 | 'api-version': apiVersion 829 | } 830 | }); 831 | expect(response.statusCode).to.equal(200); 832 | expect(response.result.version).to.equal(apiVersion); 833 | expect(response.result.data).to.equal(pathParam); 834 | }); 835 | 836 | it('resolves optional path parameters - without optional value', async () => { 837 | 838 | const apiVersion = 2; 839 | const pathParam = (parseInt(server.version.match(/^(\d+)/)[1]) >= 17 ? '' : undefined); 840 | 841 | const response = await server.inject({ 842 | method: 'GET', 843 | url: '/versioned/optionalPathParam/', 844 | headers: { 845 | 'api-version': apiVersion 846 | } 847 | }); 848 | expect(response.statusCode).to.equal(200); 849 | expect(response.result.version).to.equal(apiVersion); 850 | expect(response.result.data).to.equal(pathParam); 851 | }); 852 | 853 | it('resolves optional path parameters - with optional value', async () => { 854 | 855 | const apiVersion = 2; 856 | const pathParam = 'test'; 857 | 858 | const response = await server.inject({ 859 | method: 'GET', 860 | url: '/versioned/optionalPathParam/' + pathParam, 861 | headers: { 862 | 'api-version': apiVersion 863 | } 864 | }); 865 | expect(response.statusCode).to.equal(200); 866 | expect(response.result.version).to.equal(apiVersion); 867 | expect(response.result.data).to.equal(pathParam); 868 | }); 869 | }); 870 | 871 | describe('Versioning with passive mode', () => { 872 | 873 | beforeEach(async () => { 874 | 875 | await server.register({ 876 | plugin: require('../'), 877 | options: { 878 | validVersions: [1, 2], 879 | defaultVersion: 1, 880 | vendorName: 'mysuperapi', 881 | passiveMode: true 882 | } 883 | }); 884 | 885 | server.route({ 886 | method: 'GET', 887 | path: '/unversioned', 888 | handler: function (request, h) { 889 | 890 | const response = { 891 | data: 'unversioned' 892 | }; 893 | 894 | return response; 895 | } 896 | }); 897 | 898 | server.route({ 899 | method: 'GET', 900 | path: '/v1/versioned', 901 | handler: function (request, h) { 902 | 903 | const response = { 904 | data: 'versioned', 905 | version: request.pre.apiVersion 906 | }; 907 | 908 | return response; 909 | } 910 | }); 911 | }); 912 | 913 | it('returns no version if no header is supplied', async () => { 914 | 915 | const response = await server.inject({ 916 | method: 'GET', 917 | url: '/unversioned' 918 | }); 919 | expect(response.statusCode).to.equal(200); 920 | expect(response.result.version).to.equal(undefined); 921 | expect(response.result.data).to.equal('unversioned'); 922 | }); 923 | 924 | it('returns version if header is supplied with passive mode on', async () => { 925 | 926 | const response = await server.inject({ 927 | method: 'GET', 928 | url: '/versioned', 929 | headers: { 930 | 'Accept': 'application/vnd.mysuperapi.v1+json' 931 | } 932 | }); 933 | expect(response.statusCode).to.equal(200); 934 | expect(response.result.version).to.equal(1); 935 | expect(response.result.data).to.equal('versioned'); 936 | }); 937 | }); 938 | 939 | describe('Malformed URLs with a catchall route', () => { 940 | 941 | beforeEach(async () => { 942 | 943 | await server.register({ 944 | plugin: require('../'), 945 | options: { 946 | validVersions: [0, 1, 2], 947 | defaultVersion: 1, 948 | vendorName: 'mysuperapi' 949 | } 950 | }); 951 | server.route({ 952 | method: 'GET', 953 | path: '/{path*}', 954 | handler: function (request, h) { 955 | 956 | throw Boom.notFound('Not found'); 957 | } 958 | }); 959 | }); 960 | 961 | it('returns 400 for an invalid path', async () => { 962 | 963 | const response = await server.inject({ 964 | method: 'GET', 965 | url: '/%C0%AE%C0%AE' 966 | }); 967 | expect(response.statusCode).to.equal(400); 968 | }); 969 | 970 | it('returns 404 for a missing patch', async () => { 971 | 972 | const response = await server.inject({ 973 | method: 'GET', 974 | url: '/validencoding' 975 | }); 976 | expect(response.statusCode).to.equal(404); 977 | }); 978 | }); 979 | 980 | 981 | --------------------------------------------------------------------------------