Skip to content

Instantly share code, notes, and snippets.

@Sutto
Created October 12, 2009 08:59
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Sutto/208254 to your computer and use it in GitHub Desktop.
Save Sutto/208254 to your computer and use it in GitHub Desktop.
require 'rack'
# Released under MIT license, see tests / example.ru for usage
module Rack
class APIVersionMapper
ERROR_TEXT = '<html><head><title>Unknown API Version</title></head><body>Unknown API Version v%s</body></html>'.freeze
EMPTY_RESPONSE = [404, {'Content-Type' => 'text/html', 'Content-Length' => '0'}, ['']].freeze
VERSION_ENV_KEY = 'x-rack.api_mapper.version'.freeze
QUERY_STRING = "QUERY_STRING".freeze
class VersionWrapper
def initialize(app, version, mapper)
@app = app
@version = version
@mapper = mapper
end
def call(env)
env[VERSION_ENV_KEY] = @version
if @mapper.add_param?
env[QUERY_STRING] << "&" unless env[QUERY_STRING] == ""
env[QUERY_STRING] << "api_version=#{@version}"
end
@app.call(env)
end
end
UnknownVersionResponder = proc do |env|
if env["PATH_INFO"].to_s.squeeze("/") =~ /^\/v?([^\/]+)/
body = ERROR_TEXT % $1
[404, {"Content-Type" => "text/html", "Content-Length" => body.size.to_s}, [body]]
else
EMPTY_RESPONSE
end
end
def initialize(options = {}, &blk)
@api_versions = {}
@add_param = options.delete(:add_param)
blk.arity == 1 ? blk.call(self) : instance_eval(&blk) if block_given?
end
def add_version(version, app)
@app = nil
version_string = version.to_s.gsub(/^v/, '')
@api_versions[version_string] = VersionWrapper.new(app, version, self)
true
end
def call(env)
(@app ||= build_url_mapper).call(env)
end
def current_version=(value)
@current_version = value.to_s
end
def current_version
@current_version ||= @api_versions.keys.max
end
def add_param?
@add_param
end
def add_param=(value)
@add_param = value
end
private
def build_url_mapper
version_url_mapping = {}
@api_versions.each do |version, app|
version_url_mapping["/api/v#{version}"] = app
version_url_mapping["/api/current"] = app if version == current_version
end
version_url_mapping["/api"] = UnknownVersionResponder
Rack::URLMap.new(version_url_mapping)
end
end
end
require 'rubygems'
require 'test/unit'
require 'redgreen' if RUBY_VERSION < '1.9'
require 'rack'
require File.join(File.dirname(__FILE__), "api_version_mapper")
class APIVersionMapperTest < Test::Unit::TestCase
def setup
@example_app = proc do |env|
headers = {
"X-API-Version" => env["x-rack.api_mapper.version"].to_s,
"X-PathInfo" => env["PATH_INFO"],
"X-ScriptName" => env["SCRIPT_NAME"],
"X-QueryString" => env["QUERY_STRING"],
"Content-Type" => "text/html",
"Content-Length" => "0"
}
[200, headers, []]
end
@mapper = Rack::APIVersionMapper.new do |mapper|
mapper.add_version 1, @example_app
mapper.add_version 1.1, @example_app
mapper.add_version 2, @example_app
mapper.add_version "3", @example_app
mapper.add_version "3rc1", @example_app
mapper.current_version = 3
end
end
def test_unknown_urls
get '/not-an-api-url'
assert @response.not_found?
get '/'
assert @response.not_found?
end
def test_unknown_versions
get '/api/v4/awesome'
assert @response.not_found?
get '/api/unknown/stuff.json'
assert @response.not_found?
end
def test_known_versions
get '/api/v1/test'
assert_valid_response 1, "/api/v1", "/test"
get '/api/v2/'
assert_valid_response 2, "/api/v2", "/"
get '/api/v3/users/12.json'
assert_valid_response 3, "/api/v3", "/users/12.json"
get '/api/current/users/12.json'
assert_valid_response 3, "/api/current", "/users/12.json"
get '/api/v3rc1/rocketships.json'
assert_valid_response "3rc1", "/api/v3rc1", "/rocketships.json"
get '/api/v1.1/auth-check'
assert_valid_response 1.1, "/api/v1.1", "/auth-check"
end
def test_nested_script_name
get '/api/v2/users/12.json', 'SCRIPT_NAME' => '/my-app'
assert_valid_response 2, "/my-app/api/v2", "/users/12.json"
end
def test_adding_param
get '/api/v2/users/12.json?awesome_sauce=true'
assert_valid_response 2, "/api/v2", "/users/12.json", "awesome_sauce=true"
@mapper.add_param = true
get '/api/v2/users/12.json'
assert_valid_response 2, "/api/v2", "/users/12.json", "api_version=2"
get '/api/v2/users/12.json?awesome_sauce=true'
assert_valid_response 2, "/api/v2", "/users/12.json", "awesome_sauce=true&api_version=2"
end
protected
def get(path, env = {})
@response = Rack::MockRequest.new(@mapper).get(path, env)
end
def assert_api_version(version)
assert_equal version.to_s, @response["X-API-Version"], "Incorrect api version, expected #{version}"
end
def assert_script_name(expected)
assert_equal expected, @response["X-ScriptName"], "Incorrect SCRIPT_NAME, expected #{expected}"
end
def assert_path_info(expected)
assert_equal expected, @response["X-PathInfo"], "Incorrect PATH_INFO, expected #{expected}"
end
def assert_query_string(expected)
assert_equal expected, @response["X-QueryString"], "Incorrect QUERY_STRING, expected #{expected}"
end
def assert_valid_response(version, script_name, path_info, query_string = "")
assert @response.ok?, "the response should be ok (was #{@response.status} instead)"
assert_api_version version
assert_script_name script_name
assert_path_info path_info
assert_query_string query_string
end
end
require 'rack'
require File.join(File.dirname(__FILE__), "api_version_mapper")
API_APP = proc do |env|
headers = {
"API-Version" => env["rack.api_mapper.version"].to_s,
"X-PathInfo" => env["PATH_INFO"],
"X-ScriptName" => env["SCRIPT_NAME"],
"Content-Type" => "text/html",
"Content-Length" => "0"
}
[200, headers, []]
end
api_mapper = Rack::APIVersionMapper.new do |m|
m.add_version 1, API_APP
m.add_version 2, API_APP
m.add_version 3, API_APP
m.current_version = 2
end
run api_mapper
# Add to config/initializers/routing_api_monkey_path.tb
.class_eval do
alias_method_chain :extract_request_environment, :api_version
end
class Rack::APIVersionMapper
module RouteSetExtensions
def self.included(klass)
klass.alias_method_chain :extract_request_environment, :api_version
end
def extract_request_environment_with_api_version(request)
version = request.version[Rack::APIVersionMapper::VERSION_ENV_KEY]
extract_request_environment_without_api_version.merge :api_version => version
end
end
module RouteExtensions
def self.included(klass)
klass.alias_method_chain :recognition_conditions, :api_version
end
def recognition_conditions_with_api_version
result = recognition_conditions_without_api_version
result << "conditions[:api_version] === env[:api_version]" if conditions[:api_version]
result
end
end
end
ActionController::Routing::RouteSet.send(:include, Rack::APIVersionMapper::RouteSetExtensions)
ActionController::Routing::Route.send(:include, Rack::APIVersionMapper::RouteExtensions)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment