activeresource-five 5.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,20 @@
1
+ require 'active_support/core_ext/array/wrap'
2
+
3
+ module ActiveResource
4
+ module Callbacks
5
+ extend ActiveSupport::Concern
6
+
7
+ CALLBACKS = [
8
+ :before_validation, :after_validation, :before_save, :around_save, :after_save,
9
+ :before_create, :around_create, :after_create, :before_update, :around_update,
10
+ :after_update, :before_destroy, :around_destroy, :after_destroy
11
+ ]
12
+
13
+ included do
14
+ extend ActiveModel::Callbacks
15
+ include ActiveModel::Validations::Callbacks
16
+
17
+ define_model_callbacks :save, :create, :update, :destroy
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,92 @@
1
+ require 'active_support/core_ext/module/delegation'
2
+ require 'active_support/inflector'
3
+
4
+ module ActiveResource # :nodoc:
5
+ class Collection # :nodoc:
6
+ SELF_DEFINE_METHODS = [:to_a, :collect!, :map!]
7
+ include Enumerable
8
+ delegate :to_yaml, :all?, *(Array.instance_methods(false) - SELF_DEFINE_METHODS), :to => :to_a
9
+
10
+ # The array of actual elements returned by index actions
11
+ attr_accessor :elements, :resource_class, :original_params
12
+
13
+ # ActiveResource::Collection is a wrapper to handle parsing index responses that
14
+ # do not directly map to Rails conventions.
15
+ #
16
+ # You can define a custom class that inherets from ActiveResource::Collection
17
+ # in order to to set the elements instance.
18
+ #
19
+ # GET /posts.json delivers following response body:
20
+ # {
21
+ # posts: [
22
+ # {
23
+ # title: "ActiveResource now has associations",
24
+ # body: "Lorem Ipsum"
25
+ # },
26
+ # {...}
27
+ # ],
28
+ # next_page: "/posts.json?page=2"
29
+ # }
30
+ #
31
+ # A Post class can be setup to handle it with:
32
+ #
33
+ # class Post < ActiveResource::Base
34
+ # self.site = "http://example.com"
35
+ # self.collection_parser = PostCollection
36
+ # end
37
+ #
38
+ # And the collection parser:
39
+ #
40
+ # class PostCollection < ActiveResource::Collection
41
+ # attr_accessor :next_page
42
+ # def initialize(parsed = {})
43
+ # @elements = parsed['posts']
44
+ # @next_page = parsed['next_page']
45
+ # end
46
+ # end
47
+ #
48
+ # The result from a find method that returns multiple entries will now be a
49
+ # PostParser instance. ActiveResource::Collection includes Enumerable and
50
+ # instances can be iterated over just like an array.
51
+ # @posts = Post.find(:all) # => PostCollection:xxx
52
+ # @posts.next_page # => "/posts.json?page=2"
53
+ # @posts.map(&:id) # =>[1, 3, 5 ...]
54
+ #
55
+ # The initialize method will receive the ActiveResource::Formats parsed result
56
+ # and should set @elements.
57
+ def initialize(elements = [])
58
+ @elements = elements
59
+ end
60
+
61
+ def to_a
62
+ elements
63
+ end
64
+
65
+ def collect!
66
+ return elements unless block_given?
67
+ set = []
68
+ each { |o| set << yield(o) }
69
+ @elements = set
70
+ self
71
+ end
72
+ alias map! collect!
73
+
74
+ def first_or_create(attributes = {})
75
+ first || resource_class.create(original_params.update(attributes))
76
+ rescue NoMethodError
77
+ raise "Cannot create resource from resource type: #{resource_class.inspect}"
78
+ end
79
+
80
+ def first_or_initialize(attributes = {})
81
+ first || resource_class.new(original_params.update(attributes))
82
+ rescue NoMethodError
83
+ raise "Cannot build resource from resource type: #{resource_class.inspect}"
84
+ end
85
+
86
+ def where(clauses = {})
87
+ raise ArgumentError, "expected a clauses Hash, got #{clauses.inspect}" unless clauses.is_a? Hash
88
+ new_clauses = original_params.merge(clauses)
89
+ resource_class.where(new_clauses)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,299 @@
1
+ require 'active_support/core_ext/benchmark'
2
+ require 'active_support/core_ext/uri'
3
+ require 'active_support/core_ext/object/inclusion'
4
+ require 'net/https'
5
+ require 'date'
6
+ require 'time'
7
+ require 'uri'
8
+
9
+ module ActiveResource
10
+ # Class to handle connections to remote web services.
11
+ # This class is used by ActiveResource::Base to interface with REST
12
+ # services.
13
+ class Connection
14
+
15
+ HTTP_FORMAT_HEADER_NAMES = { :get => 'Accept',
16
+ :put => 'Content-Type',
17
+ :post => 'Content-Type',
18
+ :patch => 'Content-Type',
19
+ :delete => 'Accept',
20
+ :head => 'Accept'
21
+ }
22
+
23
+ attr_reader :site, :user, :password, :auth_type, :timeout, :open_timeout, :read_timeout, :proxy, :ssl_options
24
+ attr_accessor :format
25
+
26
+ class << self
27
+ def requests
28
+ @@requests ||= []
29
+ end
30
+ end
31
+
32
+ # The +site+ parameter is required and will set the +site+
33
+ # attribute to the URI for the remote resource service.
34
+ def initialize(site, format = ActiveResource::Formats::JsonFormat)
35
+ raise ArgumentError, 'Missing site URI' unless site
36
+ @proxy = @user = @password = nil
37
+ self.site = site
38
+ self.format = format
39
+ end
40
+
41
+ # Set URI for remote service.
42
+ def site=(site)
43
+ @site = site.is_a?(URI) ? site : URI.parse(site)
44
+ @ssl_options ||= {} if @site.is_a?(URI::HTTPS)
45
+ @user = URI.parser.unescape(@site.user) if @site.user
46
+ @password = URI.parser.unescape(@site.password) if @site.password
47
+ end
48
+
49
+ # Set the proxy for remote service.
50
+ def proxy=(proxy)
51
+ @proxy = proxy.is_a?(URI) ? proxy : URI.parse(proxy)
52
+ end
53
+
54
+ # Sets the user for remote service.
55
+ def user=(user)
56
+ @user = user
57
+ end
58
+
59
+ # Sets the password for remote service.
60
+ def password=(password)
61
+ @password = password
62
+ end
63
+
64
+ # Sets the auth type for remote service.
65
+ def auth_type=(auth_type)
66
+ @auth_type = legitimize_auth_type(auth_type)
67
+ end
68
+
69
+ # Sets the number of seconds after which HTTP requests to the remote service should time out.
70
+ def timeout=(timeout)
71
+ @timeout = timeout
72
+ end
73
+
74
+ # Sets the number of seconds after which HTTP connects to the remote service should time out.
75
+ def open_timeout=(timeout)
76
+ @open_timeout = timeout
77
+ end
78
+
79
+ # Sets the number of seconds after which HTTP read requests to the remote service should time out.
80
+ def read_timeout=(timeout)
81
+ @read_timeout = timeout
82
+ end
83
+
84
+ # Hash of options applied to Net::HTTP instance when +site+ protocol is 'https'.
85
+ def ssl_options=(options)
86
+ @ssl_options = options
87
+ end
88
+
89
+ # Executes a GET request.
90
+ # Used to get (find) resources.
91
+ def get(path, headers = {})
92
+ with_auth { request(:get, path, build_request_headers(headers, :get, self.site.merge(path))) }
93
+ end
94
+
95
+ # Executes a DELETE request (see HTTP protocol documentation if unfamiliar).
96
+ # Used to delete resources.
97
+ def delete(path, headers = {})
98
+ with_auth { request(:delete, path, build_request_headers(headers, :delete, self.site.merge(path))) }
99
+ end
100
+
101
+ # Executes a PATCH request (see HTTP protocol documentation if unfamiliar).
102
+ # Used to update resources.
103
+ def patch(path, body = '', headers = {})
104
+ with_auth { request(:patch, path, body.to_s, build_request_headers(headers, :patch, self.site.merge(path))) }
105
+ end
106
+
107
+ # Executes a PUT request (see HTTP protocol documentation if unfamiliar).
108
+ # Used to update resources.
109
+ def put(path, body = '', headers = {})
110
+ with_auth { request(:put, path, body.to_s, build_request_headers(headers, :put, self.site.merge(path))) }
111
+ end
112
+
113
+ # Executes a POST request.
114
+ # Used to create new resources.
115
+ def post(path, body = '', headers = {})
116
+ with_auth { request(:post, path, body.to_s, build_request_headers(headers, :post, self.site.merge(path))) }
117
+ end
118
+
119
+ # Executes a HEAD request.
120
+ # Used to obtain meta-information about resources, such as whether they exist and their size (via response headers).
121
+ def head(path, headers = {})
122
+ with_auth { request(:head, path, build_request_headers(headers, :head, self.site.merge(path))) }
123
+ end
124
+
125
+ private
126
+ # Makes a request to the remote service.
127
+ def request(method, path, *arguments)
128
+ result = ActiveSupport::Notifications.instrument("request.active_resource") do |payload|
129
+ payload[:method] = method
130
+ payload[:request_uri] = "#{site.scheme}://#{site.host}:#{site.port}#{path}"
131
+ payload[:result] = http.send(method, path, *arguments)
132
+ end
133
+ handle_response(result)
134
+ rescue Timeout::Error => e
135
+ raise TimeoutError.new(e.message)
136
+ rescue OpenSSL::SSL::SSLError => e
137
+ raise SSLError.new(e.message)
138
+ end
139
+
140
+ # Handles response and error codes from the remote service.
141
+ def handle_response(response)
142
+ case response.code.to_i
143
+ when 301, 302, 303, 307
144
+ raise(Redirection.new(response))
145
+ when 200...400
146
+ response
147
+ when 400
148
+ raise(BadRequest.new(response))
149
+ when 401
150
+ raise(UnauthorizedAccess.new(response))
151
+ when 403
152
+ raise(ForbiddenAccess.new(response))
153
+ when 404
154
+ raise(ResourceNotFound.new(response))
155
+ when 405
156
+ raise(MethodNotAllowed.new(response))
157
+ when 409
158
+ raise(ResourceConflict.new(response))
159
+ when 410
160
+ raise(ResourceGone.new(response))
161
+ when 422
162
+ raise(ResourceInvalid.new(response))
163
+ when 401...500
164
+ raise(ClientError.new(response))
165
+ when 500...600
166
+ raise(ServerError.new(response))
167
+ else
168
+ raise(ConnectionError.new(response, "Unknown response code: #{response.code}"))
169
+ end
170
+ end
171
+
172
+ # Creates new Net::HTTP instance for communication with the
173
+ # remote service and resources.
174
+ def http
175
+ configure_http(new_http)
176
+ end
177
+
178
+ def new_http
179
+ if @proxy
180
+ user = URI.parser.unescape(@proxy.user) if @proxy.user
181
+ password = URI.parser.unescape(@proxy.password) if @proxy.password
182
+ Net::HTTP.new(@site.host, @site.port, @proxy.host, @proxy.port, user, password)
183
+ else
184
+ Net::HTTP.new(@site.host, @site.port)
185
+ end
186
+ end
187
+
188
+ def configure_http(http)
189
+ apply_ssl_options(http).tap do |https|
190
+ # Net::HTTP timeouts default to 60 seconds.
191
+ if defined? @timeout
192
+ https.open_timeout = @timeout
193
+ https.read_timeout = @timeout
194
+ end
195
+ https.open_timeout = @open_timeout if defined?(@open_timeout)
196
+ https.read_timeout = @read_timeout if defined?(@read_timeout)
197
+ end
198
+ end
199
+
200
+ def apply_ssl_options(http)
201
+ http.tap do |https|
202
+ # Skip config if site is already a https:// URI.
203
+ if defined? @ssl_options
204
+ http.use_ssl = true
205
+
206
+ # All the SSL options have corresponding http settings.
207
+ @ssl_options.each { |key, value| http.send "#{key}=", value }
208
+ end
209
+ end
210
+ end
211
+
212
+ def default_header
213
+ @default_header ||= {}
214
+ end
215
+
216
+ # Builds headers for request to remote service.
217
+ def build_request_headers(headers, http_method, uri)
218
+ authorization_header(http_method, uri).update(default_header).update(http_format_header(http_method)).update(headers)
219
+ end
220
+
221
+ def response_auth_header
222
+ @response_auth_header ||= ""
223
+ end
224
+
225
+ def with_auth
226
+ retried ||= false
227
+ yield
228
+ rescue UnauthorizedAccess => e
229
+ raise if retried || auth_type != :digest
230
+ @response_auth_header = e.response['WWW-Authenticate']
231
+ retried = true
232
+ retry
233
+ end
234
+
235
+ def authorization_header(http_method, uri)
236
+ if @user || @password
237
+ if auth_type == :digest
238
+ { 'Authorization' => digest_auth_header(http_method, uri) }
239
+ else
240
+ { 'Authorization' => 'Basic ' + ["#{@user}:#{@password}"].pack('m').delete("\r\n") }
241
+ end
242
+ else
243
+ {}
244
+ end
245
+ end
246
+
247
+ def digest_auth_header(http_method, uri)
248
+ params = extract_params_from_response
249
+
250
+ request_uri = uri.path
251
+ request_uri << "?#{uri.query}" if uri.query
252
+
253
+ ha1 = Digest::MD5.hexdigest("#{@user}:#{params['realm']}:#{@password}")
254
+ ha2 = Digest::MD5.hexdigest("#{http_method.to_s.upcase}:#{request_uri}")
255
+
256
+ params.merge!('cnonce' => client_nonce)
257
+ request_digest = Digest::MD5.hexdigest([ha1, params['nonce'], "0", params['cnonce'], params['qop'], ha2].join(":"))
258
+ "Digest #{auth_attributes_for(uri, request_digest, params)}"
259
+ end
260
+
261
+ def client_nonce
262
+ Digest::MD5.hexdigest("%x" % (Time.now.to_i + rand(65535)))
263
+ end
264
+
265
+ def extract_params_from_response
266
+ params = {}
267
+ if response_auth_header =~ /^(\w+) (.*)/
268
+ $2.gsub(/(\w+)="(.*?)"/) { params[$1] = $2 }
269
+ end
270
+ params
271
+ end
272
+
273
+ def auth_attributes_for(uri, request_digest, params)
274
+ auth_attrs =
275
+ [
276
+ %Q(username="#{@user}"),
277
+ %Q(realm="#{params['realm']}"),
278
+ %Q(qop="#{params['qop']}"),
279
+ %Q(uri="#{uri.path}"),
280
+ %Q(nonce="#{params['nonce']}"),
281
+ %Q(nc="0"),
282
+ %Q(cnonce="#{params['cnonce']}"),
283
+ %Q(response="#{request_digest}")]
284
+
285
+ auth_attrs << %Q(opaque="#{params['opaque']}") unless params['opaque'].blank?
286
+ auth_attrs.join(", ")
287
+ end
288
+
289
+ def http_format_header(http_method)
290
+ {HTTP_FORMAT_HEADER_NAMES[http_method] => format.mime_type}
291
+ end
292
+
293
+ def legitimize_auth_type(auth_type)
294
+ return :basic if auth_type.nil?
295
+ auth_type = auth_type.to_sym
296
+ auth_type.in?([:basic, :digest]) ? auth_type : :basic
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,127 @@
1
+ require 'active_support/core_ext/object/blank'
2
+
3
+ module ActiveResource
4
+ # A module to support custom REST methods and sub-resources, allowing you to break out
5
+ # of the "default" REST methods with your own custom resource requests. For example,
6
+ # say you use Rails to expose a REST service and configure your routes with:
7
+ #
8
+ # map.resources :people, :new => { :register => :post },
9
+ # :member => { :promote => :put, :deactivate => :delete }
10
+ # :collection => { :active => :get }
11
+ #
12
+ # This route set creates routes for the following HTTP requests:
13
+ #
14
+ # POST /people/new/register.json # PeopleController.register
15
+ # PATCH/PUT /people/1/promote.json # PeopleController.promote with :id => 1
16
+ # DELETE /people/1/deactivate.json # PeopleController.deactivate with :id => 1
17
+ # GET /people/active.json # PeopleController.active
18
+ #
19
+ # Using this module, Active Resource can use these custom REST methods just like the
20
+ # standard methods.
21
+ #
22
+ # class Person < ActiveResource::Base
23
+ # self.site = "https://37s.sunrise.com"
24
+ # end
25
+ #
26
+ # Person.new(:name => 'Ryan').post(:register) # POST /people/new/register.json
27
+ # # => { :id => 1, :name => 'Ryan' }
28
+ #
29
+ # Person.find(1).put(:promote, :position => 'Manager') # PUT /people/1/promote.json
30
+ # Person.find(1).delete(:deactivate) # DELETE /people/1/deactivate.json
31
+ #
32
+ # Person.get(:active) # GET /people/active.json
33
+ # # => [{:id => 1, :name => 'Ryan'}, {:id => 2, :name => 'Joe'}]
34
+ #
35
+ module CustomMethods
36
+ extend ActiveSupport::Concern
37
+
38
+ included do
39
+ class << self
40
+ alias :orig_delete :delete
41
+
42
+ # Invokes a GET to a given custom REST method. For example:
43
+ #
44
+ # Person.get(:active) # GET /people/active.json
45
+ # # => [{:id => 1, :name => 'Ryan'}, {:id => 2, :name => 'Joe'}]
46
+ #
47
+ # Person.get(:active, :awesome => true) # GET /people/active.json?awesome=true
48
+ # # => [{:id => 1, :name => 'Ryan'}]
49
+ #
50
+ # Note: the objects returned from this method are not automatically converted
51
+ # into ActiveResource::Base instances - they are ordinary Hashes. If you are expecting
52
+ # ActiveResource::Base instances, use the <tt>find</tt> class method with the
53
+ # <tt>:from</tt> option. For example:
54
+ #
55
+ # Person.find(:all, :from => :active)
56
+ def get(custom_method_name, options = {})
57
+ hashified = format.decode(connection.get(custom_method_collection_url(custom_method_name, options), headers).body)
58
+ derooted = Formats.remove_root(hashified)
59
+ derooted.is_a?(Array) ? derooted.map { |e| Formats.remove_root(e) } : derooted
60
+ end
61
+
62
+ def post(custom_method_name, options = {}, body = '')
63
+ connection.post(custom_method_collection_url(custom_method_name, options), body, headers)
64
+ end
65
+
66
+ def patch(custom_method_name, options = {}, body = '')
67
+ connection.patch(custom_method_collection_url(custom_method_name, options), body, headers)
68
+ end
69
+
70
+ def put(custom_method_name, options = {}, body = '')
71
+ connection.put(custom_method_collection_url(custom_method_name, options), body, headers)
72
+ end
73
+
74
+ def delete(custom_method_name, options = {})
75
+ # Need to jump through some hoops to retain the original class 'delete' method
76
+ if custom_method_name.is_a?(Symbol)
77
+ connection.delete(custom_method_collection_url(custom_method_name, options), headers)
78
+ else
79
+ orig_delete(custom_method_name, options)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ module ClassMethods
86
+ def custom_method_collection_url(method_name, options = {})
87
+ prefix_options, query_options = split_options(options)
88
+ "#{prefix(prefix_options)}#{collection_name}/#{method_name}#{format_extension}#{query_string(query_options)}"
89
+ end
90
+ end
91
+
92
+ def get(method_name, options = {})
93
+ self.class.format.decode(connection.get(custom_method_element_url(method_name, options), self.class.headers).body)
94
+ end
95
+
96
+ def post(method_name, options = {}, body = nil)
97
+ request_body = body.blank? ? encode : body
98
+ if new?
99
+ connection.post(custom_method_new_element_url(method_name, options), request_body, self.class.headers)
100
+ else
101
+ connection.post(custom_method_element_url(method_name, options), request_body, self.class.headers)
102
+ end
103
+ end
104
+
105
+ def patch(method_name, options = {}, body = '')
106
+ connection.patch(custom_method_element_url(method_name, options), body, self.class.headers)
107
+ end
108
+
109
+ def put(method_name, options = {}, body = '')
110
+ connection.put(custom_method_element_url(method_name, options), body, self.class.headers)
111
+ end
112
+
113
+ def delete(method_name, options = {})
114
+ connection.delete(custom_method_element_url(method_name, options), self.class.headers)
115
+ end
116
+
117
+
118
+ private
119
+ def custom_method_element_url(method_name, options = {})
120
+ "#{self.class.prefix(prefix_options)}#{self.class.collection_name}/#{id}/#{method_name}#{self.class.format_extension}#{self.class.__send__(:query_string, options)}"
121
+ end
122
+
123
+ def custom_method_new_element_url(method_name, options = {})
124
+ "#{self.class.prefix(prefix_options)}#{self.class.collection_name}/new/#{method_name}#{self.class.format_extension}#{self.class.__send__(:query_string, options)}"
125
+ end
126
+ end
127
+ end