api_resource 0.2.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.
Files changed (59) hide show
  1. data/.document +5 -0
  2. data/.rspec +3 -0
  3. data/Gemfile +29 -0
  4. data/Gemfile.lock +152 -0
  5. data/Guardfile +22 -0
  6. data/LICENSE.txt +20 -0
  7. data/README.rdoc +19 -0
  8. data/Rakefile +49 -0
  9. data/VERSION +1 -0
  10. data/api_resource.gemspec +154 -0
  11. data/lib/api_resource.rb +129 -0
  12. data/lib/api_resource/association_activation.rb +19 -0
  13. data/lib/api_resource/associations.rb +169 -0
  14. data/lib/api_resource/associations/association_proxy.rb +115 -0
  15. data/lib/api_resource/associations/belongs_to_remote_object_proxy.rb +16 -0
  16. data/lib/api_resource/associations/dynamic_resource_scope.rb +23 -0
  17. data/lib/api_resource/associations/has_many_remote_object_proxy.rb +16 -0
  18. data/lib/api_resource/associations/has_one_remote_object_proxy.rb +24 -0
  19. data/lib/api_resource/associations/multi_argument_resource_scope.rb +15 -0
  20. data/lib/api_resource/associations/multi_object_proxy.rb +73 -0
  21. data/lib/api_resource/associations/related_object_hash.rb +12 -0
  22. data/lib/api_resource/associations/relation_scope.rb +30 -0
  23. data/lib/api_resource/associations/resource_scope.rb +34 -0
  24. data/lib/api_resource/associations/scope.rb +107 -0
  25. data/lib/api_resource/associations/single_object_proxy.rb +81 -0
  26. data/lib/api_resource/attributes.rb +162 -0
  27. data/lib/api_resource/base.rb +587 -0
  28. data/lib/api_resource/callbacks.rb +49 -0
  29. data/lib/api_resource/connection.rb +171 -0
  30. data/lib/api_resource/core_extensions.rb +7 -0
  31. data/lib/api_resource/custom_methods.rb +119 -0
  32. data/lib/api_resource/exceptions.rb +87 -0
  33. data/lib/api_resource/formats.rb +14 -0
  34. data/lib/api_resource/formats/json_format.rb +25 -0
  35. data/lib/api_resource/formats/xml_format.rb +36 -0
  36. data/lib/api_resource/local.rb +12 -0
  37. data/lib/api_resource/log_subscriber.rb +15 -0
  38. data/lib/api_resource/mocks.rb +269 -0
  39. data/lib/api_resource/model_errors.rb +86 -0
  40. data/lib/api_resource/observing.rb +29 -0
  41. data/lib/api_resource/railtie.rb +22 -0
  42. data/lib/api_resource/scopes.rb +45 -0
  43. data/spec/lib/associations_spec.rb +656 -0
  44. data/spec/lib/attributes_spec.rb +121 -0
  45. data/spec/lib/base_spec.rb +504 -0
  46. data/spec/lib/callbacks_spec.rb +68 -0
  47. data/spec/lib/connection_spec.rb +76 -0
  48. data/spec/lib/local_spec.rb +20 -0
  49. data/spec/lib/mocks_spec.rb +28 -0
  50. data/spec/lib/model_errors_spec.rb +29 -0
  51. data/spec/spec_helper.rb +36 -0
  52. data/spec/support/mocks/association_mocks.rb +46 -0
  53. data/spec/support/mocks/error_resource_mocks.rb +21 -0
  54. data/spec/support/mocks/test_resource_mocks.rb +43 -0
  55. data/spec/support/requests/association_requests.rb +14 -0
  56. data/spec/support/requests/error_resource_requests.rb +25 -0
  57. data/spec/support/requests/test_resource_requests.rb +31 -0
  58. data/spec/support/test_resource.rb +64 -0
  59. metadata +334 -0
