active_rest_client 0.9.58
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 +17 -0
- data/.rspec +2 -0
- data/.simplecov +4 -0
- data/Gemfile +4 -0
- data/Guardfile +9 -0
- data/LICENSE.txt +22 -0
- data/README.md +585 -0
- data/Rakefile +3 -0
- data/active_rest_client.gemspec +34 -0
- data/lib/active_rest_client.rb +23 -0
- data/lib/active_rest_client/base.rb +128 -0
- data/lib/active_rest_client/caching.rb +84 -0
- data/lib/active_rest_client/configuration.rb +69 -0
- data/lib/active_rest_client/connection.rb +76 -0
- data/lib/active_rest_client/connection_manager.rb +21 -0
- data/lib/active_rest_client/headers_list.rb +47 -0
- data/lib/active_rest_client/instrumentation.rb +62 -0
- data/lib/active_rest_client/lazy_association_loader.rb +95 -0
- data/lib/active_rest_client/lazy_loader.rb +23 -0
- data/lib/active_rest_client/logger.rb +67 -0
- data/lib/active_rest_client/mapping.rb +65 -0
- data/lib/active_rest_client/proxy_base.rb +143 -0
- data/lib/active_rest_client/recording.rb +24 -0
- data/lib/active_rest_client/request.rb +412 -0
- data/lib/active_rest_client/request_filtering.rb +52 -0
- data/lib/active_rest_client/result_iterator.rb +66 -0
- data/lib/active_rest_client/validation.rb +60 -0
- data/lib/active_rest_client/version.rb +3 -0
- data/spec/lib/base_spec.rb +245 -0
- data/spec/lib/caching_spec.rb +179 -0
- data/spec/lib/configuration_spec.rb +105 -0
- data/spec/lib/connection_manager_spec.rb +36 -0
- data/spec/lib/connection_spec.rb +73 -0
- data/spec/lib/headers_list_spec.rb +61 -0
- data/spec/lib/instrumentation_spec.rb +59 -0
- data/spec/lib/lazy_association_loader_spec.rb +118 -0
- data/spec/lib/lazy_loader_spec.rb +25 -0
- data/spec/lib/logger_spec.rb +63 -0
- data/spec/lib/mapping_spec.rb +48 -0
- data/spec/lib/proxy_spec.rb +154 -0
- data/spec/lib/recording_spec.rb +34 -0
- data/spec/lib/request_filtering_spec.rb +72 -0
- data/spec/lib/request_spec.rb +471 -0
- data/spec/lib/result_iterator_spec.rb +104 -0
- data/spec/lib/validation_spec.rb +113 -0
- data/spec/spec_helper.rb +22 -0
- metadata +265 -0
@@ -0,0 +1,24 @@
|
|
1
|
+
module ActiveRestClient
|
2
|
+
module Recording
|
3
|
+
module ClassMethods
|
4
|
+
@record_response = nil
|
5
|
+
|
6
|
+
def record_response(url = nil, response = nil, &block)
|
7
|
+
if url && response && @record_response
|
8
|
+
@record_response.call(url, response)
|
9
|
+
elsif block
|
10
|
+
@record_response = block
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def record_response?
|
15
|
+
!!@record_response
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.included(base)
|
20
|
+
base.extend(ClassMethods)
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,412 @@
|
|
1
|
+
require "cgi"
|
2
|
+
require "oj"
|
3
|
+
|
4
|
+
module ActiveRestClient
|
5
|
+
|
6
|
+
class Request
|
7
|
+
attr_accessor :post_params, :get_params, :url, :path, :headers, :method, :object, :body, :forced_url, :original_url
|
8
|
+
|
9
|
+
def initialize(method, object, params = {})
|
10
|
+
@method = method
|
11
|
+
@method[:options] ||= {}
|
12
|
+
@method[:options][:lazy] ||= []
|
13
|
+
@overriden_name = @method[:options][:overriden_name]
|
14
|
+
@object = object
|
15
|
+
@params = params
|
16
|
+
@headers = HeadersList.new
|
17
|
+
end
|
18
|
+
|
19
|
+
def object_is_class?
|
20
|
+
!@object.respond_to?(:dirty?)
|
21
|
+
end
|
22
|
+
|
23
|
+
def class_name
|
24
|
+
if object_is_class?
|
25
|
+
@object.name
|
26
|
+
else
|
27
|
+
@object.class.name
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def base_url
|
32
|
+
if object_is_class?
|
33
|
+
@object.base_url
|
34
|
+
else
|
35
|
+
@object.class.base_url
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def verbose?
|
40
|
+
if object_is_class?
|
41
|
+
@object.verbose
|
42
|
+
else
|
43
|
+
@object.class.verbose
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def translator
|
48
|
+
if object_is_class?
|
49
|
+
@object.translator
|
50
|
+
else
|
51
|
+
@object.class.translator
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def proxy
|
56
|
+
if object_is_class?
|
57
|
+
@object.proxy
|
58
|
+
else
|
59
|
+
@object.class.proxy
|
60
|
+
end
|
61
|
+
rescue
|
62
|
+
nil
|
63
|
+
end
|
64
|
+
|
65
|
+
def http_method
|
66
|
+
@method[:method]
|
67
|
+
end
|
68
|
+
|
69
|
+
def call(explicit_parameters=nil)
|
70
|
+
@instrumentation_name = "#{class_name}##{@method[:name]}"
|
71
|
+
result = nil
|
72
|
+
cached = nil
|
73
|
+
ActiveSupport::Notifications.instrument("request_call.active_rest_client", :name => @instrumentation_name) do
|
74
|
+
if @method[:options][:fake]
|
75
|
+
ActiveRestClient::Logger.debug " \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Faked response found"
|
76
|
+
return handle_response(OpenStruct.new(status:200, body:@method[:options][:fake], headers:{"X-ARC-Faked-Response" => "true"}))
|
77
|
+
end
|
78
|
+
@explicit_parameters = explicit_parameters
|
79
|
+
@body = nil
|
80
|
+
prepare_params
|
81
|
+
prepare_url
|
82
|
+
if object_is_class?
|
83
|
+
@object.send(:_filter_request, @method[:name], self)
|
84
|
+
else
|
85
|
+
@object.class.send(:_filter_request, @method[:name], self)
|
86
|
+
end
|
87
|
+
append_get_parameters
|
88
|
+
prepare_request_body
|
89
|
+
self.original_url = self.url
|
90
|
+
cached = ActiveRestClient::Base.read_cached_response(self)
|
91
|
+
if cached
|
92
|
+
if cached.expires && cached.expires > Time.now
|
93
|
+
ActiveRestClient::Logger.debug " \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Absolutely cached copy found"
|
94
|
+
return handle_cached_response(cached)
|
95
|
+
elsif cached.etag.present?
|
96
|
+
ActiveRestClient::Logger.debug " \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Etag cached copy found with etag #{cached.etag}"
|
97
|
+
etag = cached.etag
|
98
|
+
end
|
99
|
+
end
|
100
|
+
response = if proxy
|
101
|
+
proxy.handle(self) do |request|
|
102
|
+
request.do_request(etag)
|
103
|
+
end
|
104
|
+
else
|
105
|
+
do_request(etag)
|
106
|
+
end
|
107
|
+
if object_is_class? && @object.record_response?
|
108
|
+
@object.record_response(self.url, response)
|
109
|
+
end
|
110
|
+
result = handle_response(response)
|
111
|
+
if result == :not_modified && cached
|
112
|
+
result = cached.result
|
113
|
+
end
|
114
|
+
ActiveRestClient::Base.write_cached_response(self, response, result)
|
115
|
+
result
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def prepare_params
|
120
|
+
params = @params || @object._attributes rescue {}
|
121
|
+
if params.is_a?(String) || params.is_a?(Fixnum)
|
122
|
+
params = {id:params}
|
123
|
+
end
|
124
|
+
|
125
|
+
default_params = @method[:options][:defaults] || {}
|
126
|
+
|
127
|
+
if @explicit_parameters
|
128
|
+
params = @explicit_parameters
|
129
|
+
end
|
130
|
+
if http_method == :get
|
131
|
+
@get_params = default_params.merge(params || {})
|
132
|
+
@post_params = nil
|
133
|
+
else
|
134
|
+
@post_params = default_params.merge(params || {})
|
135
|
+
@get_params = {}
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def prepare_url
|
140
|
+
if @forced_url && @forced_url.present?
|
141
|
+
@url = @forced_url
|
142
|
+
else
|
143
|
+
@url = @method[:url].dup
|
144
|
+
matches = @url.scan(/(:[a-z_-]+)/)
|
145
|
+
@get_params ||= {}
|
146
|
+
@post_params ||= {}
|
147
|
+
matches.each do |token|
|
148
|
+
token = token.first[1,999]
|
149
|
+
target = @get_params.delete(token.to_sym) || @post_params.delete(token.to_sym) || @get_params.delete(token.to_s) || @post_params.delete(token.to_s) || ""
|
150
|
+
@url.gsub!(":#{token}", target.to_s)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def append_get_parameters
|
156
|
+
if @get_params.any?
|
157
|
+
params = @get_params.map {|k,v| "#{k}=#{CGI.escape(v.to_s)}"}
|
158
|
+
@url += "?" + params.sort * "&"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def prepare_request_body(params = nil)
|
163
|
+
@body ||= (params || @post_params || {}).map {|k,v| "#{k}=#{CGI.escape(v.to_s)}"}.sort * "&"
|
164
|
+
end
|
165
|
+
|
166
|
+
def do_request(etag)
|
167
|
+
http_headers = {}
|
168
|
+
http_headers["If-None-Match"] = etag if etag
|
169
|
+
http_headers["Accept"] = "application/hal+json, application/json;q=0.5"
|
170
|
+
headers.each do |key,value|
|
171
|
+
value = value.join(",") if value.is_a?(Array)
|
172
|
+
http_headers[key] = value
|
173
|
+
end
|
174
|
+
if @method[:options][:url] || @forced_url
|
175
|
+
@url = @method[:options][:url]
|
176
|
+
@url = @forced_url if @forced_url
|
177
|
+
if connection = ActiveRestClient::ConnectionManager.find_connection_for_url(@url)
|
178
|
+
@url = @url.slice(connection.base_url.length, 255)
|
179
|
+
else
|
180
|
+
parts = @url.match(%r{^(https?://[a-z\d\.:-]+?)(/.*)}).to_a
|
181
|
+
if (parts.empty?) # Not a full URL, so use hostname/protocol from existing base_url
|
182
|
+
uri = URI.parse(base_url)
|
183
|
+
@base_url = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != 80 && uri.port != 443}"
|
184
|
+
else
|
185
|
+
_, @base_url, @url = parts
|
186
|
+
end
|
187
|
+
connection = ActiveRestClient::ConnectionManager.get_connection(@base_url)
|
188
|
+
end
|
189
|
+
else
|
190
|
+
connection = ActiveRestClient::ConnectionManager.get_connection(base_url)
|
191
|
+
end
|
192
|
+
ActiveRestClient::Logger.info " \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Requesting #{connection.base_url}#{@url}"
|
193
|
+
|
194
|
+
if verbose?
|
195
|
+
ActiveRestClient::Logger.debug "ActiveRestClient Verbose Log:"
|
196
|
+
ActiveRestClient::Logger.debug " > GET #{@url} HTTP/1.1"
|
197
|
+
http_headers.each do |k,v|
|
198
|
+
ActiveRestClient::Logger.debug " > #{k} : #{v}"
|
199
|
+
end
|
200
|
+
ActiveRestClient::Logger.debug " > #{@body}"
|
201
|
+
end
|
202
|
+
|
203
|
+
case http_method
|
204
|
+
when :get
|
205
|
+
response = connection.get(@url, http_headers)
|
206
|
+
when :put
|
207
|
+
response = connection.put(@url, @body, http_headers)
|
208
|
+
when :post
|
209
|
+
response = connection.post(@url, @body, http_headers)
|
210
|
+
when :delete
|
211
|
+
response = connection.delete(@url, http_headers)
|
212
|
+
else
|
213
|
+
raise InvalidRequestException.new("Invalid method #{http_method}")
|
214
|
+
end
|
215
|
+
|
216
|
+
if verbose?
|
217
|
+
response.headers.each do |k,v|
|
218
|
+
ActiveRestClient::Logger.debug " < #{k} : #{v}"
|
219
|
+
end
|
220
|
+
ActiveRestClient::Logger.debug " < #{response.body}"
|
221
|
+
end
|
222
|
+
|
223
|
+
response
|
224
|
+
end
|
225
|
+
|
226
|
+
def handle_cached_response(cached)
|
227
|
+
if cached.result.is_a? ActiveRestClient::ResultIterator
|
228
|
+
cached.result
|
229
|
+
else
|
230
|
+
if object_is_class?
|
231
|
+
cached.result
|
232
|
+
else
|
233
|
+
@object._copy_from(cached.result)
|
234
|
+
@object
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
def handle_response(response)
|
240
|
+
if response.status == 304
|
241
|
+
ActiveRestClient::Logger.debug " \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Etag copy is the same as the server"
|
242
|
+
return :not_modified
|
243
|
+
end
|
244
|
+
if response.respond_to?(:proxied) && response.proxied
|
245
|
+
ActiveRestClient::Logger.debug " \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Response was proxied, unable to determine size"
|
246
|
+
else
|
247
|
+
ActiveRestClient::Logger.debug " \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Response received #{response.body.size} bytes"
|
248
|
+
end
|
249
|
+
@response = response
|
250
|
+
|
251
|
+
if response.body.is_a?(Array) || response.body.is_a?(Hash)
|
252
|
+
body = response.body
|
253
|
+
else
|
254
|
+
body = Oj.load(response.body) || {}
|
255
|
+
end
|
256
|
+
body = begin
|
257
|
+
@method[:name].nil? ? body : translator.send(@method[:name], body)
|
258
|
+
rescue NoMethodError
|
259
|
+
body
|
260
|
+
end
|
261
|
+
if body.is_a? Array
|
262
|
+
result = ActiveRestClient::ResultIterator.new(response.status)
|
263
|
+
body.each do |json_object|
|
264
|
+
result << new_object(json_object, @overriden_name)
|
265
|
+
end
|
266
|
+
else
|
267
|
+
result = new_object(body, @overriden_name)
|
268
|
+
result._status = response.status
|
269
|
+
unless object_is_class?
|
270
|
+
@object._copy_from(result)
|
271
|
+
result = @object
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
response.status ||= 200
|
276
|
+
if response.status == 401
|
277
|
+
raise HTTPUnauthorisedClientException.new(status:response.status, result:result, url:@url)
|
278
|
+
elsif response.status == 403
|
279
|
+
raise HTTPForbiddenClientException.new(status:response.status, result:result, url:@url)
|
280
|
+
elsif response.status == 404
|
281
|
+
raise HTTPNotFoundClientException.new(status:response.status, result:result, url:@url)
|
282
|
+
elsif (400..499).include? response.status
|
283
|
+
raise HTTPClientException.new(status:response.status, result:result, url:@url)
|
284
|
+
elsif (500..599).include? response.status
|
285
|
+
raise HTTPServerException.new(status:response.status, result:result, url:@url)
|
286
|
+
end
|
287
|
+
|
288
|
+
result
|
289
|
+
rescue Oj::ParseError
|
290
|
+
raise ResponseParseException.new(status:response.status, body:response.body)
|
291
|
+
end
|
292
|
+
|
293
|
+
def new_object(attributes, name = nil)
|
294
|
+
@method[:options][:has_many] ||= {}
|
295
|
+
name = name.to_sym rescue nil
|
296
|
+
if @method[:options][:has_many][name]
|
297
|
+
overriden_name = name
|
298
|
+
object = @method[:options][:has_many][name].new
|
299
|
+
else
|
300
|
+
if object_is_class?
|
301
|
+
object = @object.new
|
302
|
+
else
|
303
|
+
object = @object.class.new
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
if hal_response? && name.nil?
|
308
|
+
attributes = handle_hal_links_embedded(object, attributes)
|
309
|
+
end
|
310
|
+
|
311
|
+
attributes.each do |k,v|
|
312
|
+
k = k.to_sym
|
313
|
+
if @method[:options][:lazy].include?(k)
|
314
|
+
object._attributes[k] = ActiveRestClient::LazyAssociationLoader.new(overriden_name || k, v, self, overriden_name:(overriden_name||k))
|
315
|
+
elsif v.is_a? Hash
|
316
|
+
object._attributes[k] = new_object(v, overriden_name || k)
|
317
|
+
elsif v.is_a? Array
|
318
|
+
object._attributes[k] = ActiveRestClient::ResultIterator.new
|
319
|
+
v.each do |item|
|
320
|
+
if item.is_a? Hash
|
321
|
+
object._attributes[k] << new_object(item, overriden_name || k)
|
322
|
+
else
|
323
|
+
object._attributes[k] << item
|
324
|
+
end
|
325
|
+
end
|
326
|
+
else
|
327
|
+
if v.to_s[/\d{4}\-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})/]
|
328
|
+
object._attributes[k] = DateTime.parse(v)
|
329
|
+
else
|
330
|
+
object._attributes[k] = v
|
331
|
+
end
|
332
|
+
end
|
333
|
+
end
|
334
|
+
object.clean! unless object_is_class?
|
335
|
+
|
336
|
+
object
|
337
|
+
end
|
338
|
+
|
339
|
+
def hal_response?
|
340
|
+
_, content_type = @response.headers.detect{|k,v| k.downcase == "content-type"}
|
341
|
+
faked_response = @response.headers.detect{|k,v| k.downcase == "x-arc-faked-response"}
|
342
|
+
if content_type && content_type.respond_to?(:each)
|
343
|
+
content_type.each do |ct|
|
344
|
+
return true if ct[%r{application\/hal\+json}i]
|
345
|
+
return true if ct[%r{application\/json}i]
|
346
|
+
end
|
347
|
+
faked_response
|
348
|
+
elsif content_type && (content_type[%r{application\/hal\+json}i] || content_type[%r{application\/json}i]) || faked_response
|
349
|
+
true
|
350
|
+
else
|
351
|
+
false
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
def handle_hal_links_embedded(object, attributes)
|
356
|
+
attributes["_links"] = attributes[:_links] if attributes[:_links]
|
357
|
+
attributes["_embedded"] = attributes[:_embedded] if attributes[:_embedded]
|
358
|
+
if attributes["_links"]
|
359
|
+
attributes["_links"].each do |key, value|
|
360
|
+
if value.is_a?(Array)
|
361
|
+
object._attributes[key.to_sym] ||= ActiveRestClient::ResultIterator.new
|
362
|
+
value.each do |element|
|
363
|
+
begin
|
364
|
+
embedded_version = attributes["_embedded"][key].detect{|embed| embed["_links"]["self"]["href"] == element["href"]}
|
365
|
+
object._attributes[key.to_sym] << new_object(embedded_version, key)
|
366
|
+
rescue NoMethodError
|
367
|
+
object._attributes[key.to_sym] << ActiveRestClient::LazyAssociationLoader.new(key, element, self)
|
368
|
+
end
|
369
|
+
end
|
370
|
+
else
|
371
|
+
begin
|
372
|
+
embedded_version = attributes["_embedded"][key]
|
373
|
+
object._attributes[key.to_sym] = new_object(embedded_version, key)
|
374
|
+
rescue NoMethodError
|
375
|
+
object._attributes[key.to_sym] = ActiveRestClient::LazyAssociationLoader.new(key, value, self)
|
376
|
+
end
|
377
|
+
end
|
378
|
+
end
|
379
|
+
attributes.delete("_links")
|
380
|
+
attributes.delete("_embedded")
|
381
|
+
end
|
382
|
+
|
383
|
+
attributes
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
class RequestException < StandardError ; end
|
388
|
+
|
389
|
+
class InvalidRequestException < RequestException ; end
|
390
|
+
class ResponseParseException < RequestException
|
391
|
+
attr_accessor :status, :body
|
392
|
+
def initialize(options)
|
393
|
+
@status = options[:status]
|
394
|
+
@body = options[:body]
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
class HTTPException < RequestException
|
399
|
+
attr_accessor :status, :result, :request_url
|
400
|
+
def initialize(options)
|
401
|
+
@status = options[:status]
|
402
|
+
@result = options[:result]
|
403
|
+
@request_url = options[:url]
|
404
|
+
end
|
405
|
+
end
|
406
|
+
class HTTPClientException < HTTPException ; end
|
407
|
+
class HTTPUnauthorisedClientException < HTTPClientException ; end
|
408
|
+
class HTTPForbiddenClientException < HTTPClientException ; end
|
409
|
+
class HTTPNotFoundClientException < HTTPClientException ; end
|
410
|
+
class HTTPServerException < HTTPException ; end
|
411
|
+
|
412
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module ActiveRestClient
|
2
|
+
module RequestFiltering
|
3
|
+
module ClassMethods
|
4
|
+
def before_request(method_name = nil, &block)
|
5
|
+
@filters ||= []
|
6
|
+
if block
|
7
|
+
@filters << block
|
8
|
+
elsif method_name
|
9
|
+
@filters << method_name
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def _filter_request(name, request)
|
14
|
+
_handle_super_class_filters(name, request)
|
15
|
+
@filters ||= []
|
16
|
+
@filters.each do |filter|
|
17
|
+
if filter.is_a? Symbol
|
18
|
+
if self.respond_to?(filter)
|
19
|
+
self.send(filter, name, request)
|
20
|
+
else
|
21
|
+
instance = self.new
|
22
|
+
instance.send(filter, name, request)
|
23
|
+
end
|
24
|
+
else
|
25
|
+
filter.call(name, request)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def _handle_super_class_filters(name, request)
|
31
|
+
@parents ||= []
|
32
|
+
@parents.each do |parent|
|
33
|
+
parent._filter_request(name, request)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def _parents
|
38
|
+
@parents ||= []
|
39
|
+
end
|
40
|
+
|
41
|
+
def inherited(subclass)
|
42
|
+
subclass._parents << self
|
43
|
+
super
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.included(base)
|
48
|
+
base.extend(ClassMethods)
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|