Rack middleware submitted by user Sutto
RedisRequestLimiter
About
A configurable (yet still simple) set of middleware that makes it easy to limit user requests (typically for an api)
based on a # / hour limit. Combined with other middleware it’d be easy to do routing.
Usage
It accepts a variety of hash-style options, with defaults specified in RedisRequestLimiter::DEFAULT_OPTIONS:
- :per_user_limit – the number of requests / period a user normally can use, Defaults to 100
- :reset_requests_every – Number of seconds to reset after (Defaults to 5 minutes)
- :override_limit_proc – proc returning [limit_count, reset_every] ([nil, nil] to use defaults) based on a specified env
- :redis_rate_key – the default redis key prefix, api-requests out of the box
- :header_status_prefix – prefix for the added api limit headers, defaults to X-API-Limit
- :env_to_api_key_proc – returns a string to use in the key per-user, defaults to the requester’s IP
- :limiter_app – a rack app that is called when limited.
Additionally, RedisRequestLimiter.redis(=) is defined to let you set a default Redis client
Code
require 'rubygems'require 'rack'require 'redis_request_limiter' # Give 5 requests a minute to make testing really easyuse RedisAPILimiter, :per_user_limit => 5, :reset_requests_every => 60run proc { |env| [200, {"Content-Type" => "text/html", "Content-Length" => "5"}, ["Hello"]]} require 'redis' class RedisRequestLimiter DEFAULT_OPTIONS = { :per_user_limit => 100, :reset_requests_every => 300, :override_limit_proc => proc { |env| [nil, nil] }, # limit, every :redis_rate_key => "api-requests".freeze, :header_status_prefix => "X-API-Limit".freeze, :env_to_api_key_proc => proc { |env| env["REMOTE_ADDR"] }, :limiter_app => proc do |env| headers = {"Content-Type" => "text/html"} resets_in = env['x-rack.rate-limiter.resets-at'] - Time.now.to_i body = "You are currently limited and will be reset in appx. %s seconds." % resets_in headers["Content-Length"] = body.length.to_s return 503, headers, [body] end } attr_accessor :per_user_limit, :reset_requests_every, :redis_rate_key, :override_limit_proc, :header_status_prefix, :limiter_app, :redis, :env_to_api_key_proc def initialize(app, opts = {}) @app = app # Setup options w/ defaults real_opts = DEFAULT_OPTIONS.merge(opts) real_opts.each_pair do |key, value| send(:"#{key}=", value) if respond_to?(:"#{key}=") end end def call(env) self.dup._call(env) end def _call(env) self.preload_request_info(env) env = self.env_with_limit_information(env) if self.under_request_limit?(env) count_request!(env) status, headers, body = @app.call(env) self.add_headers(headers) [status, headers, body] else status, headers, body = @limiter_app.call(env) self.add_headers(headers) [status, headers, body] end end def self.redis @@redis ||= Redis.new end def self.redis=(value) @@redis = value end protected def env_with_limit_information(env) env = env.dup env['x-rack.rate-limiter.current-count'] = self.current_request_count env['x-rack.rate-limiter.last-period'] = self.last_period env['x-rack.rate-limiter.resets-at'] = self.last_period + @reset_requests_every env['x-rack.rate-limiter.request-limit'] = @per_user_limit return env end def add_headers(header_hash) remaining_count = [(@per_user_limit - self.current_request_count), 0].max header_hash["#{@header_status_prefix}-Requests-Used"] = @current_request_count.to_s header_hash["#{@header_status_prefix}-Requests-Remaining"] = remaining_count.to_s header_hash["#{@header_status_prefix}-Period"] = self.last_period.to_s header_hash["#{@header_status_prefix}-Seconds-Remaining"] = ((self.last_period + @reset_requests_every) - Time.now.to_i).to_s header_hash["#{@header_status_prefix}-Is-Limited"] = (remaining_count > 0 ? "No" : "Yes") end def under_request_limit?(env) if self.last_period == self.current_period self.current_request_count < @per_user_limit else @current_request_count = 0 @last_period = current_period @redis.set(@count_env_key, "0") @redis.set(@period_env_key, @current_period) true end end def count_request!(env) @current_request_count = @redis.incr(@count_env_key) end def env_rate_limit_key(env, key = "count") "#{@redis_rate_key}:#{@env_to_api_key_proc.call(env)}:#{key}" end def preload_request_info(env) current_request_limit, current_reset_every = @override_limit_proc.call(env) # Since we dup, we can change ivars @reset_requests_every = current_reset_every if !current_reset_every.nil? @per_user_limit = current_request_limit if !current_request_limit.nil? @count_env_key = env_rate_limit_key(env) @period_env_key = env_rate_limit_key(env, "current-limit-period") @redis ||= self.class.redis end def last_period @last_period ||= begin raw_stored_period = @redis.get(@period_env_key).to_i (raw_stored_period / @reset_requests_every) * @reset_requests_every end end def current_period @current_period ||= (Time.now.to_i / @reset_requests_every) * @reset_requests_every end def current_request_count @current_request_count ||= @redis.get(@count_env_key).to_i end end API Version Mapper
About
In the same vein as Rack::URLMap or drnic’s probably versioned submission, API Version Mapper provides
a simple way to declare and namespace different api versions for a Rack app.
Usage
Due to the way it’s invoked, it is run as an api typically and not middleware (possibly using Rack::Cascade or the like to call it). For an example of actual code use, see example.ru in the code – you simple call Rack::APIVersionMapper#add_version with a number (e.g. 2) and a rack app to host that version of the api – SCRIPT_NAME and PATH_INFO are corrected as needed (so, PATH_INFO will be relative to that api in the app, e.g. /api/v1/users.json will show up with a SCRIPT_NAME of /api/v1 in the app and PATH_INFO of /users.json.
Unknown API versions 404 with a message, Unknown URLs simply 404 with an empty body (for apps like Rack::Cascade). See example.ru for more examples of usage
Code
require 'rack' # Released under MIT license, see tests / example.ru for usagemodule 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 endendrequire '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 endrequire '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 = 2end 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)Rack::LayoutWrapper
About
Rack::LayoutWrapper makes it super-easy to have shared layouts between a number of Rack applications – it’s as simple as using Rack::LayoutWrapper, setting the template dir option and then using the passed in layout object (as a part of the rack env).
Usage
For the best documentation, look at the attached tests.
In most cases, it’s as simple accessing env[‘x-rack.layout_wrapper’]. This returns the Rack::LayoutWrapper instance
and makes it possible to set the layout. The returned object has available_layouts – a list of layouts templates available.
If you specified a default template, env[‘x-rack.layout_wrapper’].render_layout = true will tell it to render the default template
whilst env[‘x-rack.layout_wrapper’].layout_name = ‘some-layout-name’ would tell it to use some-layout-name.
Lastly, it’s worth noting that you can use yield or content_for_layout in the layout template to render the contents (only once)
(see the tests).
Code
BEFORE TEMPLATE<%= content_for_layout %>AFTER TEMPLATEBEFORE TEMPLATE TWO= content_for_layoutAFTER TEMPLATE TWOBEFORE TEMPLATE THREE<%= yield %>AFTER TEMPLATE THREEBEFORE TEMPLATE FOUR= yieldAFTER TEMPLATE FOUR<%= @request.request_method %> to <%= @request.path_info %> with <%= @request.query_string %> - <%= content_for_layout %> <%= request.request_method %> to <%= request.path_info %> with <%= request.query_string %> - <%= content_for_layout %> require 'rack'# sudo gem install tilt --source http://gemcutter.org/require 'tilt' # A simple Rack middleware which makes it super easy# have a shared layout rendering system used across# different rails applications.## Examples:## use Rack::LayoutWrapper, :template_dir => "required-path-to-template",# :cache_listings => true, # Optional option to cache listings# :default => "some-default-layout-name" # Optional default layout name## Released under the MIT Licensemodule Rack class LayoutWrapper class BodyWrapper def initialize(before, original_body, after) @before = before @after = after @original_body = original_body end def each yield @before @original_body.each { |c| yield c } yield @after end end @@template_path_cache = {} PLACEHOLDER_CONTENTS = 'TEMPLATE-STUFF-GOES-HERE'.freeze PLACEHOLDER_ENV_KEY = 'x-rack.layout_wrapper'.freeze attr_accessor :template_dir, :default_layout def initialize(app, opts = {}) @app = app @render_layout = false @layouts = nil @layout_name = nil @template_dir = opts[:template_dir] @cache_listings = opts[:cache_listings] || true @default_layout = opts[:default] if @template_dir.to_s.strip == "" || !::File.directory?(@template_dir) raise ArgumentError, "You need to provide a valid :template_dir directory" end end def call(env) available_layouts if @layouts.nil? && @cache_listings dup._call(env) end def _call(env) env[PLACEHOLDER_ENV_KEY] = self status, headers, body = @app.call(env) @request = (env['rack.request'] || Rack::Request.new(env)) headers, body = wrap_with_layout(headers, body) if @render_layout [status, headers, body] end def render_layout? @render_layout end def layout_name=(value) @render_layout = !value.nil? @layout_name = value end def render_layout=(value) @render_layout = value end def layout_name @layout_name ||= @default_layout end def available_layouts @layouts ||= begin layouts = Dir[::File.join(@template_dir, "**", "*")].select { |i| ::File.file?(i) } layouts.map { |l| l.gsub(/^#{Regexp.escape(@template_dir)}\/(.*)\..*/, '\1') }.uniq end end def request @request end def full_template_path default = @@template_path_cache[layout_name] return default if default && ::File.exist?(default) @@template_path_cache[@layout_name] = Dir[::File.join(@template_dir, "#{@layout_name}.*")].select { |i| ::File.file?(i) }.first end protected def render_layout_parts template_path = full_template_path return "", "" if template_path.nil? || !::File.exist?(template_path) locals = {:content_for_layout => PLACEHOLDER_CONTENTS, :request => @request} template = Tilt.new(full_template_path).render(self, locals) { PLACEHOLDER_CONTENTS } before_template, after_template = template.split(PLACEHOLDER_CONTENTS, 2) before_template ||= "" after_template ||= "" return before_template, after_template end def wrap_with_layout(headers, body) header_hash = Rack::Utils::HeaderHash.new(headers) content_length = header_hash['Content-Length'].to_i before_layout, after_layout = render_layout_parts content_length += Rack::Utils.bytesize(before_layout) content_length += Rack::Utils.bytesize(after_layout) header_hash['Content-Length'] = content_length.to_s body_wrapper = BodyWrapper.new(before_layout, body, after_layout) return header_hash, body_wrapper end endendrequire 'rubygems'require 'test/unit'require 'redgreen' if RUBY_VERSION < '1.9'require 'rack'require 'erb'require 'haml'require File.join(File.dirname(__FILE__), "layout_wrapper") class APIVersionMapperTest < Test::Unit::TestCase def setup @example_app = proc do |env| req = Rack::Request.new(env) headers = { "Content-Type" => "text/html", "Content-Length" => "8" } env[Rack::LayoutWrapper::PLACEHOLDER_ENV_KEY].layout_name = req.GET["layout_name"] if req.GET["layout_name"] env[Rack::LayoutWrapper::PLACEHOLDER_ENV_KEY].render_layout = true if req.GET["render_layout"] [200, headers, ["FROM APP"]] end @app = Rack::LayoutWrapper.new(@example_app, :template_dir => File.join(File.dirname(__FILE__), "layouts")) end def test_no_layouts assert !@app.render_layout? get '/' assert_valid_response "FROM APP" end def test_simple_erb_layout get '/?layout_name=example_a' assert_valid_response "BEFORE TEMPLATE\nFROM APP\nAFTER TEMPLATE" end def test_simple_haml_layout get '/?layout_name=example_b' assert_valid_response "BEFORE TEMPLATE TWO\nFROM APP\nAFTER TEMPLATE TWO\n" end def test_yielded_erb_layout get '/?layout_name=example_c' assert_valid_response "BEFORE TEMPLATE THREE\nFROM APP\nAFTER TEMPLATE THREE" end def test_yielded_haml_layout get '/?layout_name=example_d' assert_valid_response "BEFORE TEMPLATE FOUR\nFROM APP\nAFTER TEMPLATE FOUR\n" end def test_default_layouts @app.default_layout = "example_a" get '/?render_layout=true' assert_valid_response "BEFORE TEMPLATE\nFROM APP\nAFTER TEMPLATE" # Now, override the default get '/?layout_name=example_c' assert_valid_response "BEFORE TEMPLATE THREE\nFROM APP\nAFTER TEMPLATE THREE" end def test_template_scope get '/?layout_name=example_e' assert_valid_response "GET to / with layout_name=example_e - FROM APP\n" end def test_template_request get '/?layout_name=example_f' assert_valid_response "GET to / with layout_name=example_f - FROM APP\n" end protected def get(path, env = {}) @response = Rack::MockRequest.new(@app).get(path, env) end def assert_valid_response(body) assert @response.ok?, "the response should be ok (was #{@response.status} instead)" content_length = Rack::Utils.bytesize(body) real_content_length = @response["Content-Length"].to_i assert_equal content_length, real_content_length, "Expected a Content-Length of #{content_length}, got #{real_content_length}" assert_equal body, @response.body, "Incorrect body in response" end end