resource 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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