├── .ruby-gemset ├── .ruby-version ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── grape-throttle-0.0.5.gem ├── grape-throttle.gemspec ├── lib ├── grape-throttle.rb └── grape │ ├── extensions │ └── throttle_extension.rb │ └── middleware │ └── throttle_middleware.rb └── spec ├── simple_api_spec.rb └── spec_helper.rb /.ruby-gemset: -------------------------------------------------------------------------------- 1 | grape-throttle -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.1.5 -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - "2.1.7" 4 | - "2.2.3" 5 | script: bundle exec rspec spec -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in grape-throttle.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | grape-throttle (0.0.5) 5 | grape (>= 0.10.0) 6 | redis (~> 3.2) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activesupport (4.2.5) 12 | i18n (~> 0.7) 13 | json (~> 1.7, >= 1.7.7) 14 | minitest (~> 5.1) 15 | thread_safe (~> 0.3, >= 0.3.4) 16 | tzinfo (~> 1.1) 17 | axiom-types (0.1.1) 18 | descendants_tracker (~> 0.0.4) 19 | ice_nine (~> 0.11.0) 20 | thread_safe (~> 0.3, >= 0.3.1) 21 | builder (3.2.2) 22 | coercible (1.0.0) 23 | descendants_tracker (~> 0.0.1) 24 | descendants_tracker (0.0.4) 25 | thread_safe (~> 0.3, >= 0.3.1) 26 | diff-lcs (1.2.5) 27 | equalizer (0.0.11) 28 | fakeredis (0.5.0) 29 | redis (~> 3.0) 30 | grape (0.13.0) 31 | activesupport 32 | builder 33 | hashie (>= 2.1.0) 34 | multi_json (>= 1.3.2) 35 | multi_xml (>= 0.5.2) 36 | rack (>= 1.3.0) 37 | rack-accept 38 | rack-mount 39 | virtus (>= 1.0.0) 40 | hashie (3.4.3) 41 | i18n (0.7.0) 42 | ice_nine (0.11.1) 43 | json (1.8.3) 44 | minitest (5.8.3) 45 | multi_json (1.11.2) 46 | multi_xml (0.5.5) 47 | rack (1.6.0) 48 | rack-accept (0.4.5) 49 | rack (>= 0.4) 50 | rack-mount (0.8.3) 51 | rack (>= 1.0.0) 52 | rack-test (0.6.2) 53 | rack (>= 1.0) 54 | redis (3.2.2) 55 | rspec (3.1.0) 56 | rspec-core (~> 3.1.0) 57 | rspec-expectations (~> 3.1.0) 58 | rspec-mocks (~> 3.1.0) 59 | rspec-core (3.1.7) 60 | rspec-support (~> 3.1.0) 61 | rspec-expectations (3.1.2) 62 | diff-lcs (>= 1.2.0, < 2.0) 63 | rspec-support (~> 3.1.0) 64 | rspec-mocks (3.1.3) 65 | rspec-support (~> 3.1.0) 66 | rspec-support (3.1.2) 67 | thread_safe (0.3.5) 68 | tzinfo (1.2.2) 69 | thread_safe (~> 0.1) 70 | virtus (1.0.5) 71 | axiom-types (~> 0.1) 72 | coercible (~> 1.0) 73 | descendants_tracker (~> 0.0, >= 0.0.3) 74 | equalizer (~> 0.0, >= 0.0.9) 75 | 76 | PLATFORMS 77 | ruby 78 | 79 | DEPENDENCIES 80 | bundler 81 | fakeredis (~> 0.5.0) 82 | grape-throttle! 83 | rack-test 84 | rspec (~> 3.0) 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Alejandro Wainzinger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Grape Throttle 2 | ============== 3 | 4 | [![Gem Version](https://badge.fury.io/rb/grape-throttle.svg)](https://badge.fury.io/rb/grape-throttle) 5 | [![Build Status](https://travis-ci.org/xevix/grape-throttle.svg)](https://travis-ci.org/xevix/grape-throttle) 6 | 7 | **Deprecation Warning** 8 | 9 | This gem is no longer being actively maintained. For similar throttle functionality see [rack-attack](https://github.com/kickstarter/rack-attack). Integration of this gem with rack-attack is a future consideration. 10 | 11 | **Description** 12 | 13 | The grape-throttle gem provides a simple endpoint-specific throttling mechanism for Grape. 14 | 15 | ## Requirements 16 | 17 | * Grape >= 0.10.0 18 | * Redis 19 | 20 | ## Usage 21 | 22 | ### Build and Install 23 | 24 | To use, just install the gem from RubyGems or via Bundler by requiring it in your Gemfile. 25 | 26 | ``` 27 | gem 'grape-throttle' 28 | ``` 29 | 30 | ### Middleware Setup 31 | 32 | Then in your Grape API, install the middleware which will do the throttling. At a minimum, it requires a Redis instance for caching as the `cache` parameter. 33 | 34 | **Simple Case** 35 | 36 | ```ruby 37 | use Grape::Middleware::ThrottleMiddleware, cache: Redis.new 38 | ``` 39 | 40 | In this simple case, you just set up the middleware, and pass it a Redis instance. 41 | 42 | **Advanced Case** 43 | 44 | ```ruby 45 | use Grape::Middleware::ThrottleMiddleware, cache: $redis, user_key: ->(env) do 46 | # Use the current_user's id as an identifier 47 | user = current_user 48 | user.nil? ? nil : user.id 49 | end 50 | ``` 51 | 52 | In this more advanced case, the Redis instance is in the global variable `$redis`. 53 | 54 | The `user_key` parameter is a function that can be used to determine a custom identifier for a user. This key is used to form the Redis key to identify this user uniquely. It defaults to the IP address. The `env` parameter given to the function is the Rack environment and can be used to determine information about the caller. 55 | 56 | **Logging** 57 | 58 | The gem will log errors to STDOUT by default. If you prefer a different logger you can use the `logger` option to pass in your own logger. 59 | 60 | ```ruby 61 | use Grape::Middleware::ThrottleMiddleware, cache: Redis.new, logger: Logger.new('my_custom_log.log') 62 | ``` 63 | 64 | ### Endpoint Usage 65 | 66 | This gem adds a `throttle` DSL-like method that can be used to throttle different endpoints differently. 67 | 68 | The `throttle` method takes a Hash of the period to throttle, and the maximum allowed hits. After the maximum, the middleware throws an error with Grape's `error!` function. 69 | 70 | Supported predefined periods are: `:hourly`, `:daily`, `:monthly`. 71 | 72 | Example: 73 | 74 | ```ruby 75 | class API < Grape::API 76 | resources :users do 77 | 78 | # Allow start of competition only every 10 minutes 79 | desc "Start competition" 80 | throttle period: 10.minutes, limit: 1 81 | params do 82 | requires :id, type: Integer, desc: "id" 83 | end 84 | post "/:id/competition" do 85 | User.find(params[:id]).start_competition 86 | end 87 | 88 | # 3 times a day max 89 | desc "Fetch a user" 90 | throttle daily: 3 91 | params do 92 | requires :id, type: Integer, desc: "id" 93 | end 94 | get "/:id" do 95 | User.find(params[:id]) 96 | end 97 | 98 | # Once a month or the user will go crazy 99 | desc "Poke a user" 100 | throttle monthly: 1 101 | params do 102 | requires :id, type: Integer, desc: "id" 103 | end 104 | post "/:id/poke" do 105 | User.find(params[:id]).poke 106 | end 107 | 108 | # No limit to the amount we can annoy users 109 | desc "Annoy a user" 110 | params do 111 | requires :id, type: Integer, desc: "id" 112 | end 113 | post "/:id/annoy" do 114 | User.find(params[:id]).annoy 115 | end 116 | end 117 | end 118 | ``` 119 | 120 | ## TODO 121 | 122 | * Custom error handling and error strings, status etc. 123 | * Allow use of something other than Redis for caching 124 | 125 | ## Thanks 126 | 127 | Thanks to the awesome Grape community, and to @dblock for all the help getting this thing going. 128 | -------------------------------------------------------------------------------- /grape-throttle-0.0.5.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xevix/grape-throttle/b48de012e685b6e3e433fe4fbbee131bc9be994d/grape-throttle-0.0.5.gem -------------------------------------------------------------------------------- /grape-throttle.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'grape-throttle' 3 | s.version = '0.0.5' 4 | s.platform = Gem::Platform::RUBY 5 | s.authors = ['Alejandro Wainzinger'] 6 | s.email = ['alejandro.wainzinger@gmail.com'] 7 | s.homepage = 'https://github.com/xevix/grape-throttle' 8 | s.summary = %q{A middleware for Grape to add endpoint-specific throttling.} 9 | s.description = %q{A middleware for Grape to add endpoint-specific throttling.} 10 | s.license = 'MIT' 11 | 12 | s.add_development_dependency 'rack-test' 13 | s.add_development_dependency 'rspec', '~> 3.0' 14 | s.add_development_dependency 'bundler' 15 | s.add_development_dependency 'fakeredis', '~> 0.5.0' 16 | 17 | s.add_runtime_dependency 'grape', '>= 0.10.0' 18 | s.add_runtime_dependency 'redis', '~>3.2' 19 | 20 | s.files = ["lib/grape-throttle.rb", "lib/grape/extensions/throttle_extension.rb", "lib/grape/middleware/throttle_middleware.rb"] 21 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 22 | s.require_paths = ['lib'] 23 | end 24 | -------------------------------------------------------------------------------- /lib/grape-throttle.rb: -------------------------------------------------------------------------------- 1 | require 'grape' 2 | require 'grape/extensions/throttle_extension' 3 | require 'logger' 4 | 5 | module Grape 6 | module Middleware 7 | autoload :ThrottleMiddleware, 'grape/middleware/throttle_middleware' 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/grape/extensions/throttle_extension.rb: -------------------------------------------------------------------------------- 1 | module Grape 2 | module Extensions 3 | module ThrottleExtension 4 | 5 | def throttle(options={}) 6 | route_setting :throttle, options 7 | options 8 | end 9 | 10 | Grape::API.extend self 11 | end 12 | 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/grape/middleware/throttle_middleware.rb: -------------------------------------------------------------------------------- 1 | module Grape 2 | module Middleware 3 | class ThrottleMiddleware < Grape::Middleware::Base 4 | COUNTER_START = 0 5 | def before 6 | endpoint = env['api.endpoint'] 7 | logger = options[:logger] || Logger.new(STDOUT) 8 | return unless throttle_options = endpoint.route_setting(:throttle) 9 | 10 | if limit = throttle_options[:hourly] 11 | period = 1.hour 12 | elsif limit = throttle_options[:daily] 13 | period = 1.day 14 | elsif limit = throttle_options[:monthly] 15 | period = 1.month 16 | elsif period = throttle_options[:period] 17 | limit = throttle_options[:limit] 18 | end 19 | if limit.nil? || period.nil? 20 | raise ArgumentError.new('Please set a period and limit (see documentation)') 21 | end 22 | 23 | user_key = options[:user_key] 24 | user_value = nil 25 | user_value = user_key.call(env) unless user_key.nil? 26 | user_value ||= "ip:#{env['REMOTE_ADDR']}" 27 | 28 | r = endpoint.routes.first 29 | rate_key = "#{r.route_method}:#{r.route_path}:#{user_value}" 30 | 31 | redis = options[:cache] 32 | begin 33 | redis.ping 34 | current = redis.get(rate_key).to_i 35 | if !current.nil? && current >= limit 36 | endpoint.error!("too many requests, please try again later", 429) 37 | else 38 | redis.multi do 39 | # Set the value of the key to COUNTER_START if the key does not already exist and 40 | # set the expiry only on creation to avoid clobbering it later 41 | redis.set(rate_key, COUNTER_START, { :nx => true, :ex => period.to_i } ) 42 | redis.incr(rate_key) 43 | end 44 | end 45 | 46 | rescue Exception => e 47 | logger.warn(e.message) 48 | end 49 | 50 | end 51 | 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/simple_api_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "ThrottleHelper" do 4 | subject do 5 | Class.new(Grape::API) do 6 | use Grape::Middleware::ThrottleMiddleware, cache: Redis.new 7 | 8 | throttle daily: 3 9 | get('/throttle') do 10 | "step on it" 11 | end 12 | 13 | get('/no-throttle') do 14 | "step on it" 15 | end 16 | 17 | throttle period: 10.minutes, limit: 3 18 | get('/throttle-custom-period') do 19 | "step on it" 20 | end 21 | 22 | throttle 23 | get('/wrong-configuration') do 24 | "step on it" 25 | end 26 | 27 | throttle period: 2.seconds, limit: 3 28 | get('/really-short-throttle') do 29 | "step on it" 30 | end 31 | end 32 | end 33 | 34 | def app 35 | subject 36 | end 37 | 38 | describe "#throttle" do 39 | it "is not throttled within the rate limit" do 40 | 3.times { get "/throttle" } 41 | expect(last_response.status).to eq(200) 42 | end 43 | 44 | it "is throttled beyond the rate limit" do 45 | 4.times { get "/throttle" } 46 | expect(last_response.status).to eq(429) 47 | end 48 | 49 | describe "with custom period" do 50 | 51 | it "is not throttled within the rate limit" do 52 | 3.times { get "/throttle-custom-period" } 53 | expect(last_response.status).to eq(200) 54 | end 55 | 56 | it "is throttled beyond the rate limit" do 57 | 4.times { get "/throttle-custom-period" } 58 | expect(last_response.status).to eq(429) 59 | end 60 | 61 | end 62 | 63 | it "throws an error if period or limit is missing" do 64 | expect { get("wrong-configuration") }.to raise_exception 65 | end 66 | 67 | it "only throttles if explicitly specified" do 68 | expect do 69 | 10.times { get "/no-throttle" } 70 | end.not_to raise_exception 71 | expect(last_response.status).to eq(200) 72 | end 73 | 74 | end 75 | 76 | describe "requests just below the period" do 77 | it "do not get throttled by the rate limit" do 78 | 4.times do 79 | get "/really-short-throttle" 80 | sleep 1 81 | end 82 | 83 | expect(last_response.status).to eq(200) 84 | end 85 | end 86 | 87 | describe 'Redis down' do 88 | before do 89 | expect_any_instance_of(Redis).to receive(:ping){ raise Exception } 90 | allow($stdout).to receive(:write) 91 | end 92 | 93 | it 'should work when redis is down' do 94 | get "/throttle" 95 | expect(last_response.status).to eq(200) 96 | end 97 | 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'grape-throttle' 2 | require 'fakeredis' 3 | 4 | require 'rubygems' 5 | require 'bundler' 6 | 7 | Bundler.setup :default, :test 8 | 9 | require 'rack/test' 10 | 11 | RSpec.configure do |config| 12 | require 'rspec/expectations' 13 | config.include RSpec::Matchers 14 | config.mock_with :rspec 15 | config.include Rack::Test::Methods 16 | end 17 | --------------------------------------------------------------------------------