resource 0.1.0

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 (39) hide show
  1. data/.document +5 -0
  2. data/.rspec +1 -0
  3. data/Gemfile +26 -0
  4. data/Guardfile +22 -0
  5. data/LICENSE.txt +20 -0
  6. data/README.rdoc +19 -0
  7. data/Rakefile +49 -0
  8. data/VERSION +1 -0
  9. data/lib/api_resource.rb +51 -0
  10. data/lib/api_resource/associations.rb +472 -0
  11. data/lib/api_resource/attributes.rb +154 -0
  12. data/lib/api_resource/base.rb +517 -0
  13. data/lib/api_resource/callbacks.rb +49 -0
  14. data/lib/api_resource/connection.rb +162 -0
  15. data/lib/api_resource/core_extensions.rb +7 -0
  16. data/lib/api_resource/custom_methods.rb +119 -0
  17. data/lib/api_resource/exceptions.rb +74 -0
  18. data/lib/api_resource/formats.rb +14 -0
  19. data/lib/api_resource/formats/json_format.rb +25 -0
  20. data/lib/api_resource/formats/xml_format.rb +36 -0
  21. data/lib/api_resource/log_subscriber.rb +15 -0
  22. data/lib/api_resource/mocks.rb +249 -0
  23. data/lib/api_resource/model_errors.rb +86 -0
  24. data/lib/api_resource/observing.rb +29 -0
  25. data/resource.gemspec +125 -0
  26. data/spec/lib/associations_spec.rb +412 -0
  27. data/spec/lib/attributes_spec.rb +109 -0
  28. data/spec/lib/base_spec.rb +454 -0
  29. data/spec/lib/callbacks_spec.rb +68 -0
  30. data/spec/lib/model_errors_spec.rb +29 -0
  31. data/spec/spec_helper.rb +32 -0
  32. data/spec/support/mocks/association_mocks.rb +18 -0
  33. data/spec/support/mocks/error_resource_mocks.rb +21 -0
  34. data/spec/support/mocks/test_resource_mocks.rb +23 -0
  35. data/spec/support/requests/association_requests.rb +14 -0
  36. data/spec/support/requests/error_resource_requests.rb +25 -0
  37. data/spec/support/requests/test_resource_requests.rb +31 -0
  38. data/spec/support/test_resource.rb +19 -0
  39. metadata +277 -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,162 @@
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 = { :get => 'Accept',
15
+ :put => 'Content-Type',
16
+ :post => 'Content-Type',
17
+ :delete => 'Accept',
18
+ :head => 'Accept'
19
+ }
20
+
21
+ attr_reader :site, :user, :password, :auth_type, :timeout, :proxy, :ssl_options
22
+ attr_accessor :format
23
+
24
+ class << self
25
+ def requests
26
+ @@requests ||= []
27
+ end
28
+ end
29
+
30
+ # The +site+ parameter is required and will set the +site+
31
+ # attribute to the URI for the remote resource service.
32
+ def initialize(site, format = ApiResource::Formats::JsonFormat)
33
+ raise ArgumentError, 'Missing site URI' unless site
34
+ @user = @password = nil
35
+ @uri_parser = URI.const_defined?(:Parser) ? URI::Parser.new : URI
36
+ self.site = site
37
+ self.format = format
38
+ end
39
+
40
+ # Set URI for remote service.
41
+ def site=(site)
42
+ @site = site.is_a?(URI) ? site : @uri_parser.parse(site)
43
+ @user = @uri_parser.unescape(@site.user) if @site.user
44
+ @password = @uri_parser.unescape(@site.password) if @site.password
45
+ end
46
+
47
+ # Sets the number of seconds after which HTTP requests to the remote service should time out.
48
+ def timeout=(timeout)
49
+ @timeout = timeout
50
+ end
51
+
52
+ def get(path, headers = {})
53
+ format.decode(request(:get, path, build_request_headers(headers, :get, self.site.merge(path))))
54
+ end
55
+
56
+ def delete(path, headers = {})
57
+ request(:delete, path, build_request_headers(headers, :delete, self.site.merge(path)))
58
+ return true
59
+ end
60
+
61
+ def head(path, headers = {})
62
+ request(:head, path, build_request_headers(headers, :head, self.site.merge(path)))
63
+ end
64
+
65
+
66
+ def put(path, body = {}, headers = {})
67
+ # If there's a file to send then we can't use JSON or XML
68
+ if !body.is_a?(String) && RestClient::Payload.has_file?(body)
69
+ format.decode(request(:put, path, body, build_request_headers(headers, :put, self.site.merge(path))))
70
+ else
71
+ format.decode(request(:put, path, body, build_request_headers(headers, :put, self.site.merge(path))))
72
+ end
73
+ end
74
+
75
+ def post(path, body = {}, headers = {})
76
+ if !body.is_a?(String) && RestClient::Payload.has_file?(body)
77
+ format.decode(request(:post, path, body, build_request_headers(headers, :post, self.site.merge(path))))
78
+ else
79
+ format.decode(request(:post, path, body, build_request_headers(headers, :post, self.site.merge(path))))
80
+ end
81
+ end
82
+
83
+
84
+ private
85
+ # Makes a request to the remote service.
86
+ def request(method, path, *arguments)
87
+ handle_response do
88
+ ActiveSupport::Notifications.instrument("request.api_resource") do |payload|
89
+ payload[:method] = method
90
+ payload[:request_uri] = "#{site.scheme}://#{site.host}:#{site.port}#{path}"
91
+ payload[:result] = http(path).send(method, *arguments)
92
+ end
93
+ end
94
+ end
95
+
96
+ # Handles response and error codes from the remote service.
97
+ def handle_response(&block)
98
+ begin
99
+ result = yield
100
+ rescue RestClient::RequestTimeout
101
+ raise ApiResource::RequestTimeout.new("Request Time Out")
102
+ rescue Exception => error
103
+ if error.respond_to?(:http_code)
104
+ result = error.response
105
+ else
106
+ raise "Unknown error #{error}"
107
+ end
108
+ end
109
+ return propogate_response_or_error(result, result.code)
110
+ end
111
+
112
+ def propogate_response_or_error(response, code)
113
+ case code.to_i
114
+ when 301,302
115
+ raise ApiResource::Redirection.new(response)
116
+ when 200..400
117
+ response.body
118
+ when 400
119
+ raise ApiResource::BadRequest.new(response)
120
+ when 401
121
+ raise ApiResource::UnauthorizedAccess.new(response)
122
+ when 403
123
+ raise ApiResource::ForbiddenAccess.new(response)
124
+ when 404
125
+ raise ApiResource::ResourceNotFound.new(response)
126
+ when 405
127
+ raise ApiResource::MethodNotAllowed.new(response)
128
+ when 406
129
+ raise ApiResource::NotAccepatable.new(response)
130
+ when 409
131
+ raise ApiResource::ResourceNotFound.new(response)
132
+ when 410
133
+ raise ApiResource::ResourceGone.new(response)
134
+ when 422
135
+ raise ApiResource::UnprocessableEntity.new(response)
136
+ when 401..500
137
+ raise ApiResource::ClientError.new(response)
138
+ when 500..600
139
+ raise ApiResource::ServerError.new(response)
140
+ else
141
+ raise ApiResource::ConnectionError.new(response, "Unknown response code: #{code}")
142
+ end
143
+ end
144
+
145
+ # Creates new Net::HTTP instance for communication with the
146
+ # remote service and resources.
147
+ def http(path)
148
+ unless path =~ /\./
149
+ path += ".#{self.format.extension}"
150
+ end
151
+ RestClient::Resource.new "#{site.scheme}://#{site.host}:#{site.port}#{path}", :timeout => @timeout ? @timeout : 10, :open_timeout => @timeout ? @timeout : 10
152
+ end
153
+
154
+ def build_request_headers(headers, verb, uri)
155
+ http_format_header(verb).update(headers)
156
+ end
157
+
158
+ def http_format_header(verb)
159
+ {HTTP_FORMAT_HEADER_NAMES[verb] => format.mime_type}
160
+ end
161
+ end
162
+ 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,74 @@
1
+ module ApiResource
2
+ class ConnectionError < StandardError # :nodoc:
3
+ attr_reader :response
4
+
5
+ def initialize(response, message = nil)
6
+ @response = response
7
+ @message = message
8
+ end
9
+
10
+ def to_s
11
+ message = "Failed."
12
+ message << " Response code = #{response.code}." if response.respond_to?(:code)
13
+ message << " Response message = #{response.message}." if response.respond_to?(:message)
14
+ message
15
+ end
16
+ end
17
+
18
+ # Raised when a Timeout::Error occurs.
19
+ class RequestTimeout < ConnectionError
20
+ def initialize(message)
21
+ @message = message
22
+ end
23
+ def to_s; @message ;end
24
+ end
25
+
26
+ # Raised when a OpenSSL::SSL::SSLError occurs.
27
+ class SSLError < ConnectionError
28
+ def initialize(message)
29
+ @message = message
30
+ end
31
+ def to_s; @message ;end
32
+ end
33
+
34
+ # 3xx Redirection
35
+ class Redirection < ConnectionError # :nodoc:
36
+ def to_s; response['Location'] ? "#{super} => #{response['Location']}" : super; end
37
+ end
38
+
39
+ # 4xx Client Error
40
+ class ClientError < ConnectionError; end # :nodoc:
41
+
42
+ # 400 Bad Request
43
+ class BadRequest < ClientError; end # :nodoc
44
+
45
+ # 401 Unauthorized
46
+ class UnauthorizedAccess < ClientError; end # :nodoc
47
+
48
+ # 403 Forbidden
49
+ class ForbiddenAccess < ClientError; end # :nodoc
50
+
51
+ # 404 Not Found
52
+ class ResourceNotFound < ClientError; end # :nodoc:
53
+
54
+ # 406 Not Acceptable
55
+ class NotAcceptable < ClientError; end
56
+
57
+ # 409 Conflict
58
+ class ResourceConflict < ClientError; end # :nodoc:
59
+
60
+ # 410 Gone
61
+ class ResourceGone < ClientError; end # :nodoc:
62
+
63
+ class UnprocessableEntity < ClientError; end
64
+
65
+ # 5xx Server Error
66
+ class ServerError < ConnectionError; end # :nodoc:
67
+
68
+ # 405 Method Not Allowed
69
+ class MethodNotAllowed < ClientError # :nodoc:
70
+ def allowed_methods
71
+ @response['Allow'].split(',').map { |verb| verb.strip.downcase.to_sym }
72
+ end
73
+ end
74
+ 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
@@ -0,0 +1,25 @@
1
+ require 'active_support/json'
2
+
3
+ module ApiResource
4
+ module Formats
5
+ module JsonFormat
6
+ extend self
7
+
8
+ def extension
9
+ "json"
10
+ end
11
+
12
+ def mime_type
13
+ "application/json"
14
+ end
15
+
16
+ def encode(hash, options = nil)
17
+ ActiveSupport::JSON.encode(hash, options)
18
+ end
19
+
20
+ def decode(json)
21
+ ActiveSupport::JSON.decode(json)
22
+ end
23
+ end
24
+ end
25
+ end