Skip to content

add support for versioning using the 'Accept-Version' header #403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 10, 2013
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Next Release
* [#392](https://github.com/intridea/grape/pull/392): Extracted headers and params from `Endpoint` to `Grape::Request` - [@niedhui](https://github.com/niedhui).
* [#394](https://github.com/intridea/grape/pull/394): Path version no longer overwrites a `version` parameter - [@tmornini](https://github.com/tmornini).
* [#390](https://github.com/intridea/grape/pull/390): Added default value for an `optional` parameter - [@oivoodoo](https://github.com/oivoodoo).
* [#403](https://github.com/intridea/grape/pull/403): Added support for versioning using the 'Accept-Version' header - [@politician](https://github.com/politician).
* Your contribution here.

0.4.1 (4/1/2013)
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ end

## Versioning

There are three strategies in which clients can reach your API's endpoints: `:path`,
`:header` and `:param`. The default strategy is `:path`.
There are four strategies in which clients can reach your API's endpoints: `:path`,
`:header`, `:accept_version_header` and `:param`. The default strategy is `:path`.

### Path

Expand All @@ -233,6 +233,21 @@ supplied. This behavior is similar to routing in Rails. To circumvent this defau
one could use the `:strict` option. When this option is set to `true`, a `406 Not Acceptable` error
is returned when no correct `Accept` header is supplied.

### Accept-Version Header

```ruby
version 'v1', using: :accept_version_header
```

Using this versioning strategy, clients should pass the desired version in the HTTP `Accept-Version` header.

curl -H "Accept-Version=v1" http://localhost:9292/statuses/public_timeline

By default, the first matching version is used when no `Accept-Version` header is
supplied. This behavior is similar to routing in Rails. To circumvent this default behavior,
one could use the `:strict` option. When this option is set to `true`, a `406 Not Acceptable` error
is returned when no correct `Accept` header is supplied.

### Param

```ruby
Expand Down
7 changes: 4 additions & 3 deletions lib/grape.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ module Auth
end

module Versioner
autoload :Path, 'grape/middleware/versioner/path'
autoload :Header, 'grape/middleware/versioner/header'
autoload :Param, 'grape/middleware/versioner/param'
autoload :Path, 'grape/middleware/versioner/path'
autoload :Header, 'grape/middleware/versioner/header'
autoload :Param, 'grape/middleware/versioner/param'
autoload :AcceptVersionHeader, 'grape/middleware/versioner/accept_version_header'
end
end

Expand Down
2 changes: 2 additions & 0 deletions lib/grape/middleware/versioner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def using(strategy)
Header
when :param
Param
when :accept_version_header
AcceptVersionHeader
else
raise Grape::Exceptions::InvalidVersionerOption.new(strategy)
end
Expand Down
67 changes: 67 additions & 0 deletions lib/grape/middleware/versioner/accept_version_header.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
require 'grape/middleware/base'

module Grape
module Middleware
module Versioner
# This middleware sets various version related rack environment variables
# based on the HTTP Accept-Version header
#
# Example: For request header
# Accept-Version: v1
#
# The following rack env variables are set:
#
# env['api.version] => 'v1'
#
# If version does not match this route, then a 406 is raised with
# X-Cascade header to alert Rack::Mount to attempt the next matched
# route.
class AcceptVersionHeader < Base

def before
potential_version = (env['HTTP_ACCEPT_VERSION'] || '').strip

if strict?
# If no Accept-Version header:
if potential_version.empty?
throw :error, :status => 406, :headers => error_headers, :message => 'Accept-Version header must be set.'
end
end

unless potential_version.empty?
# If the requested version is not supported:
if !versions.any? { |v| v.to_s == potential_version }
throw :error, :status => 406, :headers => error_headers, :message => 'The requested version is not supported.'
end

env['api.version'] = potential_version
end
end

private

def versions
options[:versions] || []
end

def strict?
options[:version_options] && options[:version_options][:strict]
end

# By default those errors contain an `X-Cascade` header set to `pass`, which allows nesting and stacking
# of routes (see [Rack::Mount](https://github.com/josh/rack-mount) for more information). To prevent
# this behavior, and not add the `X-Cascade` header, one can set the `:cascade` option to `false`.
def cascade?
options[:version_options] && options[:version_options].has_key?(:cascade) ?
!! options[:version_options][:cascade] :
true
end

def error_headers
cascade? ? { 'X-Cascade' => 'pass' } : {}
end

end
end
end
end
17 changes: 17 additions & 0 deletions spec/grape/api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ def app; subject end
# pending 'routes if any media type is allowed'
end

describe '.version using accept_version_header' do
it_should_behave_like 'versioning' do
let(:macro_options) do
{
:using => :accept_version_header
}
end
end
end

describe '.represent' do
it 'requires a :with option' do
expect{ subject.represent Object, {} }.to raise_error(Grape::Exceptions::InvalidWithOptionForRepresent)
Expand Down Expand Up @@ -275,6 +285,13 @@ def subject.enable_root_route!
versioned_get "/", "v1", :using => :param
end

it 'Accept-Version header versioned APIs' do
subject.version 'v1', :using => :accept_version_header
subject.enable_root_route!

versioned_get "/", "v1", :using => :accept_version_header
end

it 'unversioned APIs' do
subject.enable_root_route!

Expand Down
121 changes: 121 additions & 0 deletions spec/grape/middleware/versioner/accept_version_header_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
require 'spec_helper'

describe Grape::Middleware::Versioner::AcceptVersionHeader do
let(:app) { lambda{|env| [200, env, env]} }
subject { Grape::Middleware::Versioner::AcceptVersionHeader.new(app, @options || {}) }

before do
@options = {
:version_options => {
:using => :accept_version_header
},
}
end

context 'api.version' do
before do
@options[:versions] = ['v1']
end

it 'is set' do
status, _, env = subject.call('HTTP_ACCEPT_VERSION' => 'v1')
env['api.version'].should eql 'v1'
status.should == 200
end

it 'is set if format provided' do
status, _, env = subject.call('HTTP_ACCEPT_VERSION' => 'v1')
env['api.version'].should eql 'v1'
status.should == 200
end

it 'fails with 406 Not Acceptable if version is not supported' do
expect {
env = subject.call('HTTP_ACCEPT_VERSION' => 'v2').last
}.to throw_symbol(
:error,
:status => 406,
:headers => {'X-Cascade' => 'pass'},
:message => 'The requested version is not supported.'
)
end
end

it 'succeeds if :strict is not set' do
subject.call('HTTP_ACCEPT_VERSION' => '').first.should == 200
subject.call({}).first.should == 200
end

it 'succeeds if :strict is set to false' do
@options[:version_options][:strict] = false
subject.call('HTTP_ACCEPT_VERSION' => '').first.should == 200
subject.call({}).first.should == 200
end

context 'when :strict is set' do
before do
@options[:versions] = ['v1']
@options[:version_options][:strict] = true
end

it 'fails with 406 Not Acceptable if header is not set' do
expect {
env = subject.call({}).last
}.to throw_symbol(
:error,
:status => 406,
:headers => {'X-Cascade' => 'pass'},
:message => 'Accept-Version header must be set.'
)
end

it 'fails with 406 Not Acceptable if header is empty' do
expect {
env = subject.call('HTTP_ACCEPT_VERSION' => '').last
}.to throw_symbol(
:error,
:status => 406,
:headers => {'X-Cascade' => 'pass'},
:message => 'Accept-Version header must be set.'
)
end

it 'succeeds if proper header is set' do
subject.call('HTTP_ACCEPT_VERSION' => 'v1').first.should == 200
end
end

context 'when :strict and :cascade=>false are set' do
before do
@options[:versions] = ['v1']
@options[:version_options][:strict] = true
@options[:version_options][:cascade] = false
end

it 'fails with 406 Not Acceptable if header is not set' do
expect {
env = subject.call({}).last
}.to throw_symbol(
:error,
:status => 406,
:headers => {},
:message => 'Accept-Version header must be set.'
)
end

it 'fails with 406 Not Acceptable if header is empty' do
expect {
env = subject.call('HTTP_ACCEPT_VERSION' => '').last
}.to throw_symbol(
:error,
:status => 406,
:headers => {},
:message => 'Accept-Version header must be set.'
)
end

it 'succeeds if proper header is set' do
subject.call('HTTP_ACCEPT_VERSION' => 'v1').first.should == 200
end
end
end
5 changes: 4 additions & 1 deletion spec/grape/middleware/versioner_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@
klass.using(:param).should == Grape::Middleware::Versioner::Param
end

end
it 'recognizes :accept_version_header' do
klass.using(:accept_version_header).should == Grape::Middleware::Versioner::AcceptVersionHeader
end
end
6 changes: 6 additions & 0 deletions spec/support/versioned_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ def versioned_path(options = {})
File.join('/', options[:prefix] || '', options[:path])
when :header
File.join('/', options[:prefix] || '', options[:path])
when :accept_version_header
File.join('/', options[:prefix] || '', options[:path])
else
raise ArgumentError.new("unknown versioning strategy: #{options[:using]}")
end
Expand All @@ -25,6 +27,10 @@ def versioned_headers(options)
{
'HTTP_ACCEPT' => "application/vnd.#{options[:vendor]}-#{options[:version]}+#{options[:format]}"
}
when :accept_version_header
{
'HTTP_ACCEPT_VERSION' => "#{options[:version]}"
}
else
raise ArgumentError.new("unknown versioning strategy: #{options[:using]}")
end
Expand Down