@@ -0,0 +1,49 @@
1
+ module ApiResource
2
+
3
+ module Callbacks
4
+
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+
9
+ extend ActiveModel::Callbacks
10
+
11
+ define_model_callbacks :save, :create, :update, :destroy
12
+
13
+ [:save, :create, :update, :destroy].each do |action|
14
+ alias_method_chain action, :callbacks
15
+ end
16
+
17
+ end
18
+
19
+ module InstanceMethods
20
+
21
+ def save_with_callbacks(*args)
22
+ _run_save_callbacks do
23
+ save_without_callbacks(*args)
24
+ end
25
+ end
26
+
27
+ def create_with_callbacks(*args)
28
+ _run_create_callbacks do
29
+ create_without_callbacks(*args)
30
+ end
31
+ end
32
+
33
+ def update_with_callbacks(*args)
34
+ _run_update_callbacks do
35
+ update_without_callbacks(*args)
36
+ end
37
+ end
38
+
39
+ def destroy_with_callbacks(*args)
40
+ _run_destroy_callbacks do
41
+ destroy_without_callbacks(*args)
42
+ end
43
+ end
44
+
45
+ end
46
+
47
+ end
48
+
49
+ end
@@ -0,0 +1,171 @@
1
+ require 'active_support/core_ext/benchmark'
2
+ require 'rest_client'
3
+ require 'net/https'
4
+ require 'date'
5
+ require 'time'
6
+ require 'uri'
7
+
8
+ module ApiResource
9
+ # Class to handle connections to remote web services.
10
+ # This class is used by ActiveResource::Base to interface with REST
11
+ # services.
12
+ class Connection
13
+
14
+ HTTP_FORMAT_HEADER_NAMES = {
15
+ :get => 'Accept',
16
+ :put => 'Content-Type',
17
+ :post => 'Content-Type',
18
+ :delete => 'Accept',
19
+ :head => 'Accept'
20
+ }
21
+
22
+ attr_reader :site, :user, :password, :auth_type, :timeout, :proxy, :ssl_options
23
+ attr_accessor :format
24
+
25
+ class << self
26
+ def requests
27
+ @@requests ||= []
28
+ end
29
+ end
30
+
31
+ # The +site+ parameter is required and will set the +site+
32
+ # attribute to the URI for the remote resource service.
33
+ def initialize(site, format = ApiResource::Formats::JsonFormat)
34
+ raise ArgumentError, 'Missing site URI' unless site
35
+ @user = @password = nil
36
+ @uri_parser = URI.const_defined?(:Parser) ? URI::Parser.new : URI
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_parser.parse(site)
44
+ @user = @uri_parser.unescape(@site.user) if @site.user
45
+ @password = @uri_parser.unescape(@site.password) if @site.password
46
+ end
47
+
48
+ # Sets the number of seconds after which HTTP requests to the remote service should time out.
49
+ def timeout=(timeout)
50
+ @timeout = timeout
51
+ end
52
+
53
+ def get(path, headers = {})
54
+ format.decode(request(:get, path, build_request_headers(headers, :get, self.site.merge(path))))
55
+ end
56
+
57
+ def delete(path, headers = {})
58
+ request(:delete, path, build_request_headers(headers, :delete, self.site.merge(path)))
59
+ return true
60
+ end
61
+
62
+ def head(path, headers = {})
63
+ request(:head, path, build_request_headers(headers, :head, self.site.merge(path)))
64
+ end
65
+
66
+
67
+ def put(path, body = {}, headers = {})
68
+ # If there's a file to send then we can't use JSON or XML
69
+ if !body.is_a?(String) && RestClient::Payload.has_file?(body)
70
+ format.decode(request(:put, path, body, build_request_headers(headers, :put, self.site.merge(path))))
71
+ else
72
+ format.decode(request(:put, path, body, build_request_headers(headers, :put, self.site.merge(path))))
73
+ end
74
+ end
75
+
76
+ def post(path, body = {}, headers = {})
77
+ if !body.is_a?(String) && RestClient::Payload.has_file?(body)
78
+ format.decode(request(:post, path, body, build_request_headers(headers, :post, self.site.merge(path))))
79
+ else
80
+ format.decode(request(:post, path, body, build_request_headers(headers, :post, self.site.merge(path))))
81
+ end
82
+ end
83
+
84
+
85
+ private
86
+ # Makes a request to the remote service.
87
+ def request(method, path, *arguments)
88
+ handle_response(path) do
89
+ ActiveSupport::Notifications.instrument("request.api_resource") do |payload|
90
+
91
+ # debug logging
92
+ ApiResource.logger.debug("#{method.to_s.upcase} #{path}")
93
+
94
+ payload[:method] = method
95
+ payload[:request_uri] = "#{site.scheme}://#{site.host}:#{site.port}#{path}"
96
+ payload[:result] = http(path).send(method, *arguments)
97
+ end
98
+ end
99
+ end
100
+
101
+ # Handles response and error codes from the remote service.
102
+ def handle_response(path, &block)
103
+ begin
104
+ result = yield
105
+ rescue RestClient::RequestTimeout
106
+ raise ApiResource::RequestTimeout.new("Request Time Out - Accessing #{path}}")
107
+ rescue Exception => error
108
+ if error.respond_to?(:http_code)
109
+ ApiResource.logger.error("#{self} accessing #{path}")
110
+ ApiResource.logger.error(error.message)
111
+ result = error.response
112
+ else
113
+ raise ApiResource::ConnectionError.new(nil, :message => "Unknown error #{error}")
114
+ end
115
+ end
116
+ return propogate_response_or_error(result, result.code)
117
+ end
118
+
119
+ def propogate_response_or_error(response, code)
120
+ case code.to_i
121
+ when 301,302
122
+ raise ApiResource::Redirection.new(response)
123
+ when 200..400
124
+ response.body
125
+ when 400
126
+ raise ApiResource::BadRequest.new(response)
127
+ when 401
128
+ raise ApiResource::UnauthorizedAccess.new(response)
129
+ when 403
130
+ raise ApiResource::ForbiddenAccess.new(response)
131
+ when 404
132
+ raise ApiResource::ResourceNotFound.new(response)
133
+ when 405
134
+ raise ApiResource::MethodNotAllowed.new(response)
135
+ when 406
136
+ raise ApiResource::NotAccepatable.new(response)
137
+ when 409
138
+ raise ApiResource::ResourceNotFound.new(response)
139
+ when 410
140
+ raise ApiResource::ResourceGone.new(response)
141
+ when 422
142
+ raise ApiResource::UnprocessableEntity.new(response)
143
+ when 401..500
144
+ raise ApiResource::ClientError.new(response)
145
+ when 500..600
146
+ raise ApiResource::ServerError.new(response)
147
+ else
148
+ raise ApiResource::ConnectionError.new(response, :message => "Unknown response code: #{code}")
149
+ end
150
+ end
151
+
152
+ # Creates new Net::HTTP instance for communication with the
153
+ # remote service and resources.
154
+ def http(path)
155
+ unless path =~ /\./
156
+ path += ".#{self.format.extension}"
157
+ end
158
+ RestClient::Resource.new("#{site.scheme}://#{site.host}:#{site.port}#{path}", {:timeout => ApiResource.timeout, :open_timeout => ApiResource.open_timeout})
159
+ end
160
+
161
+ def build_request_headers(headers, verb, uri)
162
+ http_format_header(verb).update(headers)
163
+ end
164
+
165
+ def http_format_header(verb)
166
+ {}.tap do |ret|
167
+ ret[HTTP_FORMAT_HEADER_NAMES[verb]] = format.mime_type
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,7 @@
1
+ class Array
2
+
3
+ def symbolize_array
4
+ self.collect{|item| item.to_s.to_sym}
5
+ end
6
+
7
+ end
@@ -0,0 +1,119 @@
1
+ require 'active_support/core_ext/object/blank'
2
+
3
+ module ApiResource
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.xml # PeopleController.register
15
+ # PUT /people/1/promote.xml # PeopleController.promote with :id => 1
16
+ # DELETE /people/1/deactivate.xml # PeopleController.deactivate with :id => 1
17
+ # GET /people/active.xml # 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 = "http://37s.sunrise.i:3000"
24
+ # end
25
+ #
26
+ # Person.new(:name => 'Ryan).post(:register) # POST /people/new/register.xml
27
+ # # => { :id => 1, :name => 'Ryan' }
28
+ #
29
+ # Person.find(1).put(:promote, :position => 'Manager') # PUT /people/1/promote.xml
30
+ # Person.find(1).delete(:deactivate) # DELETE /people/1/deactivate.xml
31
+ #
32
+ # Person.get(:active) # GET /people/active.xml
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.xml
45
+ # # => [{:id => 1, :name => 'Ryan'}, {:id => 2, :name => 'Joe'}]
46
+ #
47
+ # Person.get(:active, :awesome => true) # GET /people/active.xml?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
+ connection.get(custom_method_collection_url(custom_method_name, options), headers)
58
+ end
59
+
60
+ def post(custom_method_name, options = {}, body = '')
61
+ connection.post(custom_method_collection_url(custom_method_name, options), body, headers)
62
+ end
63
+
64
+ def put(custom_method_name, options = {}, body = '')
65
+ connection.put(custom_method_collection_url(custom_method_name, options), body, headers)
66
+ end
67
+
68
+ def delete(custom_method_name, options = {})
69
+ # Need to jump through some hoops to retain the original class 'delete' method
70
+ if custom_method_name.is_a?(Symbol)
71
+ connection.delete(custom_method_collection_url(custom_method_name, options), headers)
72
+ else
73
+ orig_delete(custom_method_name, options)
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ module ClassMethods
80
+ def custom_method_collection_url(method_name, options = {})
81
+ prefix_options, query_options = split_options(options)
82
+ "#{prefix(prefix_options)}#{collection_name}/#{method_name}.#{format.extension}#{query_string(query_options)}"
83
+ end
84
+ end
85
+
86
+ module InstanceMethods
87
+ def get(method_name, options = {})
88
+ connection.get(custom_method_element_url(method_name, options), self.class.headers)
89
+ end
90
+
91
+ def post(method_name, options = {}, body = nil)
92
+ request_body = body.blank? ? encode : body
93
+ if new?
94
+ connection.post(custom_method_new_element_url(method_name, options), request_body, self.class.headers)
95
+ else
96
+ connection.post(custom_method_element_url(method_name, options), request_body, self.class.headers)
97
+ end
98
+ end
99
+
100
+ def put(method_name, options = {}, body = '')
101
+ connection.put(custom_method_element_url(method_name, options), body, self.class.headers)
102
+ end
103
+
104
+ def delete(method_name, options = {})
105
+ connection.delete(custom_method_element_url(method_name, options), self.class.headers)
106
+ end
107
+
108
+
109
+ private
110
+ def custom_method_element_url(method_name, options = {})
111
+ "#{self.class.prefix(prefix_options)}#{self.class.collection_name}/#{id}/#{method_name}.#{self.class.format.extension}#{self.class.__send__(:query_string, options)}"
112
+ end
113
+
114
+ def custom_method_new_element_url(method_name, options = {})
115
+ "#{self.class.prefix(prefix_options)}#{self.class.collection_name}/new/#{method_name}.#{self.class.format.extension}#{self.class.__send__(:query_string, options)}"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,87 @@
1
+ module ApiResource
2
+ class ConnectionError < StandardError # :nodoc:
3
+
4
+ cattr_accessor :http_code
5
+
6
+ attr_reader :response
7
+
8
+ def initialize(response, options = {})
9
+ @response = response
10
+ @message = options[:message]
11
+ @path = options[:path]
12
+ end
13
+
14
+ def to_s
15
+ message = "Failed."
16
+ message << " Response code = #{response.code}." if response.respond_to?(:code)
17
+ message << " Response message = #{response.message}." if response.respond_to?(:message)
18
+ message << "\n#{@message}"
19
+ message << "\n#{@path}"
20
+ end
21
+
22
+ def http_code
23
+ self.class.http_code
24
+ end
25
+
26
+ end
27
+
28
+ # Raised when a Timeout::Error occurs.
29
+ class RequestTimeout < ConnectionError
30
+ def initialize(message)
31
+ @message = message
32
+ end
33
+ def to_s; @message ;end
34
+ end
35
+
36
+ # Raised when a OpenSSL::SSL::SSLError occurs.
37
+ class SSLError < ConnectionError
38
+ def initialize(message)
39
+ @message = message
40
+ end
41
+ def to_s; @message ;end
42
+ end
43
+
44
+ # 3xx Redirection
45
+ class Redirection < ConnectionError # :nodoc:
46
+ def to_s; response['Location'] ? "#{super} => #{response['Location']}" : super; end
47
+ end
48
+
49
+ # 4xx Client Error
50
+ class ClientError < ConnectionError; end # :nodoc:
51
+
52
+ # 400 Bad Request
53
+ class BadRequest < ClientError; self.http_code = 400; end # :nodoc
54
+
55
+ # 401 Unauthorized
56
+ class UnauthorizedAccess < ClientError; self.http_code = 401; end # :nodoc
57
+
58
+ # 403 Forbidden
59
+ class ForbiddenAccess < ClientError; self.http_code = 403; end # :nodoc
60
+
61
+ # 404 Not Found
62
+ class ResourceNotFound < ClientError; self.http_code = 404; end # :nodoc:
63
+
64
+ # 406 Not Acceptable
65
+ class NotAcceptable < ClientError; self.http_code = 406; end
66
+
67
+ # 409 Conflict
68
+ class ResourceConflict < ClientError; self.http_code = 409; end # :nodoc:
69
+
70
+ # 410 Gone
71
+ class ResourceGone < ClientError; self.http_code = 410; end # :nodoc:
72
+
73
+ class UnprocessableEntity < ClientError; self.http_code = 422; end
74
+
75
+ # 5xx Server Error
76
+ class ServerError < ConnectionError; self.http_code = 400; end # :nodoc:
77
+
78
+ # 405 Method Not Allowed
79
+ class MethodNotAllowed < ClientError # :nodoc:
80
+
81
+ self.http_code = 405
82
+
83
+ def allowed_methods
84
+ @response['Allow'].split(',').map { |verb| verb.strip.downcase.to_sym }
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,14 @@
1
+ module ApiResource
2
+ module Formats
3
+ autoload :XmlFormat, 'api_resource/formats/xml_format'
4
+ autoload :JsonFormat, 'api_resource/formats/json_format'
5
+
6
+ # Lookup the format class from a mime type reference symbol. Example:
7
+ #
8
+ # ActiveResource::Formats[:xml] # => ActiveResource::Formats::XmlFormat
9
+ # ActiveResource::Formats[:json] # => ActiveResource::Formats::JsonFormat
10
+ def self.[](mime_type_reference)
11
+ ApiResource::Formats.const_get(ActiveSupport::Inflector.camelize(mime_type_reference.to_s) + "Format")
12
+ end
13
+ end
14
+ end