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.
- data/.document +5 -0
- data/.rspec +3 -0
- data/Gemfile +29 -0
- data/Gemfile.lock +152 -0
- data/Guardfile +22 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +19 -0
- data/Rakefile +49 -0
- data/VERSION +1 -0
- data/api_resource.gemspec +154 -0
- data/lib/api_resource.rb +129 -0
- data/lib/api_resource/association_activation.rb +19 -0
- data/lib/api_resource/associations.rb +169 -0
- data/lib/api_resource/associations/association_proxy.rb +115 -0
- data/lib/api_resource/associations/belongs_to_remote_object_proxy.rb +16 -0
- data/lib/api_resource/associations/dynamic_resource_scope.rb +23 -0
- data/lib/api_resource/associations/has_many_remote_object_proxy.rb +16 -0
- data/lib/api_resource/associations/has_one_remote_object_proxy.rb +24 -0
- data/lib/api_resource/associations/multi_argument_resource_scope.rb +15 -0
- data/lib/api_resource/associations/multi_object_proxy.rb +73 -0
- data/lib/api_resource/associations/related_object_hash.rb +12 -0
- data/lib/api_resource/associations/relation_scope.rb +30 -0
- data/lib/api_resource/associations/resource_scope.rb +34 -0
- data/lib/api_resource/associations/scope.rb +107 -0
- data/lib/api_resource/associations/single_object_proxy.rb +81 -0
- data/lib/api_resource/attributes.rb +162 -0
- data/lib/api_resource/base.rb +587 -0
- data/lib/api_resource/callbacks.rb +49 -0
- data/lib/api_resource/connection.rb +171 -0
- data/lib/api_resource/core_extensions.rb +7 -0
- data/lib/api_resource/custom_methods.rb +119 -0
- data/lib/api_resource/exceptions.rb +87 -0
- data/lib/api_resource/formats.rb +14 -0
- data/lib/api_resource/formats/json_format.rb +25 -0
- data/lib/api_resource/formats/xml_format.rb +36 -0
- data/lib/api_resource/local.rb +12 -0
- data/lib/api_resource/log_subscriber.rb +15 -0
- data/lib/api_resource/mocks.rb +269 -0
- data/lib/api_resource/model_errors.rb +86 -0
- data/lib/api_resource/observing.rb +29 -0
- data/lib/api_resource/railtie.rb +22 -0
- data/lib/api_resource/scopes.rb +45 -0
- data/spec/lib/associations_spec.rb +656 -0
- data/spec/lib/attributes_spec.rb +121 -0
- data/spec/lib/base_spec.rb +504 -0
- data/spec/lib/callbacks_spec.rb +68 -0
- data/spec/lib/connection_spec.rb +76 -0
- data/spec/lib/local_spec.rb +20 -0
- data/spec/lib/mocks_spec.rb +28 -0
- data/spec/lib/model_errors_spec.rb +29 -0
- data/spec/spec_helper.rb +36 -0
- data/spec/support/mocks/association_mocks.rb +46 -0
- data/spec/support/mocks/error_resource_mocks.rb +21 -0
- data/spec/support/mocks/test_resource_mocks.rb +43 -0
- data/spec/support/requests/association_requests.rb +14 -0
- data/spec/support/requests/error_resource_requests.rb +25 -0
- data/spec/support/requests/test_resource_requests.rb +31 -0
- data/spec/support/test_resource.rb +64 -0
- 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,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
|