rack-pooledthrottle 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +64 -0
- data/Rakefile +2 -0
- data/lib/rack/pooledthrottle.rb +11 -0
- data/lib/rack/pooledthrottle/memcached_throttle.rb +13 -0
- data/lib/rack/pooledthrottle/throttle.rb +107 -0
- data/lib/rack/pooledthrottle/version.rb +5 -0
- data/rack-pooledthrottle.gemspec +29 -0
- data/spec/memecached_spec.rb +65 -0
- data/spec/spec_helper.rb +44 -0
- metadata +156 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: f17681ae19c501bb8f4194f2d7e9389f3aae51ee
|
4
|
+
data.tar.gz: 6349f697567a2cbc6f3a04248e710dbd36c253eb
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 2431de24d6e9c40b2f5e0b2b0f5bfdca57d5ae38b5b99e3c3cec3698c258f3cb56d52efbd30472071ca568046b5c5dcbc1784fff2208880ec31eb080eb8ecf1d
|
7
|
+
data.tar.gz: 68eec81e9a722fc99bcbdc66a3cec630ed20500188854bea77ca48f77c83b46a950958b2756a0061f97569ccb4908e5ddbb5cf8e57f3307ad9772552251309f7
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2015 Scott Watermasysk
|
2
|
+
|
3
|
+
MIT License
|
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
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
# Rack::Pooledthrottle
|
2
|
+
|
3
|
+
Rack::Pooledthrottle is middleware which provides rate-limiting of incoming HTTP requests to Rack applications. You should be able to use it with any Ruby web framework (I have only tested Sinatra and Rails).
|
4
|
+
|
5
|
+
I initially tried to work it into [Rack Throttle](https://github.com/bendiken/rack-throttle), but because of Rack Throttle's
|
6
|
+
many backend options I thought it would be too complicated.
|
7
|
+
|
8
|
+
So how is it different?
|
9
|
+
|
10
|
+
1. It uses a pool (via [ConnectionPool](https://github.com/mperham/connection_pool)) of connections instead of creating one on each request
|
11
|
+
1. It uses a sliding TTL for tracking. This means if you limit an IP to 10 requests every hour and the first request comes in at 1:30 the user can make up to 9 more requests until 2:30.
|
12
|
+
1. The TTL is set on middleware declaration. No other subclasses.
|
13
|
+
1. Database support is limited to Memcached (and eventually Redis)
|
14
|
+
|
15
|
+
|
16
|
+
## Installation
|
17
|
+
|
18
|
+
Add this line to your application's Gemfile:
|
19
|
+
|
20
|
+
```ruby
|
21
|
+
gem 'rack-pooledthrottle'
|
22
|
+
```
|
23
|
+
|
24
|
+
And then execute:
|
25
|
+
|
26
|
+
$ bundle
|
27
|
+
|
28
|
+
Or install it yourself as:
|
29
|
+
|
30
|
+
$ gem install rack-pooledthrottle
|
31
|
+
|
32
|
+
## Usage
|
33
|
+
|
34
|
+
### Adding throttling to a Rails application
|
35
|
+
|
36
|
+
require 'rack/pooledthrottle'
|
37
|
+
require 'dalli' #Dalli is not required. You must add it to your gem file if you want to use it.
|
38
|
+
$mc_pool ||= ConnectionPool.new(size: 5) {Dalli::Client.new}
|
39
|
+
|
40
|
+
class Application < Rails::Application
|
41
|
+
config.middleware.use Rack::Throttle::Interval, max: 10, ttl: 3600, pool: $mc_pool
|
42
|
+
end
|
43
|
+
|
44
|
+
### Adding throttling to a Sinatra application
|
45
|
+
|
46
|
+
require 'sinatra'
|
47
|
+
require 'rack/pooledthrottle'
|
48
|
+
|
49
|
+
use Rack::Throttle::Interval, max: 5, ttl: 60, pool: $mc_pool #see above for pool
|
50
|
+
|
51
|
+
get('/hello') { "Hello, world!\n" }
|
52
|
+
|
53
|
+
## HAT TIP
|
54
|
+
|
55
|
+
I just want to make it super clear that a vast majority of this code was based on the excellent work of [Rack Throttle](https://github.com/bendiken/rack-throttle).
|
56
|
+
|
57
|
+
|
58
|
+
## Contributing
|
59
|
+
|
60
|
+
1. Fork it ( https://github.com/[my-github-username]/rack-pooledthrottle/fork )
|
61
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
62
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
63
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
64
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
module Rack
|
2
|
+
module PooledThrottle
|
3
|
+
class Throttle
|
4
|
+
attr_reader :app, :options
|
5
|
+
|
6
|
+
def initialize(app, options = {})
|
7
|
+
@app, @options = app, options
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(env)
|
11
|
+
request = Rack::Request.new(env)
|
12
|
+
allowed?(request) ? app.call(env) : rate_limit_exceeded(request)
|
13
|
+
end
|
14
|
+
|
15
|
+
protected
|
16
|
+
|
17
|
+
def allowed?(request)
|
18
|
+
case
|
19
|
+
when whitelisted?(request) then true
|
20
|
+
when blacklisted?(request) then false
|
21
|
+
else
|
22
|
+
query_cache?(request)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def query_cache?(request)
|
27
|
+
false
|
28
|
+
end
|
29
|
+
|
30
|
+
def whitelisted?(request)
|
31
|
+
false
|
32
|
+
end
|
33
|
+
|
34
|
+
def blacklisted?(request)
|
35
|
+
false
|
36
|
+
end
|
37
|
+
|
38
|
+
def cache_key(request)
|
39
|
+
"#{namespace}:#{key_prefix}#{client_identifier(request)}"
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
##
|
44
|
+
# @param [Rack::Request] request
|
45
|
+
# @return [String]
|
46
|
+
def client_identifier(request)
|
47
|
+
if cio = options[:client_identifier]
|
48
|
+
cio.call(request)
|
49
|
+
else
|
50
|
+
request.ip.to_s
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def namespace
|
55
|
+
options[:namespace] || 'rpt'
|
56
|
+
end
|
57
|
+
|
58
|
+
def key_prefix
|
59
|
+
options[:key_prefix]
|
60
|
+
end
|
61
|
+
|
62
|
+
def pool
|
63
|
+
options[:pool]
|
64
|
+
end
|
65
|
+
|
66
|
+
def max
|
67
|
+
(options[:max] || 10).to_i
|
68
|
+
end
|
69
|
+
|
70
|
+
def ttl
|
71
|
+
(options[:ttl] || 60).to_i
|
72
|
+
end
|
73
|
+
|
74
|
+
##
|
75
|
+
# Outputs a `Rate Limit Exceeded` error.
|
76
|
+
#
|
77
|
+
# @return [Array(Integer, Hash, #each)]
|
78
|
+
def rate_limit_exceeded(request)
|
79
|
+
options[:rate_limit_exceeded_callback].call(request) if options[:rate_limit_exceeded_callback]
|
80
|
+
headers = respond_to?(:retry_after) ? {'Retry-After' => retry_after.to_f.ceil.to_s} : {}
|
81
|
+
http_error(options[:code] || 403, options[:message] || 'Rate Limit Exceeded', headers)
|
82
|
+
end
|
83
|
+
|
84
|
+
##
|
85
|
+
# Outputs an HTTP `4xx` or `5xx` response.
|
86
|
+
#
|
87
|
+
# @param [Integer] code
|
88
|
+
# @param [String, #to_s] message
|
89
|
+
# @param [Hash{String => String}] headers
|
90
|
+
# @return [Array(Integer, Hash, #each)]
|
91
|
+
def http_error(code, message = nil, headers = {})
|
92
|
+
[code, {'Content-Type' => 'text/plain; charset=utf-8'}.merge(headers),
|
93
|
+
[http_status(code), (message.nil? ? "\n" : " (#{message})\n")]]
|
94
|
+
end
|
95
|
+
|
96
|
+
##
|
97
|
+
# Returns the standard HTTP status message for the given status `code`.
|
98
|
+
#
|
99
|
+
# @param [Integer] code
|
100
|
+
# @return [String]
|
101
|
+
def http_status(code)
|
102
|
+
[code, Rack::Utils::HTTP_STATUS_CODES[code]].join(' ')
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'rack/pooledthrottle/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "rack-pooledthrottle"
|
8
|
+
spec.version = Rack::Pooledthrottle::VERSION
|
9
|
+
spec.authors = ["Scott Watermasysk"]
|
10
|
+
spec.email = ["scottwater@gmail.com"]
|
11
|
+
spec.summary = %q{Throttle HTTP requests.}
|
12
|
+
spec.description = %q{Throttle HTTP requests using a connection pool for all database connections}
|
13
|
+
spec.homepage = ""
|
14
|
+
spec.license = "Public Domain"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_runtime_dependency 'rack', '~> 1'
|
22
|
+
spec.add_runtime_dependency 'connection_pool', "~> 2.2"
|
23
|
+
spec.add_development_dependency "bundler", "~> 1.7"
|
24
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
25
|
+
spec.add_development_dependency "rspec", "~> 3.3"
|
26
|
+
spec.add_development_dependency 'rack-test', '0.6.3'
|
27
|
+
spec.add_development_dependency 'dalli', '~> 2.7'
|
28
|
+
|
29
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'dalli'
|
3
|
+
|
4
|
+
describe Rack::PooledThrottle::MemcachedThrottle do
|
5
|
+
include Rack::Test::Methods
|
6
|
+
|
7
|
+
before(:each) do
|
8
|
+
pool.with{|m| m.flush}
|
9
|
+
@options = {
|
10
|
+
pool: pool,
|
11
|
+
max: 3,
|
12
|
+
ttl: 30
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
def pool
|
17
|
+
@pool ||= ConnectionPool.new(size: 1) do
|
18
|
+
Dalli::Client.new((ENV["MEMCACHED_SERVERS"] || "127.0.0.1:11211").split(",").map{|s| s.strip})
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def app
|
23
|
+
@app ||= Rack::PooledThrottle::MemcachedThrottle.new(example_target_app, @options)
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'expect a passing message' do
|
27
|
+
get '/foo'
|
28
|
+
expect(last_response.body).to show_allowed_response
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'expect a passing message' do
|
32
|
+
4.times {get '/foo'}
|
33
|
+
expect(last_response.body).to show_throttled_response
|
34
|
+
end
|
35
|
+
|
36
|
+
it "should return true if whitelisted" do
|
37
|
+
allow(app).to receive(:whitelisted?).and_return(true)
|
38
|
+
4.times {get "/foo"}
|
39
|
+
expect(last_response.body).to show_allowed_response
|
40
|
+
end
|
41
|
+
|
42
|
+
it "should return true if blacklisted" do
|
43
|
+
allow(app).to receive(:blacklisted?).and_return(true)
|
44
|
+
get "/foo"
|
45
|
+
expect(last_response.body).to show_throttled_response
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
it 'should allow the client_identifier to be overriden and pass' do
|
50
|
+
@options[:max] = 2
|
51
|
+
@options[:client_identifier] = ->(request){request.params['email']}
|
52
|
+
2.times {get('/foo', email: 'scottwater@gmail.com')}
|
53
|
+
2.times {get '/foo', email: 'scott@kickofflabs.com'}
|
54
|
+
expect(last_response.body).to show_allowed_response
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'should allow the client_identifier to be overriden and fail' do
|
58
|
+
@options[:max] = 2
|
59
|
+
@options[:client_identifier] = ->(request){request.params['email']}
|
60
|
+
2.times {get('/foo', email: 'scottwater@gmail.com')}
|
61
|
+
3.times {get '/foo', email: 'scott@kickofflabs.com'}
|
62
|
+
expect(last_response.body).to show_throttled_response
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require "rack/test"
|
2
|
+
require "rack/pooledthrottle"
|
3
|
+
|
4
|
+
def example_target_app
|
5
|
+
target_app = Object.new
|
6
|
+
allow(target_app).to receive_messages(call: [200, {}, "Example App Body"])
|
7
|
+
target_app
|
8
|
+
end
|
9
|
+
|
10
|
+
RSpec::Matchers.define :show_allowed_response do
|
11
|
+
match do |body|
|
12
|
+
body.include?("Example App Body")
|
13
|
+
end
|
14
|
+
|
15
|
+
failure_message do
|
16
|
+
"expected response to show the allowed response"
|
17
|
+
end
|
18
|
+
|
19
|
+
failure_message_when_negated do
|
20
|
+
"expected response not to show the allowed response"
|
21
|
+
end
|
22
|
+
|
23
|
+
description do
|
24
|
+
"expected the allowed response"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
RSpec::Matchers.define :show_throttled_response do
|
29
|
+
match do |body|
|
30
|
+
body.include?("Rate Limit Exceeded")
|
31
|
+
end
|
32
|
+
|
33
|
+
failure_message do
|
34
|
+
"expected response to show the throttled response"
|
35
|
+
end
|
36
|
+
|
37
|
+
failure_message_when_negated do
|
38
|
+
"expected response not to show the throttled response"
|
39
|
+
end
|
40
|
+
|
41
|
+
description do
|
42
|
+
"expected the throttled response"
|
43
|
+
end
|
44
|
+
end
|
metadata
ADDED
@@ -0,0 +1,156 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rack-pooledthrottle
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Scott Watermasysk
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-09-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rack
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: connection_pool
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '2.2'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2.2'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bundler
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.7'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.7'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '10.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '10.0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.3'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.3'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rack-test
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - '='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 0.6.3
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - '='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 0.6.3
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: dalli
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '2.7'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '2.7'
|
111
|
+
description: Throttle HTTP requests using a connection pool for all database connections
|
112
|
+
email:
|
113
|
+
- scottwater@gmail.com
|
114
|
+
executables: []
|
115
|
+
extensions: []
|
116
|
+
extra_rdoc_files: []
|
117
|
+
files:
|
118
|
+
- ".gitignore"
|
119
|
+
- Gemfile
|
120
|
+
- LICENSE.txt
|
121
|
+
- README.md
|
122
|
+
- Rakefile
|
123
|
+
- lib/rack/pooledthrottle.rb
|
124
|
+
- lib/rack/pooledthrottle/memcached_throttle.rb
|
125
|
+
- lib/rack/pooledthrottle/throttle.rb
|
126
|
+
- lib/rack/pooledthrottle/version.rb
|
127
|
+
- rack-pooledthrottle.gemspec
|
128
|
+
- spec/memecached_spec.rb
|
129
|
+
- spec/spec_helper.rb
|
130
|
+
homepage: ''
|
131
|
+
licenses:
|
132
|
+
- Public Domain
|
133
|
+
metadata: {}
|
134
|
+
post_install_message:
|
135
|
+
rdoc_options: []
|
136
|
+
require_paths:
|
137
|
+
- lib
|
138
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
139
|
+
requirements:
|
140
|
+
- - ">="
|
141
|
+
- !ruby/object:Gem::Version
|
142
|
+
version: '0'
|
143
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
144
|
+
requirements:
|
145
|
+
- - ">="
|
146
|
+
- !ruby/object:Gem::Version
|
147
|
+
version: '0'
|
148
|
+
requirements: []
|
149
|
+
rubyforge_project:
|
150
|
+
rubygems_version: 2.2.2
|
151
|
+
signing_key:
|
152
|
+
specification_version: 4
|
153
|
+
summary: Throttle HTTP requests.
|
154
|
+
test_files:
|
155
|
+
- spec/memecached_spec.rb
|
156
|
+
- spec/spec_helper.rb
|