├── .gitignore ├── .rspec ├── .travis.yml ├── Changes.md ├── Gemfile ├── Gemfile.rails50 ├── Gemfile.rails51 ├── License.txt ├── README.md ├── Rakefile ├── api-versions.gemspec ├── lib ├── api-versions.rb ├── api-versions │ ├── dsl.rb │ ├── middleware.rb │ ├── railtie.rb │ ├── version.rb │ └── version_check.rb └── generators │ └── api_versions │ ├── bump_generator.rb │ └── templates │ └── controller.rb └── spec ├── api_versions_spec.rb ├── dummy ├── app │ └── controllers │ │ ├── api │ │ ├── v1 │ │ │ └── bar_controller.rb │ │ ├── v2 │ │ │ ├── bar_controller.rb │ │ │ ├── foo_controller.rb │ │ │ └── users_controller.rb │ │ └── v3 │ │ │ ├── bar_controller.rb │ │ │ ├── foo_controller.rb │ │ │ └── nests │ │ │ └── nested_controller.rb │ │ ├── auth_api │ │ └── v1 │ │ │ └── quux_controller.rb │ │ ├── errors_controller.rb │ │ └── v1 │ │ └── qux_controller.rb ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── environment.rb │ ├── environments │ │ └── test.rb │ └── routes.rb └── log │ └── .gitkeep ├── generators └── bump_generator_spec.rb ├── middleware_spec.rb ├── routing_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | spec/dummy/log/* 6 | log/* 7 | tmp/* 8 | coverage/* 9 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | rvm: 4 | - 2.2.9 5 | - 2.3.7 6 | - 2.4.4 7 | - 2.5.1 8 | 9 | gemfile: 10 | - Gemfile 11 | - Gemfile.rails50 12 | - Gemfile.rails51 13 | 14 | notifications: 15 | email: 16 | recipients: 17 | - api-versions@erich.erichmenge.com 18 | - me@davidcel.is 19 | -------------------------------------------------------------------------------- /Changes.md: -------------------------------------------------------------------------------- 1 | ## 1.2.0 2 | 3 | * Pass `#api` options to Rails namespace [#1, David Celis] 4 | * Fix issue with middleware and nil accept header [#2, rposborne] 5 | 6 | ## 1.1.0 7 | 8 | * Use middleware to simplify the MIME type instead of doing it at the controller. 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in api-versions.gemspec 4 | gemspec 5 | 6 | gem 'rails', '~> 5.2' 7 | -------------------------------------------------------------------------------- /Gemfile.rails50: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in api-versions.gemspec 4 | gemspec 5 | 6 | gem 'rails', '~> 5.0' 7 | -------------------------------------------------------------------------------- /Gemfile.rails51: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in api-versions.gemspec 4 | gemspec 5 | 6 | gem 'rails', '~> 5.1' 7 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2012 Erich Menge 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # api-versions 2 | 3 | [![Build Status](https://travis-ci.org/EDMC/api-versions.png?branch=master)](https://travis-ci.org/EDMC/api-versions) 4 | [![Gem Version](https://badge.fury.io/rb/api-versions.png)](http://badge.fury.io/rb/api-versions) 5 | [![Coverage Status](https://coveralls.io/repos/erichmenge/api-versions/badge.png)](https://coveralls.io/r/erichmenge/api-versions) 6 | [![Code Climate](https://codeclimate.com/github/erichmenge/api-versions.png)](https://codeclimate.com/github/erichmenge/api-versions) 7 | 8 | ### Requirements 9 | * Rails 5.0+ 10 | * Ruby 2.2+ 11 | 12 | 13 | #### api-versions is a Gem to help you manage your Rails API endpoints. 14 | api-versions is very lightweight. It adds a generator and only one method to the Rails route mapper. 15 | 16 | ##### It helps you in three ways: 17 | 18 | * Provides a DSL for versioning your API in your routes file, favoring client headers vs changing the resource URLs. 19 | * Provides methods to cache and retrieve resources in your routes file to keep it from getting cluttered 20 | * Provides a generator to bump your API controllers to the next version, while inheriting the previous version. 21 | 22 | *See below for more details on each of these topics* 23 | 24 | #### Assumptions api-versions makes: 25 | * You want the client to use headers to specify the API version instead of changing the URL. (`Accept` header of `application/vnd.myvendor+json;version=1` for example) 26 | * You specify your API version in whole integers. v1, v2, v3, etc. 27 | If you need semantic versioning for an API you're likely making too many backwards incompatible changes. API versions should not change all that often. 28 | * Your API controllers will live under the `api/v{n}/` directory. For example `app/controllers/api/v1/authorizations_controller.rb`. 29 | 30 | ## Installation 31 | In your Gemfile: 32 | 33 | gem "api-versions", "~> 1.0" 34 | 35 | ## Versions are specified by header, not by URL 36 | A lot of APIs are versioned by changing the URL. `http://test.host/api/v1/some_resource/new` for example. 37 | But is some_resource different from version 1 to version 2? It is likely the same resource, it is simply the interface that is changing. 38 | api-versions prefers the URLs stay the same. `http://test.host/api/some_resource/new` need not ever change (so long as the resource exists). The client specifies how it wants to interface with 39 | this resource with the `Accept` header. So if the client wants version 2 of the API, the `Accept` header might look like this: `application/vnd.myvendor+json;version=2`. A complete example is 40 | below. 41 | 42 | ## DSL ## 43 | api-versions provides a (very) lightweight DSL for your routes file. Everything having to do with your routes API lives in the api block. This DSL helps you version your API as well as providing a caching mechanism to prevent the need of copy/pasting the same resources into new versions of the API. 44 | 45 | For example: 46 | 47 | In your routes.rb file: 48 | 49 | ``` ruby 50 | # You can leave default_version out, but if you do the first version used will become the default 51 | api vendor_string: "myvendor", default_version: 1 do 52 | version 1 do 53 | cache as: 'v1' do 54 | resources :authorizations 55 | end 56 | end 57 | 58 | version 2 do 59 | inherit from: 'v1' 60 | end 61 | end 62 | ``` 63 | 64 | `rake routes` outputs: 65 | 66 | api_authorizations GET /api/authorizations(.:format) api/v1/authorizations#index 67 | POST /api/authorizations(.:format) api/v1/authorizations#create 68 | new_api_authorization GET /api/authorizations/new(.:format) api/v1/authorizations#new 69 | edit_api_authorization GET /api/authorizations/:id/edit(.:format) api/v1/authorizations#edit 70 | api_authorization GET /api/authorizations/:id(.:format) api/v1/authorizations#show 71 | PUT /api/authorizations/:id(.:format) api/v1/authorizations#update 72 | DELETE /api/authorizations/:id(.:format) api/v1/authorizations#destroy 73 | GET /api/authorizations(.:format) api/v2/authorizations#index 74 | POST /api/authorizations(.:format) api/v2/authorizations#create 75 | GET /api/authorizations/new(.:format) api/v2/authorizations#new 76 | GET /api/authorizations/:id/edit(.:format) api/v2/authorizations#edit 77 | GET /api/authorizations/:id(.:format) api/v2/authorizations#show 78 | PUT /api/authorizations/:id(.:format) api/v2/authorizations#update 79 | DELETE /api/authorizations/:id(.:format) api/v2/authorizations#destroy 80 | 81 | 82 | Then the client simply sets the Accept header `application/vnd.myvendor+json;version=1`. If no version is specified, the default version you set will be assumed. 83 | You'll of course still need to copy all of your controllers over (or bump them automatically, see below), even if they haven't changed from version to version. At least you'll remove a bit of the mess in your routes file. 84 | 85 | A more complicated example: 86 | 87 | ``` ruby 88 | api vendor_string: "myvendor", default_version: 1 do 89 | version 1 do 90 | cache as: 'v1' do 91 | resources :authorizations, only: :create 92 | resources :foo 93 | resources :bar 94 | end 95 | end 96 | 97 | version 2 do 98 | cache as: 'v2' do 99 | inherit from: 'v1' 100 | resources :my_new_resource 101 | end 102 | end 103 | 104 | # V3 has everything in V2, and everything in V1 as well by virtue of V1 being cached in V2. 105 | version 3 do 106 | inherit from: 'v2' 107 | end 108 | end 109 | ``` 110 | 111 | And finally `rake routes` outputs: 112 | 113 | api_authorizations POST /api/authorizations(.:format) api/v1/authorizations#create 114 | api_foo_index GET /api/foo(.:format) api/v1/foo#index 115 | POST /api/foo(.:format) api/v1/foo#create 116 | new_api_foo GET /api/foo/new(.:format) api/v1/foo#new 117 | edit_api_foo GET /api/foo/:id/edit(.:format) api/v1/foo#edit 118 | api_foo GET /api/foo/:id(.:format) api/v1/foo#show 119 | PUT /api/foo/:id(.:format) api/v1/foo#update 120 | DELETE /api/foo/:id(.:format) api/v1/foo#destroy 121 | api_bar_index GET /api/bar(.:format) api/v1/bar#index 122 | POST /api/bar(.:format) api/v1/bar#create 123 | new_api_bar GET /api/bar/new(.:format) api/v1/bar#new 124 | edit_api_bar GET /api/bar/:id/edit(.:format) api/v1/bar#edit 125 | api_bar GET /api/bar/:id(.:format) api/v1/bar#show 126 | PUT /api/bar/:id(.:format) api/v1/bar#update 127 | DELETE /api/bar/:id(.:format) api/v1/bar#destroy 128 | POST /api/authorizations(.:format) api/v2/authorizations#create 129 | GET /api/foo(.:format) api/v2/foo#index 130 | POST /api/foo(.:format) api/v2/foo#create 131 | GET /api/foo/new(.:format) api/v2/foo#new 132 | GET /api/foo/:id/edit(.:format) api/v2/foo#edit 133 | GET /api/foo/:id(.:format) api/v2/foo#show 134 | PUT /api/foo/:id(.:format) api/v2/foo#update 135 | DELETE /api/foo/:id(.:format) api/v2/foo#destroy 136 | GET /api/bar(.:format) api/v2/bar#index 137 | POST /api/bar(.:format) api/v2/bar#create 138 | GET /api/bar/new(.:format) api/v2/bar#new 139 | GET /api/bar/:id/edit(.:format) api/v2/bar#edit 140 | GET /api/bar/:id(.:format) api/v2/bar#show 141 | PUT /api/bar/:id(.:format) api/v2/bar#update 142 | DELETE /api/bar/:id(.:format) api/v2/bar#destroy 143 | api_my_new_resource_index GET /api/my_new_resource(.:format) api/v2/my_new_resource#index 144 | POST /api/my_new_resource(.:format) api/v2/my_new_resource#create 145 | new_api_my_new_resource GET /api/my_new_resource/new(.:format) api/v2/my_new_resource#new 146 | edit_api_my_new_resource GET /api/my_new_resource/:id/edit(.:format) api/v2/my_new_resource#edit 147 | api_my_new_resource GET /api/my_new_resource/:id(.:format) api/v2/my_new_resource#show 148 | PUT /api/my_new_resource/:id(.:format) api/v2/my_new_resource#update 149 | DELETE /api/my_new_resource/:id(.:format) api/v2/my_new_resource#destroy 150 | POST /api/authorizations(.:format) api/v3/authorizations#create 151 | GET /api/foo(.:format) api/v3/foo#index 152 | POST /api/foo(.:format) api/v3/foo#create 153 | GET /api/foo/new(.:format) api/v3/foo#new 154 | GET /api/foo/:id/edit(.:format) api/v3/foo#edit 155 | GET /api/foo/:id(.:format) api/v3/foo#show 156 | PUT /api/foo/:id(.:format) api/v3/foo#update 157 | DELETE /api/foo/:id(.:format) api/v3/foo#destroy 158 | GET /api/bar(.:format) api/v3/bar#index 159 | POST /api/bar(.:format) api/v3/bar#create 160 | GET /api/bar/new(.:format) api/v3/bar#new 161 | GET /api/bar/:id/edit(.:format) api/v3/bar#edit 162 | GET /api/bar/:id(.:format) api/v3/bar#show 163 | PUT /api/bar/:id(.:format) api/v3/bar#update 164 | DELETE /api/bar/:id(.:format) api/v3/bar#destroy 165 | GET /api/my_new_resource(.:format) api/v3/my_new_resource#index 166 | POST /api/my_new_resource(.:format) api/v3/my_new_resource#create 167 | GET /api/my_new_resource/new(.:format) api/v3/my_new_resource#new 168 | GET /api/my_new_resource/:id/edit(.:format) api/v3/my_new_resource#edit 169 | GET /api/my_new_resource/:id(.:format) api/v3/my_new_resource#show 170 | PUT /api/my_new_resource/:id(.:format) api/v3/my_new_resource#update 171 | DELETE /api/my_new_resource/:id(.:format) api/v3/my_new_resource#destroy 172 | ## api_versions:bump 173 | The api-versions gem provides a Rails generator called `api_versions:bump`. This generator will go through all of your API controllers and find the highest version number and bump 174 | all controllers with it up to the next in sequence. 175 | 176 | If for example you have a controller `api/v1/authorizations_controller.rb` it will create `api/v2/authorizations_controller.rb` and inside: 177 | 178 | ``` ruby 179 | class Api::V2::AuthorizationsController < Api::V1::AuthorizationsController 180 | end 181 | ``` 182 | 183 | So instead of copying your prior version controllers over to the new ones and duplicating all the code in them, you can redefine specific methods, 184 | or start from scratch by removing the inheritance. 185 | 186 | ## Passing Rails Routing options 187 | The api-versions routing DSL will pass any options that are regularly accepted by Rails' own routing DSL. For example, if you are using an api subdomain and don't need your paths prefixed with `/api`, you can override it as you normally would: 188 | 189 | ```ruby 190 | api vendor_string: 'myvendor', default_version: 1, path: '' do 191 | version 1 do 192 | cache as: 'v1' do 193 | resources :foo 194 | end 195 | end 196 | end 197 | ``` 198 | 199 | Then a `rake routes` would show your desires fulfilled: 200 | 201 | ``` 202 | GET /foo(.:format) api/v1/foo#index 203 | POST /foo(.:format) api/v1/foo#create 204 | GET /foo/new(.:format) api/v1/foo#new 205 | GET /foo/:id/edit(.:format) api/v1/foo#edit 206 | GET /foo/:id(.:format) api/v1/foo#show 207 | PUT /foo/:id(.:format) api/v2/foo#update 208 | DELETE /foo/:id(.:format) api/v2/foo#destroy 209 | ``` 210 | 211 | It's also possible to configure route's namespace with `:namespace` option (if you want to remove namespacing at all just pass a blank string): 212 | 213 | ```ruby 214 | api vendor_string: 'myvendor', default_version: 1, namespace: 'auth_api' do 215 | version 1 do 216 | cache as: 'v1' do 217 | resources :foo, only: :index 218 | end 219 | end 220 | end 221 | ``` 222 | 223 | Then a `rake routes` would show your desires fulfilled: 224 | 225 | ``` 226 | GET /auth_api/foo(.:format) auth_api/v1/foo#index 227 | ``` 228 | 229 | ## Testing 230 | Because controller tests will not go through the routing constraints, you will get routing errors when testing API 231 | controllers. 232 | 233 | To avoid this problem you can use request/integration tests which will hit the routing constraints. 234 | 235 | To do this in RSpec, you should only need to move your spec files from `spec/controllers.` to `spec/requests/`: 236 | 237 | ```ruby 238 | # spec/requests/api/v1/widgets_controller_spec.rb 239 | require 'spec_helper' 240 | 241 | describe Api::V1::WidgetsController do 242 | describe "GET 'index'" do 243 | it "should be successful" do 244 | get '/api/widgets', {}, 'HTTP_ACCEPT' => 'application/vnd.myvendor+json; version=1' 245 | response.should be_success 246 | end 247 | end 248 | end 249 | ``` 250 | 251 | For Test::Unit, inherit from ActionDispatch::IntegrationTest: 252 | 253 | ```ruby 254 | # test/integration/api/v1/widgets_controller_test.rb 255 | require 'test_helper' 256 | 257 | class Api::V1::WidgetsControllerTest < ActionDispatch::IntegrationTest 258 | test "GET 'index'" do 259 | get '/api/widgets', {}, 'HTTP_ACCEPT' => 'application/vnd.myvendor+json; version=1' 260 | assert_response 200 261 | end 262 | end 263 | ``` 264 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new do |t| 5 | t.pattern = "./spec/**/*_spec.rb" # don't need this, it's default. 6 | # Put spec opts in a file named .rspec in root 7 | end 8 | 9 | task :default => :spec 10 | -------------------------------------------------------------------------------- /api-versions.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "api-versions/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "api-versions" 7 | s.version = ApiVersions::VERSION 8 | s.authors = ["Erich Menge", "David Celis"] 9 | s.email = ["erich.menge@me.com", "me@davidcel.is"] 10 | s.homepage = "https://github.com/EDMC/api-versions" 11 | s.summary = "api-versions helps manage your Rails app API endpoints." 12 | s.description = "api-versions helps manage your Rails app API endpoints." 13 | 14 | s.files = `git ls-files`.split("\n") 15 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 16 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 17 | s.require_paths = ["lib"] 18 | 19 | s.required_ruby_version = '>= 1.9' 20 | 21 | s.add_dependency('actionpack', '>= 3.0') 22 | s.add_dependency('activesupport', '>= 3.0') 23 | 24 | s.add_development_dependency "rspec-rails", "~> 3.7" 25 | s.add_development_dependency 'ammeter', '~> 1.1' 26 | s.add_development_dependency "coveralls" 27 | end 28 | -------------------------------------------------------------------------------- /lib/api-versions.rb: -------------------------------------------------------------------------------- 1 | require "api-versions/version" 2 | require "api-versions/version_check" 3 | require "api-versions/dsl" 4 | require 'api-versions/middleware' 5 | require 'api-versions/railtie' 6 | 7 | module ApiVersions 8 | def api(options = {}, &block) 9 | raise "Please set a vendor_string for the api method" if options[:vendor_string].nil? 10 | 11 | VersionCheck.default_version = options[:default_version] 12 | VersionCheck.vendor_string = options[:vendor_string] 13 | 14 | api_namespace = options.fetch(:namespace, :api) 15 | 16 | if api_namespace.blank? 17 | DSL.new(self, &block) 18 | else 19 | namespace(api_namespace, options) { DSL.new(self, &block) } 20 | end 21 | end 22 | end 23 | 24 | ActionDispatch::Routing::Mapper.send :include, ApiVersions 25 | -------------------------------------------------------------------------------- /lib/api-versions/dsl.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module ApiVersions 4 | class DSL 5 | extend Forwardable 6 | 7 | def initialize(context, &block) 8 | @context = context 9 | singleton_class.def_delegators :@context, *(@context.public_methods - public_methods) 10 | instance_eval(&block) 11 | end 12 | 13 | def version(version_number, &block) 14 | VersionCheck.default_version ||= version_number 15 | 16 | constraints VersionCheck.new(version: version_number) do 17 | scope({ module: "v#{version_number}" }, &block) 18 | end 19 | end 20 | 21 | def inherit(options) 22 | Array.wrap(options[:from]).each do |block| 23 | @resource_cache[block].call 24 | end 25 | end 26 | 27 | def cache(options, &block) 28 | @resource_cache ||= {} 29 | @resource_cache[options[:as]] = block 30 | yield 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/api-versions/middleware.rb: -------------------------------------------------------------------------------- 1 | module ApiVersions 2 | class Middleware 3 | def initialize(app) 4 | @app = app 5 | end 6 | 7 | def call(env) 8 | accept_string = env['HTTP_ACCEPT'] || "" 9 | accepts = accept_string.split(',') 10 | accepts.push("application/vnd.#{ApiVersions::VersionCheck.vendor_string}+json") unless accept_string.include?('application/vnd.') 11 | offset = 0 12 | accepts.dup.each_with_index do |accept, i| 13 | accept.strip! 14 | match = /\Aapplication\/vnd\.#{ApiVersions::VersionCheck.vendor_string}\s*\+\s*(?\w+)\s*/.match(accept) 15 | if match 16 | accepts.insert i + offset, "application/#{match[:format]}" 17 | offset += 1 18 | end 19 | end 20 | 21 | env['HTTP_ACCEPT'] = accepts.join(',') 22 | @app.call(env) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/api-versions/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'rails/railtie' 2 | 3 | module ApiVersions 4 | class Railtie < Rails::Railtie 5 | config.app_middleware.use ApiVersions::Middleware 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/api-versions/version.rb: -------------------------------------------------------------------------------- 1 | module ApiVersions 2 | class Version 3 | MAJOR = 1 4 | MINOR = 2 5 | PATCH = 1 6 | 7 | def self.to_s 8 | [MAJOR, MINOR, PATCH].join('.') 9 | end 10 | end 11 | 12 | VERSION = Version.to_s 13 | end 14 | -------------------------------------------------------------------------------- /lib/api-versions/version_check.rb: -------------------------------------------------------------------------------- 1 | module ApiVersions 2 | class VersionCheck 3 | class << self 4 | attr_accessor :default_version, :vendor_string 5 | end 6 | 7 | def initialize(args = {}) 8 | @process_version = args[:version] 9 | end 10 | 11 | def matches?(request) 12 | accepts = request.headers['Accept'].split(',') 13 | accepts.any? do |accept| 14 | accept.strip! 15 | accepts_proper_format?(accept) && (matches_version?(accept) || unversioned?(accept)) 16 | end 17 | end 18 | 19 | private 20 | 21 | def accepts_proper_format?(accept_string) 22 | accept_string =~ /\Aapplication\/vnd\.#{self.class.vendor_string}\s*\+\s*.+/ 23 | end 24 | 25 | def matches_version?(accept_string) 26 | accept_string =~ /version\s*?=\s*?#{@process_version}\b/ 27 | end 28 | 29 | def unversioned?(accept_string) 30 | @process_version == self.class.default_version && !(accept_string =~ /version\s*?=\s*?\d*\b/i) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/generators/api_versions/bump_generator.rb: -------------------------------------------------------------------------------- 1 | module ApiVersions 2 | module Generators 3 | class BumpGenerator < Rails::Generators::Base 4 | desc "Bump API version to next version, initializing controllers" 5 | source_root File.expand_path('../templates', __FILE__) 6 | 7 | def get_controllers 8 | Dir.chdir File.join(Rails.root, 'app', 'controllers') do 9 | @controllers = Dir.glob('api/v**/**/*.rb') 10 | end 11 | 12 | @highest_version = @controllers.map do |controller| 13 | controller.match(/api\/v(\d+?)\//)[1] 14 | end.max 15 | 16 | @controllers.keep_if { |element| element =~ /api\/v#{@highest_version}\// } 17 | end 18 | 19 | def generate_new_controllers 20 | @controllers.each do |controller| 21 | new_controller = controller.gsub(/api\/v#{@highest_version}\//, "api/v#{@highest_version.to_i + 1}/") 22 | @current_new_controller = new_controller.chomp(File.extname(controller)).camelize 23 | @current_old_controller = controller.chomp(File.extname(controller)).camelize 24 | template 'controller.rb', File.join('app', 'controllers', new_controller) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/generators/api_versions/templates/controller.rb: -------------------------------------------------------------------------------- 1 | class <%= @current_new_controller %> < <%= @current_old_controller %> 2 | end 3 | -------------------------------------------------------------------------------- /spec/api_versions_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiVersions do 4 | let(:testclass) { Class.new.extend ApiVersions } 5 | 6 | it "should raise if no vendor string is provided" do 7 | expect { testclass.api }.to raise_exception(RuntimeError, 'Please set a vendor_string for the api method') 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/api/v1/bar_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::V1::BarController < ActionController::Base 2 | def new 3 | respond_to do |format| 4 | format.json { render json: {} } 5 | format.xml { render xml: {} } 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/api/v2/bar_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::V2::BarController < Api::V1::BarController 2 | 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/api/v2/foo_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::V2::FooController < ActionController::Base 2 | def new 3 | respond_to do |format| 4 | format.json { render json: {} } 5 | format.xml { render xml: {} } 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/api/v2/users_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::V2::UsersController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/api/v3/bar_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::V3::BarController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/api/v3/foo_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::V3::FooController < Api::V2::FooController 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/api/v3/nests/nested_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::V3::Nests::NestedController < ActionController::Base 2 | def new 3 | render body: nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/auth_api/v1/quux_controller.rb: -------------------------------------------------------------------------------- 1 | class AuthApi::V1::QuuxController < ActionController::Base 2 | def new 3 | respond_to do |format| 4 | format.json { render json: {} } 5 | format.xml { render xml: {} } 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/errors_controller.rb: -------------------------------------------------------------------------------- 1 | class ErrorsController < ActionController::Base 2 | def not_found 3 | head :not_found 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/v1/qux_controller.rb: -------------------------------------------------------------------------------- 1 | class V1::QuxController < ActionController::Base 2 | def new 3 | respond_to do |format| 4 | format.json { render json: {} } 5 | format.xml { render xml: {} } 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Dummy::Application 5 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | # Pick the frameworks you want: 4 | require "action_controller/railtie" 5 | 6 | if defined?(Rails.groups) 7 | Bundler.require(*Rails.groups) 8 | else 9 | Bundler.require 10 | end 11 | require "api-versions" 12 | 13 | module Dummy 14 | class Application < Rails::Application 15 | end 16 | end 17 | 18 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | gemfile = File.expand_path('../../../../Gemfile', __FILE__) 2 | 3 | if File.exist?(gemfile) 4 | ENV['BUNDLE_GEMFILE'] = gemfile 5 | require 'bundler' 6 | Bundler.setup 7 | end 8 | 9 | $:.unshift File.expand_path('../../../../lib', __FILE__) -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the rails application. 5 | Dummy::Application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | config.cache_classes = true 3 | config.eager_load = false 4 | config.consider_all_requests_local = true 5 | config.action_controller.perform_caching = false 6 | config.action_dispatch.show_exceptions = false 7 | config.action_controller.allow_forgery_protection = false 8 | config.active_support.deprecation = :stderr 9 | config.secret_key_base = "eb6e66d4263b36e93912a0e0c367059d38c3417058890737c1bed0e52961429817430c38be6acab423cf023fe51c05123727f4dd4782cb2a46966d496d8e951c" 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.routes.draw do 2 | api vendor_string: "myvendor" do 3 | version 1 do 4 | cache as: 'v1' do 5 | resources :bar 6 | end 7 | end 8 | 9 | version 2 do 10 | cache as: 'v2' do 11 | resources :foo 12 | inherit from: 'v1' 13 | end 14 | end 15 | 16 | version 3 do 17 | inherit from: 'v2' 18 | 19 | namespace :nests do 20 | resources :nested 21 | end 22 | end 23 | end 24 | 25 | api vendor_string: 'myvendor', path: '' do 26 | version 1 do 27 | cache as: 'v1' do 28 | resources :baz 29 | end 30 | end 31 | end 32 | 33 | api vendor_string: 'myvendor', namespace: '' do 34 | version 1 do 35 | cache as: 'v1' do 36 | resources :qux 37 | end 38 | end 39 | end 40 | 41 | api vendor_string: 'myvendor', namespace: 'auth_api' do 42 | version 1 do 43 | cache as: 'v1' do 44 | resources :quux 45 | end 46 | end 47 | end 48 | 49 | api vendor_string: 'myvendor', path: '' do 50 | version 1 do 51 | cache as: 'v1' do 52 | resources :posts, shallow: true do 53 | resources :replies 54 | end 55 | end 56 | end 57 | end 58 | 59 | get '*a' => 'errors#not_found' 60 | end 61 | -------------------------------------------------------------------------------- /spec/dummy/log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EDMC/api-versions/4e6f2d01369c1a82b0a1c927b0237d487cf7658c/spec/dummy/log/.gitkeep -------------------------------------------------------------------------------- /spec/generators/bump_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'generators/api_versions/bump_generator' 3 | 4 | describe ApiVersions::Generators::BumpGenerator do 5 | before do 6 | destination File.expand_path("../../../tmp", __FILE__) 7 | prepare_destination 8 | end 9 | 10 | describe "Generated files" do 11 | before { run_generator } 12 | 13 | describe "Bar Controller" do 14 | subject { file('app/controllers/api/v4/bar_controller.rb') } 15 | 16 | it { is_expected.to exist } 17 | it { is_expected.to contain /Api::V4::BarController < Api::V3::BarController/ } 18 | end 19 | 20 | describe "Foo Controller" do 21 | subject { file('app/controllers/api/v4/foo_controller.rb') } 22 | 23 | it { is_expected.to exist } 24 | it { is_expected.to contain /Api::V4::FooController < Api::V3::FooController/ } 25 | end 26 | 27 | describe "Nested Controller" do 28 | subject { file('app/controllers/api/v4/nests/nested_controller.rb') } 29 | 30 | it { is_expected.to exist } 31 | it { is_expected.to contain /Api::V4::Nests::NestedController < Api::V3::Nests::NestedController/ } 32 | end 33 | 34 | describe "Users Controller" do 35 | subject { file('app/controllers/api/v4/users_controller.rb') } 36 | 37 | it { is_expected.not_to exist } 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'rack/test' 3 | 4 | describe ApiVersions::Middleware do 5 | let(:app) { ->(env) { [200, { "Content-Type" => "text/plain" }, [env["HTTP_ACCEPT"]]] } } 6 | 7 | around do |example| 8 | original = ApiVersions::VersionCheck.vendor_string 9 | ApiVersions::VersionCheck.vendor_string = "myvendor" 10 | example.run 11 | ApiVersions::VersionCheck.vendor_string = original 12 | end 13 | 14 | describe "the accept header" do 15 | it "should not adjust the header" do 16 | request = Rack::MockRequest.env_for("/", "HTTP_ACCEPT" => "application/vnd.foobar+json;version=1", lint: true, fatal: true) 17 | response = described_class.new(app).call(request).last 18 | expect(response.first).to eq("application/vnd.foobar+json;version=1") 19 | end 20 | 21 | it "should adjust the header" do 22 | request = Rack::MockRequest.env_for("/", "HTTP_ACCEPT" => "text/plain,application/vnd.myvendor+json;version=1,text/html,application/vnd.myvendor+xml", lint: true, fatal: true) 23 | response = described_class.new(app).call(request).last 24 | expect(response.last).to eq("text/plain,application/json,application/vnd.myvendor+json;version=1,text/html,application/xml,application/vnd.myvendor+xml") 25 | end 26 | 27 | it "should add a default vendor accept to a nil Accept header" do 28 | request = Rack::MockRequest.env_for("/", lint: true, fatal: true) 29 | response = described_class.new(app).call(request).last 30 | expect(response.last).to eq("application/json,application/vnd.#{ApiVersions::VersionCheck.vendor_string}+json") 31 | end 32 | 33 | it "should add a default vendor accept to an empty Accept header" do 34 | request = Rack::MockRequest.env_for("/", "HTTP_ACCEPT" => '', lint: true, fatal: true) 35 | response = described_class.new(app).call(request).last 36 | expect(response.last).to eq("application/json,application/vnd.#{ApiVersions::VersionCheck.vendor_string}+json") 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/routing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'API Routing' do 4 | include RSpec::Rails::RequestExampleGroup 5 | 6 | describe "V1" do 7 | it "should not route something from V2" do 8 | get new_api_foo_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.myvendor+json;version=1' } 9 | expect(response.status).to eq(404) 10 | end 11 | 12 | it "should route" do 13 | get new_api_bar_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.myvendor+json;version=1' } 14 | expect(@controller.class).to eq(Api::V1::BarController) 15 | end 16 | 17 | it "should default" do 18 | get new_api_bar_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.myvendor+json' } 19 | expect(@controller.class).to eq(Api::V1::BarController) 20 | end 21 | 22 | it "should default with nothing after the semi-colon" do 23 | get new_api_bar_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.myvendor+json; ' } 24 | expect(@controller.class).to eq(Api::V1::BarController) 25 | end 26 | end 27 | 28 | describe "V2" do 29 | it "should copy bar" do 30 | get new_api_bar_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.myvendor+json;version=2' } 31 | expect(@controller.class).to eq(Api::V2::BarController) 32 | end 33 | 34 | it "should add foo" do 35 | get new_api_foo_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.myvendor+json;version=2' } 36 | expect(@controller.class).to eq(Api::V2::FooController) 37 | end 38 | 39 | it "should not default" do 40 | get new_api_foo_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.myvendor+json' } 41 | expect(response.status).to eq(404) 42 | end 43 | 44 | it "should default" do 45 | original_version = ApiVersions::VersionCheck.default_version 46 | ApiVersions::VersionCheck.default_version = 2 47 | get new_api_foo_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.myvendor+json' } 48 | ApiVersions::VersionCheck.default_version = original_version 49 | expect(@controller.class).to eq(Api::V2::FooController) 50 | end 51 | end 52 | 53 | describe "V3" do 54 | it "should copy foo" do 55 | get new_api_foo_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.myvendor+json;version=3' } 56 | expect(@controller.class).to eq(Api::V3::FooController) 57 | end 58 | 59 | it "should route to nested controllers" do 60 | get new_api_nests_nested_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.myvendor+json;version=3' } 61 | expect(@controller.class).to eq(Api::V3::Nests::NestedController) 62 | end 63 | end 64 | 65 | describe "Header syntax" do 66 | context "when valid" do 67 | after(:each) do 68 | get new_api_bar_path, headers: { 'HTTP_ACCEPT' => @accept_string } 69 | desired_format = /application\/.*\+\s*(?\w+)\s*/.match(@accept_string)[:format] 70 | expect(response.content_type).to eq("application/#{desired_format}") 71 | expect(response).to be_successful 72 | end 73 | 74 | context "the semi-colon" do 75 | it "should allow spaces after" do 76 | @accept_string = 'application/vnd.myvendor+json; version=1' 77 | end 78 | 79 | it "should allow spaces before" do 80 | @accept_string = 'application/vnd.myvendor+xml ;version=1' 81 | end 82 | 83 | it "should allow spaces around" do 84 | @accept_string = 'application/vnd.myvendor+xml ; version=1' 85 | end 86 | end 87 | 88 | context "the equal sign" do 89 | it "should allow spacing before" do 90 | @accept_string = 'application/vnd.myvendor+json; version =1' 91 | end 92 | 93 | it "should allow spacing after" do 94 | @accept_string = 'application/vnd.myvendor+json; version= 1' 95 | end 96 | 97 | it "should allow spacing around" do 98 | @accept_string = 'application/vnd.myvendor+json; version = 1' 99 | end 100 | end 101 | 102 | context "the plus sign" do 103 | it "should allow spacing before" do 104 | @accept_string = 'application/vnd.myvendor +xml; version=1' 105 | end 106 | 107 | it "should allow spacing after" do 108 | @accept_string = 'application/vnd.myvendor+ xml; version=1' 109 | end 110 | 111 | it "should allow spacing around" do 112 | @accept_string = 'application/vnd.myvendor + xml; version=1' 113 | end 114 | end 115 | end 116 | 117 | it "should not route when invalid" do 118 | get new_api_bar_path, headers: { 'HTTP_ACCEPT' => 'application/vnd.garbage+xml;version=1' } 119 | expect(response.status).to eq(404) 120 | end 121 | end 122 | 123 | describe 'paths' do 124 | it "should pass options, such as :path, to the regular routing DSL" do 125 | expect(new_api_baz_path).to eq('/baz/new') 126 | end 127 | 128 | it 'should be possible to use shallow routes with overwritten :path option' do 129 | expect(api_reply_path(1)).to eq('/replies/1') 130 | end 131 | end 132 | 133 | describe 'namespace' do 134 | it "should be possible to remove api namespace" do 135 | expect(new_qux_path).to eq('/qux/new') 136 | end 137 | 138 | it "should be possible to overwrite api namespace" do 139 | expect(new_auth_api_quux_path).to eq('/auth_api/quux/new') 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | 3 | Coveralls.wear! do 4 | add_filter "/spec/" 5 | end 6 | 7 | ENV["RAILS_ENV"] ||= 'test' 8 | require File.expand_path("../dummy/config/environment", __FILE__) 9 | require 'rspec/rails' 10 | require 'ammeter/init' 11 | 12 | Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f} 13 | 14 | RSpec.configure do |config| 15 | config.infer_base_class_for_anonymous_controllers = true 16 | config.order = "random" 17 | end 18 | --------------------------------------------------------------------------------