garner 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE.md +22 -0
- data/README.md +114 -0
- data/lib/garner/cache/object_identity.rb +214 -0
- data/lib/garner/config.rb +101 -0
- data/lib/garner/middleware/base.rb +47 -0
- data/lib/garner/middleware/cache/bust.rb +20 -0
- data/lib/garner/mixins/grape_cache.rb +104 -0
- data/lib/garner/mixins/mongoid_document.rb +49 -0
- data/lib/garner/objects/etag.rb +20 -0
- data/lib/garner/strategies/cache/expiration_strategy.rb +16 -0
- data/lib/garner/strategies/keys/caller_strategy.rb +29 -0
- data/lib/garner/strategies/keys/request_get_strategy.rb +22 -0
- data/lib/garner/strategies/keys/request_path_strategy.rb +21 -0
- data/lib/garner/strategies/keys/version_strategy.rb +29 -0
- data/lib/garner/version.rb +5 -0
- data/lib/garner.rb +22 -0
- metadata +223 -0
data/LICENSE.md
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2012 Art.sy, Frank Macreery, Daniel Doubrovkine & Contributors
|
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,114 @@
|
|
1
|
+
Garner [![Build Status](https://secure.travis-ci.org/dblock/garner.png)](http://travis-ci.org/dblock/garner)
|
2
|
+
======
|
3
|
+
|
4
|
+
Garner is a practical Rack-based cache implementation for RESTful APIs with support for HTTP 304 Not Modified based on time and ETags, model and instance binding and hierarchical invalidation. Garner is currently targeted at [Grape](https://github.com/intridea/grape), other systems may need some work.
|
5
|
+
|
6
|
+
Usage
|
7
|
+
-----
|
8
|
+
|
9
|
+
Add Garner to Gemfile with `gem "garner"` and run `bundle install`. Include the Garner mixin into your API. Currently Grape is supported out of the box. It's also recommended to prevent clients from caching dynamic data by default using the `Garner::Middleware::Cache::Bust` middleware. See below for a detailed explanation.
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
class API < Grape::API
|
13
|
+
use Garner::Middleware::Cache::Bust
|
14
|
+
helpers Garner::Mixins::Grape::Cache
|
15
|
+
end
|
16
|
+
```
|
17
|
+
|
18
|
+
To cache a value, invoke `cache` from within your API. Without any parameters it generates a key based on the source code location, request parameters and path, and stores the value in the cache configured as `Garner.config.cache`. The cache is automatically `Rails.cache` when mounted in Rails and an instance of `ActiveSupport::Cache::MemoryStore` otherwise.
|
19
|
+
|
20
|
+
``` ruby
|
21
|
+
get "/" do
|
22
|
+
cache do
|
23
|
+
{ :counter => 42 }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
```
|
27
|
+
|
28
|
+
To enable support for the date-based `If-Modified-Since` and the ETag-based `If-None-Match`, use `cache_or_304`. If the data hasn't changed, the API will return `304 Not Modified` without a cache miss. For example, if the inside of a cached block is a database query, it will not be executed the second time. This is possible because Garner stores an entry for every cache binding with the last-modified timestamp and ETag.
|
29
|
+
|
30
|
+
``` ruby
|
31
|
+
get "/" do
|
32
|
+
cache_or_304 do
|
33
|
+
{ :counter => 42 }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
```
|
37
|
+
|
38
|
+
The cached value can also be bound to other models. For example, if a user has an address that may or may not change when the user is saved, you will want the cached address to be invalidated every time the user record changes.
|
39
|
+
|
40
|
+
``` ruby
|
41
|
+
get "/me/address" do
|
42
|
+
cache_or_304(:bind => [ User, current_user.id ]) do
|
43
|
+
current_user.address
|
44
|
+
end
|
45
|
+
end
|
46
|
+
```
|
47
|
+
|
48
|
+
Binding Strategies
|
49
|
+
------------------
|
50
|
+
|
51
|
+
The binding parameter can be an object, class, array of objects, or array of classes on which to bind the validity of the cached result contained in the subsequent block. If no bind argument is specified, the subsequent block result will remain valid until it expires due to natural causes (e.g., passage of default memcached expiry, or memcached overflow). Here are some examples of how to use the bind option.
|
52
|
+
|
53
|
+
* `bind: { klass: Widget, object: { id: params[:id] } }` will cause the subsequent block result to be invalidated on any change to the `Widget` object whose `id` attribute equals `params[:id]`.
|
54
|
+
* `bind: { klass: User, object: { id: current_user.id } }` will cause the subsequent block result to be invalidated on any change to the `User` object whose `id` attribute equals `current_user.id`. This is one way to bind a cache result to any change in the current user.
|
55
|
+
* `bind: { klass: Widget }` will cause the subsequent block result to be invalidated on any change to any object of class `Widget`. This is the appropriate strategy for index paths like `/widgets`.
|
56
|
+
* `bind: [{ klass: Widget }, { klass: User, object: { id: current_user.id } }]` will cause the subsequent block result to be invalidated on any change to either the current user, or any object of class `Widget`.
|
57
|
+
|
58
|
+
Bind supports some nice shorthands.
|
59
|
+
|
60
|
+
* `bind: [Widget]` is shorthand for `bind: { klass: Widget }`
|
61
|
+
* `bind: [Widget, params[:id]]` is shorthand for `bind: { klass: Widget, object: { id: params[:slug] } }`
|
62
|
+
* `bind: [User, { id: current_user.id }]` is shorthand for `bind: { klass: User, object: { id: current_user.id } }`
|
63
|
+
* `bind: [[Widget], [User, { id: current_user.id }]]` is shorthand for `bind: [{ klass: Widget }, { klass: User, object: { id: current_user.id } }]`
|
64
|
+
|
65
|
+
Invalidation
|
66
|
+
------------
|
67
|
+
|
68
|
+
You must take care of data invalidation on save. Garner currently includes a mixin with support for [Mongoid](https://github.com/mongoid/mongoid). Extend `Mongoid::Document` as follows (eg. in `config/initializers/mongoid_document.rb`).
|
69
|
+
|
70
|
+
``` ruby
|
71
|
+
module Mongoid
|
72
|
+
module Document
|
73
|
+
include Garner::Mixins::Mongoid::Document
|
74
|
+
end
|
75
|
+
end
|
76
|
+
```
|
77
|
+
|
78
|
+
Please contribute other invalidation mixins.
|
79
|
+
|
80
|
+
Configuration
|
81
|
+
-------------
|
82
|
+
|
83
|
+
By default `Garner` will use an instance of `ActiveSupport::Cache::MemoryStore` in a non-Rails and `Rails.cache` in a Rails environment. You can configure it to use any other cache store.
|
84
|
+
|
85
|
+
``` ruby
|
86
|
+
Garner.configure do |config|
|
87
|
+
config.cache = ActiveSupport::Cache::FileStore.new
|
88
|
+
end
|
89
|
+
```
|
90
|
+
|
91
|
+
Preventing Clients from Caching Dynamic Data
|
92
|
+
--------------------------------------------
|
93
|
+
|
94
|
+
Generally, dynamic data cannot have a well-defined expiration time. Therefore, we must tell the client not to cache it. This can be accomplished using the `Garner::Middleware::Cache::Bust` middleware, executed after any API call. The middleware adds a `Cache-Control` and an `Expires` header.
|
95
|
+
|
96
|
+
```
|
97
|
+
Cache-Control: private, max-age=0, must-revalidate
|
98
|
+
Expires: Fri, 01 Jan 1990 00:00:00 GMT
|
99
|
+
```
|
100
|
+
|
101
|
+
The `private` option of the `Cache-Control` header instructs the client that it is allowed to store data in a private cache (unnecessary, but is known to work around overzealous cache implementations), `max-age` that it must check with the server every time it needs this data and `must-revalidate` prevents gateways from returning a response if your API server is unreachable. An additional `Expires` header will make double-sure the entire request expires immediately.
|
102
|
+
|
103
|
+
Contributing
|
104
|
+
------------
|
105
|
+
|
106
|
+
Fork the project. Make your feature addition or bug fix with tests. Send a pull request. Bonus points for topic branches.
|
107
|
+
|
108
|
+
Copyright and License
|
109
|
+
---------------------
|
110
|
+
|
111
|
+
MIT License, see [LICENSE](https://github.com/dblock/garner/blob/master/LICENSE.md) for details.
|
112
|
+
|
113
|
+
(c) 2012 [Art.sy Inc.](http://artsy.github.com), [Frank Macreery](https://github.com/macreery), [Daniel Doubrovkine](https://github.com/dblock) and [Contributors](https://github.com/dblock/garner/blob/master/CHANGELOG.md)
|
114
|
+
|
@@ -0,0 +1,214 @@
|
|
1
|
+
module Garner
|
2
|
+
module Cache
|
3
|
+
#
|
4
|
+
# A cache that uses an object identity binding strategy.
|
5
|
+
#
|
6
|
+
# Allows some flexibility in how caller binds objects in cache.
|
7
|
+
# The binding can be an object, class, array of objects, or array of classes
|
8
|
+
# on which to bind the validity of the cached result contained in the subsequent
|
9
|
+
# block.
|
10
|
+
#
|
11
|
+
# @example `bind: { klass: Widget, object: { id: params[:id] } }` will cause a cached instance to be
|
12
|
+
# invalidated on any change to the `Widget` object whose slug attribute equals `params[:id]`
|
13
|
+
#
|
14
|
+
# @example `bind: { klass: User, object: { id: current_user.id } }` will cause a cached instance to be
|
15
|
+
# invalidated on any change to the `User` object whose id attribute equals current_user.id.
|
16
|
+
# This is one way to bind a cache result to any change in the current user.
|
17
|
+
#
|
18
|
+
# @example `bind: { klass: Widget }` will cause the cached instance to be invalidated on any change to
|
19
|
+
# any object of class Widget. This is the appropriate strategy for index paths like /widgets.
|
20
|
+
#
|
21
|
+
# @example `bind: [{ klass: Widget }, { klass: User, object: { id: current_user.id } }]` will cause a
|
22
|
+
# cached instance to be invalidated on any change to either the current user, or any object of class Widget.
|
23
|
+
#
|
24
|
+
# @example `bind: [Artwork]` is shorthand for `bind: { klass: Artwork }`
|
25
|
+
#
|
26
|
+
# @example `bind: [Artwork, params[:id]]` is shorthand for `bind: { klass: Artwork, object: { id: params[:id] } }`
|
27
|
+
#
|
28
|
+
# @example `bind: [User, { id: current_user.id }] is shorthand for `bind: { klass: User, object: { id: current_user.id } }`
|
29
|
+
#
|
30
|
+
# @example `bind: [[Artwork], [User, { id: current_user.id }]]` is shorthand for
|
31
|
+
# `bind: [{ klass: Artwork }, { klass: User, object: { id: current_user.id } }]`
|
32
|
+
#
|
33
|
+
module ObjectIdentity
|
34
|
+
|
35
|
+
IDENTITY_FIELDS = [ :id ]
|
36
|
+
|
37
|
+
KEY_STRATEGIES = [
|
38
|
+
Garner::Strategies::Keys::Caller,
|
39
|
+
Garner::Strategies::Keys::Version,
|
40
|
+
Garner::Strategies::Keys::RequestPath,
|
41
|
+
Garner::Strategies::Keys::RequestGet
|
42
|
+
]
|
43
|
+
|
44
|
+
CACHE_STRATEGIES = [
|
45
|
+
Garner::Strategies::Cache::Expiration
|
46
|
+
]
|
47
|
+
|
48
|
+
class << self
|
49
|
+
|
50
|
+
# cache the result of an executable block
|
51
|
+
def cache(binding = nil, context = {})
|
52
|
+
# apply cache strategies
|
53
|
+
cache_options = cache_options(context)
|
54
|
+
CACHE_STRATEGIES.each do |strategy|
|
55
|
+
cache_options = strategy.apply(cache_options)
|
56
|
+
end
|
57
|
+
key = key(binding, key_context(context))
|
58
|
+
result = Garner.config.cache.fetch(key, cache_options) do
|
59
|
+
object = yield
|
60
|
+
reset_cache_metadata(key, object)
|
61
|
+
object
|
62
|
+
end
|
63
|
+
Garner.config.cache.delete(key) unless result
|
64
|
+
result
|
65
|
+
end
|
66
|
+
|
67
|
+
# invalidate an object that has been cached
|
68
|
+
def invalidate(* args)
|
69
|
+
options = index(*args)
|
70
|
+
reset_key_prefix_for(options[:klass], options[:object])
|
71
|
+
reset_key_prefix_for(options[:klass]) if options[:object]
|
72
|
+
end
|
73
|
+
|
74
|
+
# metadata for cached objects:
|
75
|
+
# :etag - Unique hash of object content
|
76
|
+
# :last_modified - Timestamp of last modification event
|
77
|
+
def cache_metadata(binding, context = {})
|
78
|
+
key = key(binding, key_context(context))
|
79
|
+
Garner.config.cache.read(meta(key))
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
# applied cache options
|
85
|
+
def cache_options(context)
|
86
|
+
context[:cache_options] || {}
|
87
|
+
end
|
88
|
+
|
89
|
+
# applied key context
|
90
|
+
def key_context(context)
|
91
|
+
new_context = {}
|
92
|
+
context ||= {}
|
93
|
+
KEY_STRATEGIES.each do |strategy|
|
94
|
+
new_context = strategy.apply(new_context, context)
|
95
|
+
end
|
96
|
+
new_context
|
97
|
+
end
|
98
|
+
|
99
|
+
def reset_key_prefix_for(klass, object = nil)
|
100
|
+
Garner.config.cache.write(
|
101
|
+
index_string_for(klass, object),
|
102
|
+
new_key_prefix_for(klass, object),
|
103
|
+
{}
|
104
|
+
)
|
105
|
+
end
|
106
|
+
|
107
|
+
def reset_cache_metadata(key, object)
|
108
|
+
return unless object
|
109
|
+
metadata = {
|
110
|
+
:etag => Garner::Objects::ETag.from(object),
|
111
|
+
:last_modified => Time.now
|
112
|
+
}
|
113
|
+
meta_key = meta(key)
|
114
|
+
Garner.config.cache.write(meta_key, metadata)
|
115
|
+
end
|
116
|
+
|
117
|
+
def new_key_prefix_for(klass, object = nil)
|
118
|
+
Digest::MD5.hexdigest("#{klass}/#{object || "*"}:#{new_key_postfix}")
|
119
|
+
end
|
120
|
+
|
121
|
+
# Generate a key in the Klass/id format.
|
122
|
+
# @example Widget/id=1,Gadget/slug=forty-two,Fudget/*
|
123
|
+
def key(binding = nil, context = {})
|
124
|
+
bound = binding && binding[:bind] ? standardize(binding[:bind]) : {}
|
125
|
+
bound = (bound.is_a?(Array) ? bound : [ bound ]).compact
|
126
|
+
bound.collect { |el|
|
127
|
+
if el[:object] && ! IDENTITY_FIELDS.map { |id| el[:object][id] }.compact.any?
|
128
|
+
raise ArgumentError, ":bind object arguments (#{bound}) can only be keyed by #{IDENTITY_FIELDS.join(", ")}"
|
129
|
+
end
|
130
|
+
find_or_create_key_prefix_for(el[:klass], el[:object])
|
131
|
+
}.join(",") + ":" +
|
132
|
+
Digest::MD5.hexdigest(
|
133
|
+
KEY_STRATEGIES.map { |strategy| context[strategy.field] }.compact.join("\n")
|
134
|
+
)
|
135
|
+
end
|
136
|
+
|
137
|
+
# Generate an index key from args
|
138
|
+
def index(* args)
|
139
|
+
case args[0]
|
140
|
+
when Hash
|
141
|
+
args[0]
|
142
|
+
when Class
|
143
|
+
case args[1]
|
144
|
+
when Hash
|
145
|
+
{ :klass => args[0], :object => args[1] }
|
146
|
+
when NilClass
|
147
|
+
{ :klass => args[0] }
|
148
|
+
else
|
149
|
+
{ :klass => args[0], :object => { IDENTITY_FIELDS.first => args[1] } }
|
150
|
+
end
|
151
|
+
else
|
152
|
+
raise ArgumentError, "invalid args, must be (klass, identifier) or hash (#{args})"
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def find_or_create_key_prefix_for(klass, object = nil)
|
157
|
+
Garner.config.cache.fetch(index_string_for(klass, object), {}) do
|
158
|
+
new_key_prefix_for(klass, object)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def new_key_prefix_for(klass, object = nil)
|
163
|
+
Digest::MD5.hexdigest("#{klass}/#{object || "*"}:#{new_key_postfix}")
|
164
|
+
end
|
165
|
+
|
166
|
+
def new_key_postfix
|
167
|
+
SecureRandom.respond_to?(:uuid) ? SecureRandom.uuid : (0...16).map{ ('a'..'z').to_a[rand(26)] }.join
|
168
|
+
end
|
169
|
+
|
170
|
+
def standardize(binding)
|
171
|
+
case binding
|
172
|
+
when Hash
|
173
|
+
binding
|
174
|
+
when Array
|
175
|
+
bind_array(binding)
|
176
|
+
when NilClass
|
177
|
+
nil
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Generate a metadata key.
|
182
|
+
def meta(key)
|
183
|
+
"#{key}:meta"
|
184
|
+
end
|
185
|
+
|
186
|
+
def bind_array(ary)
|
187
|
+
case ary[0]
|
188
|
+
when Array, Hash
|
189
|
+
ary.collect { |subary| standardize(subary) }
|
190
|
+
when Class
|
191
|
+
h = { :klass => ary[0] }
|
192
|
+
h.merge!({
|
193
|
+
:object => (ary[1].is_a?(Hash) ? ary[1] : { IDENTITY_FIELDS.first => ary[1] })
|
194
|
+
}) if ary[1]
|
195
|
+
h
|
196
|
+
else
|
197
|
+
raise ArgumentError, "invalid argument type #{ary[0].class} in :bind (#{ary[0]})"
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def index_string_for(klass, object = nil)
|
202
|
+
prefix = "INDEX"
|
203
|
+
IDENTITY_FIELDS.each do |field|
|
204
|
+
if object && object[field]
|
205
|
+
return "#{prefix}:#{klass}/#{field}=#{object[field]}"
|
206
|
+
end
|
207
|
+
end
|
208
|
+
"#{prefix}:#{klass}/*"
|
209
|
+
end
|
210
|
+
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
module Garner
|
2
|
+
|
3
|
+
class << self
|
4
|
+
|
5
|
+
# Set the configuration options. Best used by passing a block.
|
6
|
+
#
|
7
|
+
# @example Set up configuration options.
|
8
|
+
# Garner.configure do |config|
|
9
|
+
# config.cache = Rails.cache
|
10
|
+
# end
|
11
|
+
#
|
12
|
+
# @return [ Config ] The configuration obejct.
|
13
|
+
def configure
|
14
|
+
block_given? ? yield(Garner::Config) : Garner::Config
|
15
|
+
end
|
16
|
+
alias :config :configure
|
17
|
+
end
|
18
|
+
|
19
|
+
module Config
|
20
|
+
extend self
|
21
|
+
|
22
|
+
# Current configuration settings.
|
23
|
+
attr_accessor :settings
|
24
|
+
|
25
|
+
# Default configuration settings.
|
26
|
+
attr_accessor :defaults
|
27
|
+
|
28
|
+
@settings = {}
|
29
|
+
@defaults = {}
|
30
|
+
|
31
|
+
# Define a configuration option with a default.
|
32
|
+
#
|
33
|
+
# @example Define the option.
|
34
|
+
# Config.option(:cache, :default => nil)
|
35
|
+
#
|
36
|
+
# @param [ Symbol ] name The name of the configuration option.
|
37
|
+
# @param [ Hash ] options Extras for the option.
|
38
|
+
#
|
39
|
+
# @option options [ Object ] :default The default value.
|
40
|
+
def option(name, options = {})
|
41
|
+
defaults[name] = settings[name] = options[:default]
|
42
|
+
|
43
|
+
class_eval <<-RUBY
|
44
|
+
def #{name}
|
45
|
+
settings[#{name.inspect}]
|
46
|
+
end
|
47
|
+
|
48
|
+
def #{name}=(value)
|
49
|
+
settings[#{name.inspect}] = value
|
50
|
+
end
|
51
|
+
|
52
|
+
def #{name}?
|
53
|
+
#{name}
|
54
|
+
end
|
55
|
+
RUBY
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns the default cache store, either Rails.cache or an instance of ActiveSupport::Cache::MemoryStore.
|
59
|
+
#
|
60
|
+
# @example Get the default cache store
|
61
|
+
# config.default_cache
|
62
|
+
#
|
63
|
+
# @return [ Cache ] The default cache store instance.
|
64
|
+
def default_cache
|
65
|
+
defined?(Rails) && Rails.respond_to?(:cache) ? Rails.cache : ::ActiveSupport::Cache::MemoryStore.new
|
66
|
+
end
|
67
|
+
|
68
|
+
# Returns the cache, or defaults to Rails cache when running in Rails or an instance of ActiveSupport::Cache::MemoryStore otherwise.
|
69
|
+
#
|
70
|
+
# @example Get the cache.
|
71
|
+
# config.cache
|
72
|
+
#
|
73
|
+
# @return [ Cache ] The configured cache or a default cache instance.
|
74
|
+
def cache
|
75
|
+
settings[:cache] = default_cache unless settings.has_key?(:cache)
|
76
|
+
settings[:cache]
|
77
|
+
end
|
78
|
+
|
79
|
+
# Sets the cache to use.
|
80
|
+
#
|
81
|
+
# @example Set the cache.
|
82
|
+
# config.cache = Rails.cache
|
83
|
+
#
|
84
|
+
# @return [ Cache ] The newly set cache.
|
85
|
+
def cache=(cache)
|
86
|
+
settings[:cache] = cache
|
87
|
+
end
|
88
|
+
|
89
|
+
# Reset the configuration options to the defaults.
|
90
|
+
#
|
91
|
+
# @example Reset the configuration options.
|
92
|
+
# config.reset!
|
93
|
+
def reset!
|
94
|
+
settings.replace(defaults)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Default cache expiration time.
|
98
|
+
option(:expires_in, :default => nil)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# Based on https://github.com/intridea/grape/blob/master/lib/grape/middleware/base.rb.
|
2
|
+
module Garner
|
3
|
+
module Middleware
|
4
|
+
class Base
|
5
|
+
attr_reader :app, :env, :options
|
6
|
+
|
7
|
+
# @param [Rack Application] app The standard argument for a Rack middleware.
|
8
|
+
# @param [Hash] options A hash of options, simply stored for use by subclasses.
|
9
|
+
def initialize(app, options = {})
|
10
|
+
@app = app
|
11
|
+
@options = default_options.merge(options)
|
12
|
+
end
|
13
|
+
|
14
|
+
def default_options; {} end
|
15
|
+
|
16
|
+
def call(env)
|
17
|
+
dup.call!(env)
|
18
|
+
end
|
19
|
+
|
20
|
+
def call!(env)
|
21
|
+
@env = env
|
22
|
+
before
|
23
|
+
@app_response = @app.call(@env)
|
24
|
+
after || @app_response
|
25
|
+
end
|
26
|
+
|
27
|
+
# @abstract
|
28
|
+
# Called before the application is called in the middleware lifecycle.
|
29
|
+
def before; end
|
30
|
+
|
31
|
+
# @abstract
|
32
|
+
# Called after the application is called in the middleware lifecycle.
|
33
|
+
# @return [Response, nil] a Rack SPEC response or nil to call the application afterwards.
|
34
|
+
def after; end
|
35
|
+
|
36
|
+
def request
|
37
|
+
Rack::Request.new(self.env)
|
38
|
+
end
|
39
|
+
|
40
|
+
def response
|
41
|
+
Rack::Response.new(@app_response)
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Garner
|
2
|
+
module Middleware
|
3
|
+
module Cache
|
4
|
+
# @abstract
|
5
|
+
# Add the necessary Cache-Control and Expires headers to bust client cache.
|
6
|
+
class Bust < Garner::Middleware::Base
|
7
|
+
def after
|
8
|
+
# private: ok to store API results in a private cache
|
9
|
+
# max-age: don't reuse the cached result without checking with the server (server might say 304 Not Modified)
|
10
|
+
# must-revalidate: prevent gateways from returning a response if the API server is not reachable
|
11
|
+
@app_response[1]["Cache-Control"] = "private, max-age=0, must-revalidate"
|
12
|
+
# don't reuse the cached result without checking with the server
|
13
|
+
@app_response[1]["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
|
14
|
+
@app_response
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module Garner
|
2
|
+
module Mixins
|
3
|
+
module Grape
|
4
|
+
#
|
5
|
+
# A cache that supports conditional GETs
|
6
|
+
#
|
7
|
+
# Borrows generously from http://themomorohoax.com/2009/01/07/using-stale-with-rails-to-return-304-not-modified
|
8
|
+
# See also RFC 2616: http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.4
|
9
|
+
# for explanation of how If-Modified-Since and If-None-Match request headers are handled.
|
10
|
+
#
|
11
|
+
module Cache
|
12
|
+
|
13
|
+
def cache_enabled?
|
14
|
+
true
|
15
|
+
end
|
16
|
+
|
17
|
+
# cache a record
|
18
|
+
def cache(options = {}, &block)
|
19
|
+
unless cache_enabled?
|
20
|
+
yield
|
21
|
+
else
|
22
|
+
binding, context = cache_binding_and_context(options)
|
23
|
+
Garner::Cache::ObjectIdentity.cache(binding, context) do
|
24
|
+
yield
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# invalidate a cache record
|
30
|
+
def invalidate(*args)
|
31
|
+
Garner::Cache::ObjectIdentity.invalidate(* args)
|
32
|
+
end
|
33
|
+
|
34
|
+
def cache_or_304(options = {}, &block)
|
35
|
+
binding, context = cache_binding_and_context(options)
|
36
|
+
# metadata written in a previous GET
|
37
|
+
metadata = Garner::Cache::ObjectIdentity.cache_metadata(binding, context)
|
38
|
+
error!("Not Modified", 304) if metadata && fresh?(metadata)
|
39
|
+
rc = cache(options, &block)
|
40
|
+
# metadata has been generated by cache
|
41
|
+
metadata = Garner::Cache::ObjectIdentity.cache_metadata(binding, context)
|
42
|
+
self.last_modified = metadata[:last_modified]
|
43
|
+
self.etag = metadata[:etag]
|
44
|
+
rc
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def cache_binding_and_context(options)
|
50
|
+
cache_context = {}
|
51
|
+
cache_context.merge!(options.dup)
|
52
|
+
cache_context[:request] = request
|
53
|
+
cache_context.delete(:bind)
|
54
|
+
cache_binding = (options || {})[:bind]
|
55
|
+
cache_binding = cache_binding ? { :bind => cache_binding } : {}
|
56
|
+
[ cache_binding, cache_context ]
|
57
|
+
end
|
58
|
+
|
59
|
+
def fresh?(metadata = {})
|
60
|
+
case
|
61
|
+
when if_modified_since && if_none_match
|
62
|
+
not_modified?(metadata[:last_modified]) && etag_matches?(metadata[:etag])
|
63
|
+
when if_modified_since
|
64
|
+
not_modified?(metadata[:last_modified])
|
65
|
+
when if_none_match
|
66
|
+
etag_matches?(metadata[:etag])
|
67
|
+
else
|
68
|
+
false
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def if_modified_since
|
73
|
+
if since = env["HTTP_IF_MODIFIED_SINCE"]
|
74
|
+
Time.rfc2822(since) rescue nil
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def if_none_match
|
79
|
+
env["HTTP_IF_NONE_MATCH"]
|
80
|
+
end
|
81
|
+
|
82
|
+
def not_modified?(modified_at)
|
83
|
+
if_modified_since && modified_at && if_modified_since >= modified_at
|
84
|
+
end
|
85
|
+
|
86
|
+
def etag_matches?(etag)
|
87
|
+
if_none_match && if_none_match == etag
|
88
|
+
end
|
89
|
+
|
90
|
+
def last_modified=(utc_time)
|
91
|
+
return unless utc_time
|
92
|
+
header "Last-Modified", utc_time.httpdate
|
93
|
+
end
|
94
|
+
|
95
|
+
def etag=(etag)
|
96
|
+
return unless etag
|
97
|
+
header "ETag", etag
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module Garner
|
2
|
+
module Mixins
|
3
|
+
module Mongoid
|
4
|
+
module Document
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
before_save :invalidate_api_cache
|
9
|
+
before_destroy :invalidate_api_cache
|
10
|
+
cattr_accessor :api_cache_class
|
11
|
+
end
|
12
|
+
|
13
|
+
# invalidate API cache
|
14
|
+
def invalidate_api_cache
|
15
|
+
self.all_embedding_documents.each { |doc| doc.invalidate_api_cache }
|
16
|
+
cache_class = self.class.api_cache_class || self.class
|
17
|
+
Garner::Cache::ObjectIdentity::IDENTITY_FIELDS.each do |identity_field|
|
18
|
+
next unless self.respond_to?(identity_field)
|
19
|
+
Garner::Cache::ObjectIdentity.invalidate(cache_class, { identity_field => self.send(identity_field) })
|
20
|
+
end
|
21
|
+
Garner::Cache::ObjectIdentity.invalidate(cache_class)
|
22
|
+
end
|
23
|
+
|
24
|
+
# navigate the parent embedding document hierarchy
|
25
|
+
def all_embedding_documents
|
26
|
+
obj = self
|
27
|
+
docs = []
|
28
|
+
while obj.metadata && obj.embedded?
|
29
|
+
break if docs.detect { |doc| doc.class == obj.class }
|
30
|
+
parent = obj.send(obj.metadata.inverse)
|
31
|
+
docs << parent
|
32
|
+
obj = parent
|
33
|
+
end
|
34
|
+
docs
|
35
|
+
end
|
36
|
+
|
37
|
+
module ClassMethods
|
38
|
+
# Including classes can call `cache_as` to specify a different class
|
39
|
+
# on which to bind API cache objects.
|
40
|
+
# @example `Admin`, which extends `User` should call `cache_as User`
|
41
|
+
def cache_as(klass)
|
42
|
+
self.api_cache_class = klass
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Garner
|
2
|
+
module Objects
|
3
|
+
module ETag
|
4
|
+
class << self
|
5
|
+
# @abstract
|
6
|
+
# Serialize in a way such that the ETag matches that which would
|
7
|
+
# be returned by Rack::ETag at response time.
|
8
|
+
def from(object)
|
9
|
+
serialization = case object
|
10
|
+
when nil then ""
|
11
|
+
when String then object
|
12
|
+
when Hash then object.respond_to?(:to_json) ? object.to_json : MultiJson.dump(object)
|
13
|
+
else Marshal.dump(object)
|
14
|
+
end
|
15
|
+
%("#{Digest::MD5.hexdigest(serialization)}")
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Garner
|
2
|
+
module Strategies
|
3
|
+
module Cache
|
4
|
+
# Injects an expires_in value from the globl configuration.
|
5
|
+
module Expiration
|
6
|
+
class << self
|
7
|
+
def apply(current, options = {})
|
8
|
+
rc = current ? current.dup : {}
|
9
|
+
rc[:expires_in] = Garner.config.expires_in if Garner.config.expires_in && ! current[:expires_in]
|
10
|
+
rc
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Garner
|
2
|
+
module Strategies
|
3
|
+
module Keys
|
4
|
+
# Injects the caller's location into the key.
|
5
|
+
module Caller
|
6
|
+
class << self
|
7
|
+
|
8
|
+
def field
|
9
|
+
:caller
|
10
|
+
end
|
11
|
+
|
12
|
+
def apply(key, context = {})
|
13
|
+
rc = key ? key.dup : {}
|
14
|
+
clr = nil
|
15
|
+
caller.each do |line|
|
16
|
+
split = line.split(":")
|
17
|
+
next unless split.length >= 2
|
18
|
+
path = Pathname.new(split[0]).realpath.to_s
|
19
|
+
next unless path.include?("/app/") || path.include?("/spec/")
|
20
|
+
rc[field] = "#{path}:#{split[1]}"
|
21
|
+
break
|
22
|
+
end
|
23
|
+
rc
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Garner
|
2
|
+
module Strategies
|
3
|
+
module Keys
|
4
|
+
# Inject the request GET parameters into the key.
|
5
|
+
module RequestGet
|
6
|
+
class << self
|
7
|
+
|
8
|
+
def field
|
9
|
+
:params
|
10
|
+
end
|
11
|
+
|
12
|
+
def apply(key, context = {})
|
13
|
+
key = key ? key.dup : {}
|
14
|
+
key[field] = context[:request].GET.dup if context && context[:request]
|
15
|
+
key
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Garner
|
2
|
+
module Strategies
|
3
|
+
module Keys
|
4
|
+
# Inject the request path into the key.
|
5
|
+
module RequestPath
|
6
|
+
class << self
|
7
|
+
|
8
|
+
def field
|
9
|
+
:path
|
10
|
+
end
|
11
|
+
|
12
|
+
def apply(key, context = {})
|
13
|
+
key = key ? key.dup : {}
|
14
|
+
key[field] = context[:request].path if context && context[:request] && context[:request].respond_to?(:path)
|
15
|
+
key
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Garner
|
2
|
+
module Strategies
|
3
|
+
module Keys
|
4
|
+
# Inject the request path into the key.
|
5
|
+
module Version
|
6
|
+
class << self
|
7
|
+
|
8
|
+
def field
|
9
|
+
:version
|
10
|
+
end
|
11
|
+
|
12
|
+
def default_version
|
13
|
+
nil
|
14
|
+
end
|
15
|
+
|
16
|
+
def apply(key, context = {})
|
17
|
+
key = key ? key.dup : {}
|
18
|
+
if context && context[:version]
|
19
|
+
key[:version] = context[:version]
|
20
|
+
elsif default_version
|
21
|
+
key[:version] = default_version
|
22
|
+
end
|
23
|
+
key
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/garner.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'multi_json'
|
2
|
+
require 'active_support'
|
3
|
+
# garner
|
4
|
+
require 'garner/version'
|
5
|
+
require 'garner/config'
|
6
|
+
# objects
|
7
|
+
require 'garner/objects/etag'
|
8
|
+
# middleware
|
9
|
+
require 'garner/middleware/base'
|
10
|
+
require 'garner/middleware/cache/bust'
|
11
|
+
# key strategies
|
12
|
+
require 'garner/strategies/keys/version_strategy'
|
13
|
+
require 'garner/strategies/keys/caller_strategy'
|
14
|
+
require 'garner/strategies/keys/request_path_strategy'
|
15
|
+
require 'garner/strategies/keys/request_get_strategy'
|
16
|
+
# cache option strategies
|
17
|
+
require 'garner/strategies/cache/expiration_strategy'
|
18
|
+
# caches
|
19
|
+
require 'garner/cache/object_identity'
|
20
|
+
# mixins
|
21
|
+
require 'garner/mixins/grape_cache'
|
22
|
+
require 'garner/mixins/mongoid_document'
|
metadata
ADDED
@@ -0,0 +1,223 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: garner
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Daniel Doubrovkine
|
9
|
+
- Frank Macreery
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2012-06-22 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: rack
|
17
|
+
requirement: &70275893494820 !ruby/object:Gem::Requirement
|
18
|
+
none: false
|
19
|
+
requirements:
|
20
|
+
- - ! '>='
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '0'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: *70275893494820
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: json
|
28
|
+
requirement: &70275893493960 !ruby/object:Gem::Requirement
|
29
|
+
none: false
|
30
|
+
requirements:
|
31
|
+
- - ! '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: *70275893493960
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: multi_json
|
39
|
+
requirement: &70275893493440 !ruby/object:Gem::Requirement
|
40
|
+
none: false
|
41
|
+
requirements:
|
42
|
+
- - ! '>='
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
version: '0'
|
45
|
+
type: :runtime
|
46
|
+
prerelease: false
|
47
|
+
version_requirements: *70275893493440
|
48
|
+
- !ruby/object:Gem::Dependency
|
49
|
+
name: activesupport
|
50
|
+
requirement: &70275893492560 !ruby/object:Gem::Requirement
|
51
|
+
none: false
|
52
|
+
requirements:
|
53
|
+
- - ! '>='
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '0'
|
56
|
+
type: :runtime
|
57
|
+
prerelease: false
|
58
|
+
version_requirements: *70275893492560
|
59
|
+
- !ruby/object:Gem::Dependency
|
60
|
+
name: bundler
|
61
|
+
requirement: &70275893491640 !ruby/object:Gem::Requirement
|
62
|
+
none: false
|
63
|
+
requirements:
|
64
|
+
- - ! '>='
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: '0'
|
67
|
+
type: :development
|
68
|
+
prerelease: false
|
69
|
+
version_requirements: *70275893491640
|
70
|
+
- !ruby/object:Gem::Dependency
|
71
|
+
name: grape
|
72
|
+
requirement: &70275893490880 !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - =
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: 0.2.0
|
78
|
+
type: :development
|
79
|
+
prerelease: false
|
80
|
+
version_requirements: *70275893490880
|
81
|
+
- !ruby/object:Gem::Dependency
|
82
|
+
name: rack-test
|
83
|
+
requirement: &70275893489940 !ruby/object:Gem::Requirement
|
84
|
+
none: false
|
85
|
+
requirements:
|
86
|
+
- - =
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: 0.6.1
|
89
|
+
type: :development
|
90
|
+
prerelease: false
|
91
|
+
version_requirements: *70275893489940
|
92
|
+
- !ruby/object:Gem::Dependency
|
93
|
+
name: rspec
|
94
|
+
requirement: &70275893489020 !ruby/object:Gem::Requirement
|
95
|
+
none: false
|
96
|
+
requirements:
|
97
|
+
- - ~>
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: '2.10'
|
100
|
+
type: :development
|
101
|
+
prerelease: false
|
102
|
+
version_requirements: *70275893489020
|
103
|
+
- !ruby/object:Gem::Dependency
|
104
|
+
name: jeweler
|
105
|
+
requirement: &70275893503600 !ruby/object:Gem::Requirement
|
106
|
+
none: false
|
107
|
+
requirements:
|
108
|
+
- - =
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: 1.8.3
|
111
|
+
type: :development
|
112
|
+
prerelease: false
|
113
|
+
version_requirements: *70275893503600
|
114
|
+
- !ruby/object:Gem::Dependency
|
115
|
+
name: mongoid
|
116
|
+
requirement: &70275893502040 !ruby/object:Gem::Requirement
|
117
|
+
none: false
|
118
|
+
requirements:
|
119
|
+
- - ~>
|
120
|
+
- !ruby/object:Gem::Version
|
121
|
+
version: '2.4'
|
122
|
+
type: :development
|
123
|
+
prerelease: false
|
124
|
+
version_requirements: *70275893502040
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: bson_ext
|
127
|
+
requirement: &70275893500500 !ruby/object:Gem::Requirement
|
128
|
+
none: false
|
129
|
+
requirements:
|
130
|
+
- - ~>
|
131
|
+
- !ruby/object:Gem::Version
|
132
|
+
version: '1.6'
|
133
|
+
type: :development
|
134
|
+
prerelease: false
|
135
|
+
version_requirements: *70275893500500
|
136
|
+
- !ruby/object:Gem::Dependency
|
137
|
+
name: yard
|
138
|
+
requirement: &70275893499420 !ruby/object:Gem::Requirement
|
139
|
+
none: false
|
140
|
+
requirements:
|
141
|
+
- - ! '>='
|
142
|
+
- !ruby/object:Gem::Version
|
143
|
+
version: '0'
|
144
|
+
type: :development
|
145
|
+
prerelease: false
|
146
|
+
version_requirements: *70275893499420
|
147
|
+
- !ruby/object:Gem::Dependency
|
148
|
+
name: redcarpet
|
149
|
+
requirement: &70275893498780 !ruby/object:Gem::Requirement
|
150
|
+
none: false
|
151
|
+
requirements:
|
152
|
+
- - ! '>='
|
153
|
+
- !ruby/object:Gem::Version
|
154
|
+
version: '0'
|
155
|
+
type: :development
|
156
|
+
prerelease: false
|
157
|
+
version_requirements: *70275893498780
|
158
|
+
- !ruby/object:Gem::Dependency
|
159
|
+
name: github-markup
|
160
|
+
requirement: &70275893498280 !ruby/object:Gem::Requirement
|
161
|
+
none: false
|
162
|
+
requirements:
|
163
|
+
- - ! '>='
|
164
|
+
- !ruby/object:Gem::Version
|
165
|
+
version: '0'
|
166
|
+
type: :development
|
167
|
+
prerelease: false
|
168
|
+
version_requirements: *70275893498280
|
169
|
+
description: Garner is a set of Rack middleware and cache helpers that implement various
|
170
|
+
strategies.
|
171
|
+
email: dblock@dblock.org
|
172
|
+
executables: []
|
173
|
+
extensions: []
|
174
|
+
extra_rdoc_files:
|
175
|
+
- LICENSE.md
|
176
|
+
- README.md
|
177
|
+
files:
|
178
|
+
- lib/garner.rb
|
179
|
+
- lib/garner/cache/object_identity.rb
|
180
|
+
- lib/garner/config.rb
|
181
|
+
- lib/garner/middleware/base.rb
|
182
|
+
- lib/garner/middleware/cache/bust.rb
|
183
|
+
- lib/garner/mixins/grape_cache.rb
|
184
|
+
- lib/garner/mixins/mongoid_document.rb
|
185
|
+
- lib/garner/objects/etag.rb
|
186
|
+
- lib/garner/strategies/cache/expiration_strategy.rb
|
187
|
+
- lib/garner/strategies/keys/caller_strategy.rb
|
188
|
+
- lib/garner/strategies/keys/request_get_strategy.rb
|
189
|
+
- lib/garner/strategies/keys/request_path_strategy.rb
|
190
|
+
- lib/garner/strategies/keys/version_strategy.rb
|
191
|
+
- lib/garner/version.rb
|
192
|
+
- LICENSE.md
|
193
|
+
- README.md
|
194
|
+
homepage: http://github.com/dblock/garner
|
195
|
+
licenses:
|
196
|
+
- MIT
|
197
|
+
post_install_message:
|
198
|
+
rdoc_options: []
|
199
|
+
require_paths:
|
200
|
+
- lib
|
201
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
202
|
+
none: false
|
203
|
+
requirements:
|
204
|
+
- - ! '>='
|
205
|
+
- !ruby/object:Gem::Version
|
206
|
+
version: '0'
|
207
|
+
segments:
|
208
|
+
- 0
|
209
|
+
hash: 3544902871771726004
|
210
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
211
|
+
none: false
|
212
|
+
requirements:
|
213
|
+
- - ! '>='
|
214
|
+
- !ruby/object:Gem::Version
|
215
|
+
version: '0'
|
216
|
+
requirements: []
|
217
|
+
rubyforge_project:
|
218
|
+
rubygems_version: 1.8.10
|
219
|
+
signing_key:
|
220
|
+
specification_version: 3
|
221
|
+
summary: Garner is a set of Rack middleware and cache helpers that implement various
|
222
|
+
strategies.
|
223
|
+
test_files: []
|