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,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
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'active_support/core_ext/hash/conversions'
|
2
|
+
|
3
|
+
module ApiResource
|
4
|
+
module Formats
|
5
|
+
module XmlFormat
|
6
|
+
extend self
|
7
|
+
|
8
|
+
def extension
|
9
|
+
"xml"
|
10
|
+
end
|
11
|
+
|
12
|
+
def mime_type
|
13
|
+
"application/xml"
|
14
|
+
end
|
15
|
+
|
16
|
+
def encode(hash, options={})
|
17
|
+
hash.to_xml(options)
|
18
|
+
end
|
19
|
+
|
20
|
+
def decode(xml)
|
21
|
+
from_xml_data(Hash.from_xml(xml))
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
# Manipulate from_xml Hash, because xml_simple is not exactly what we
|
26
|
+
# want for Active Resource.
|
27
|
+
def from_xml_data(data)
|
28
|
+
if data.is_a?(Hash) && data.keys.size == 1
|
29
|
+
data.values.first
|
30
|
+
else
|
31
|
+
data
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module ApiResource
|
2
|
+
class LogSubscriber < ActiveSupport::LogSubscriber
|
3
|
+
def request(event)
|
4
|
+
result = event[:payload]
|
5
|
+
info "#{event.payload[:method].to_s.upcase} #{even.payload[:request_uri]}"
|
6
|
+
info "--> %d %s %d (%.1fms)" % [result.code, result.message, result.body.to_s.length, event.duration]
|
7
|
+
end
|
8
|
+
|
9
|
+
def logger
|
10
|
+
Rails.logger
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
ApiResource::LogSubscriber.attach_to :api_resource
|
@@ -0,0 +1,269 @@
|
|
1
|
+
require 'api_resource'
|
2
|
+
|
3
|
+
module ApiResource
|
4
|
+
|
5
|
+
module Mocks
|
6
|
+
|
7
|
+
@@endpoints = {}
|
8
|
+
@@path = nil
|
9
|
+
|
10
|
+
# A simple interface class to change the new connection to look like the
|
11
|
+
# old activeresource connection
|
12
|
+
class Interface
|
13
|
+
|
14
|
+
def initialize(path)
|
15
|
+
@path = path
|
16
|
+
end
|
17
|
+
|
18
|
+
def get(*args, &block)
|
19
|
+
Connection.send(:get, @path, *args, &block)
|
20
|
+
end
|
21
|
+
def post(*args, &block)
|
22
|
+
Connection.send(:post, @path, *args, &block)
|
23
|
+
end
|
24
|
+
def put(*args, &block)
|
25
|
+
Connection.send(:put, @path, *args, &block)
|
26
|
+
end
|
27
|
+
def delete(*args, &block)
|
28
|
+
Connection.send(:delete, @path, *args, &block)
|
29
|
+
end
|
30
|
+
def head(*args, &block)
|
31
|
+
Connection.send(:head, @path, *args, &block)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# set ApiResource's http
|
36
|
+
def self.init
|
37
|
+
::ApiResource::Connection.class_eval do
|
38
|
+
private
|
39
|
+
alias_method :http_without_mock, :http
|
40
|
+
def http(path)
|
41
|
+
Interface.new(path)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# set ApiResource's http
|
47
|
+
def self.remove
|
48
|
+
::ApiResource::Connection.class_eval do
|
49
|
+
private
|
50
|
+
alias_method :http, :http_without_mock
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# clear out the defined mocks
|
55
|
+
def self.clear_endpoints
|
56
|
+
ret = @@endpoints
|
57
|
+
@@endpoints = {}
|
58
|
+
ret
|
59
|
+
end
|
60
|
+
# re-set the endpoints
|
61
|
+
def self.set_endpoints(new_endpoints)
|
62
|
+
@@endpoints = new_endpoints
|
63
|
+
end
|
64
|
+
# return the defined endpoints
|
65
|
+
def self.endpoints
|
66
|
+
@@endpoints
|
67
|
+
end
|
68
|
+
def self.define(&block)
|
69
|
+
instance_eval(&block) if block_given?
|
70
|
+
end
|
71
|
+
# define an endpoint for the mock
|
72
|
+
def self.endpoint(path, &block)
|
73
|
+
path, format = path.split(".")
|
74
|
+
@@endpoints[path] ||= []
|
75
|
+
with_path_and_format(path, format) do
|
76
|
+
instance_eval(&block) if block_given?
|
77
|
+
end
|
78
|
+
end
|
79
|
+
# find a matching response
|
80
|
+
def self.find_response(request)
|
81
|
+
# these are stored as [[Request, Response], [Request, Response]]
|
82
|
+
responses_and_params = self.responses_for_path(request.path)
|
83
|
+
ret = (responses_and_params[:responses] || []).select{|pair| pair.first.match?(request)}
|
84
|
+
raise Exception.new("More than one response matches #{request}") if ret.length > 1
|
85
|
+
return ret.first ? {:response => ret.first[1], :params => responses_and_params[:params]} : nil
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.paths_match?(known_path, entered_path)
|
89
|
+
PathString.paths_match?(known_path, entered_path)
|
90
|
+
end
|
91
|
+
|
92
|
+
# This method assumes that the two are matching paths
|
93
|
+
# if they aren't the behavior is undefined
|
94
|
+
def self.extract_params(known_path, entered_path)
|
95
|
+
PathString.extract_params(known_path, entered_path)
|
96
|
+
end
|
97
|
+
|
98
|
+
# returns a hash {:responses => [[Request, Response],[Request,Response]], :params => {...}}
|
99
|
+
# if there is no match returns nil
|
100
|
+
def self.responses_for_path(path)
|
101
|
+
path = path.split("?").first
|
102
|
+
path = path.split(/\./).first
|
103
|
+
# The obvious case
|
104
|
+
if @@endpoints[path]
|
105
|
+
return {:responses => @@endpoints[path], :params => {}}
|
106
|
+
end
|
107
|
+
# parameter names prefixed with colons should match parts
|
108
|
+
# of the path and push those parameters into the response
|
109
|
+
@@endpoints.keys.each do |possible_path|
|
110
|
+
if self.paths_match?(possible_path, path)
|
111
|
+
return {:responses => @@endpoints[possible_path], :params => self.extract_params(possible_path, path)}
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
return {:responses => nil, :params => nil}
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
private
|
120
|
+
def self.with_path_and_format(path, format, &block)
|
121
|
+
@@path, @@format = path, format
|
122
|
+
ret = yield
|
123
|
+
@@path, @@format = nil, nil
|
124
|
+
ret
|
125
|
+
end
|
126
|
+
# define the
|
127
|
+
[:post, :put, :get, :delete, :head].each do |verb|
|
128
|
+
instance_eval <<-EOE, __FILE__, __LINE__ + 1
|
129
|
+
def #{verb}(response_body, opts = {}, &block)
|
130
|
+
|
131
|
+
raise Exception.new("Must be called from within an endpoint block") unless @@path
|
132
|
+
opts = opts.reverse_merge({:status_code => 200, :response_headers => {}, :params => {}})
|
133
|
+
|
134
|
+
@@endpoints[@@path] << [MockRequest.new(:#{verb}, @@path, :params => opts[:params], :format => @@format), MockResponse.new(response_body, :status_code => opts[:status_code], :headers => opts[:response_headers], :format => @@format, &block)]
|
135
|
+
end
|
136
|
+
EOE
|
137
|
+
end
|
138
|
+
|
139
|
+
class MockResponse
|
140
|
+
attr_reader :body, :headers, :code, :format, :block
|
141
|
+
def initialize(body, opts = {}, &block)
|
142
|
+
opts = opts.reverse_merge({:headers => {}, :status_code => 200})
|
143
|
+
@body = body
|
144
|
+
@headers = opts[:headers]
|
145
|
+
@code = opts[:status_code]
|
146
|
+
@format = (opts[:format] || :json)
|
147
|
+
@block = block if block_given?
|
148
|
+
end
|
149
|
+
def []=(key, val)
|
150
|
+
@headers[key] = val
|
151
|
+
end
|
152
|
+
def [](key)
|
153
|
+
@headers[key]
|
154
|
+
end
|
155
|
+
|
156
|
+
def body
|
157
|
+
raise Exception.new("Body must respond to to_#{self.format}") unless @body.respond_to?("to_#{self.format}")
|
158
|
+
@body.send("to_#{self.format}")
|
159
|
+
end
|
160
|
+
|
161
|
+
def body_as_object
|
162
|
+
return @body
|
163
|
+
end
|
164
|
+
|
165
|
+
def generate_response(params)
|
166
|
+
@body = @body.instance_exec(params, &self.block) if self.block
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
class MockRequest
|
171
|
+
attr_reader :method, :path, :body, :headers, :params, :format, :query
|
172
|
+
|
173
|
+
def initialize(method, path, opts = {})
|
174
|
+
@method = method.to_sym
|
175
|
+
|
176
|
+
# set the normalized path, format and query string
|
177
|
+
@path, @query = path.split("?")
|
178
|
+
@path, @format = @path.split(".")
|
179
|
+
|
180
|
+
# if we have params, it is a MockRequest definition
|
181
|
+
if opts[:params]
|
182
|
+
@params = opts[:params]
|
183
|
+
# otherwise, we need to check either the query string or the body
|
184
|
+
# depending on the http verb
|
185
|
+
else
|
186
|
+
case @method
|
187
|
+
when :post, :put
|
188
|
+
@params = JSON.parse(opts[:body] || "")
|
189
|
+
when :get, :delete, :head
|
190
|
+
@params = sorted_params(@query || "")
|
191
|
+
end
|
192
|
+
end
|
193
|
+
@body = opts[:body]
|
194
|
+
@headers = opts[:headers] || {}
|
195
|
+
@headers["Content-Length"] = @body.blank? ? "0" : @body.size.to_s
|
196
|
+
end
|
197
|
+
|
198
|
+
#
|
199
|
+
def sorted_params(data)
|
200
|
+
ret = {}
|
201
|
+
data.split("&").each do |val|
|
202
|
+
val = val.split("=")
|
203
|
+
if val.last =~ /^\d+$/
|
204
|
+
ret[val.first] = val.last.to_i
|
205
|
+
elsif val.last =~ /^[\d\.]+$/
|
206
|
+
ret[val.first] = val.last.to_f
|
207
|
+
elsif val.last == "true"
|
208
|
+
ret[val.first] = true
|
209
|
+
elsif val.last == "false"
|
210
|
+
ret[val.first] = false
|
211
|
+
else
|
212
|
+
ret[val.first] = val.last
|
213
|
+
end
|
214
|
+
end
|
215
|
+
ret
|
216
|
+
end
|
217
|
+
|
218
|
+
# because of the context these come from, we can assume that the path already matches
|
219
|
+
def match?(request)
|
220
|
+
return false unless self.method == request.method
|
221
|
+
return false unless self.format == request.format || request.format.nil? || self.format.nil?
|
222
|
+
Comparator.diff(self.params, request.params) == {}
|
223
|
+
end
|
224
|
+
# string representation
|
225
|
+
def to_s
|
226
|
+
"#{self.method.upcase} #{self.format} #{self.path} #{self.params}"
|
227
|
+
end
|
228
|
+
end
|
229
|
+
class Connection
|
230
|
+
|
231
|
+
cattr_accessor :requests
|
232
|
+
self.requests = []
|
233
|
+
|
234
|
+
# body? methods
|
235
|
+
{ true => %w(post put),
|
236
|
+
false => %w(get delete head) }.each do |has_body, methods|
|
237
|
+
methods.each do |method|
|
238
|
+
# def post(path, body, headers)
|
239
|
+
# request = ApiResource::Request.new(:post, path, body, headers)
|
240
|
+
# self.class.requests << request
|
241
|
+
# if response = LifebookerClient::Mocks.find_response(request)
|
242
|
+
# response
|
243
|
+
# else
|
244
|
+
# raise InvalidRequestError.new("Could not find a response recorded for #{request.to_s} - Responses recorded are: - #{inspect_responses}")
|
245
|
+
# end
|
246
|
+
# end
|
247
|
+
instance_eval <<-EOE, __FILE__, __LINE__ + 1
|
248
|
+
def #{method}(path, #{'body, ' if has_body}headers)
|
249
|
+
opts = {:headers => headers}
|
250
|
+
#{"opts[:body] = body" if has_body}
|
251
|
+
request = MockRequest.new(:#{method}, path, opts)
|
252
|
+
self.requests << request
|
253
|
+
if response = Mocks.find_response(request)
|
254
|
+
response[:response].tap{|resp| resp.generate_response(response[:params])}
|
255
|
+
else
|
256
|
+
raise ApiResource::ResourceNotFound.new(
|
257
|
+
MockResponse.new("", {:headers => {"Content-type" => "application/json"}, :status_code => 404}),
|
258
|
+
:message => "\nCould not find a response recorded for \#{request.pretty_inspect}\n" +
|
259
|
+
"Potential Responses Are:\n" +
|
260
|
+
"\#{Array.wrap(Mocks.responses_for_path(request.path)[:responses]).collect(&:first).pretty_inspect}"
|
261
|
+
)
|
262
|
+
end
|
263
|
+
end
|
264
|
+
EOE
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module ApiResource
|
2
|
+
|
3
|
+
class Errors < ::ActiveModel::Errors
|
4
|
+
|
5
|
+
def from_array(messages, save_cache = false)
|
6
|
+
clear unless save_cache
|
7
|
+
humanized_attributes = @base.attributes.keys.inject({}) { |h, attr_name| h.update(attr_name.humanize => attr_name) }
|
8
|
+
messages.each do |message|
|
9
|
+
attr_message = humanized_attributes.keys.detect do |attr_name|
|
10
|
+
if message[0,attr_name.size + 1] == "#{attr_name} "
|
11
|
+
add humanized_attributes[attr_name], message[(attr_name.size + 1)..-1]
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def from_hash(messages, save_cache = false)
|
18
|
+
clear unless save_cache
|
19
|
+
messages.each do |attr, message_array|
|
20
|
+
message_array.each do |message|
|
21
|
+
add attr, message
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
module ModelErrors
|
29
|
+
|
30
|
+
extend ActiveSupport::Concern
|
31
|
+
include ActiveModel::Validations
|
32
|
+
|
33
|
+
included do
|
34
|
+
# this is required here because this module will be above Base in the inheritance chain
|
35
|
+
alias_method_chain :save, :validations
|
36
|
+
end
|
37
|
+
|
38
|
+
module InstanceMethods
|
39
|
+
|
40
|
+
def save_with_validations(*args)
|
41
|
+
# we want to leave the original intact
|
42
|
+
options = args.clone.extract_options!
|
43
|
+
|
44
|
+
perform_validation = options.blank? ? true : options[:validate]
|
45
|
+
|
46
|
+
@remote_errors = nil
|
47
|
+
if perform_validation && valid? || !perform_validation
|
48
|
+
save_without_validations(*args)
|
49
|
+
true
|
50
|
+
else
|
51
|
+
false
|
52
|
+
end
|
53
|
+
rescue ApiResource::UnprocessableEntity => error
|
54
|
+
@remote_errors = error
|
55
|
+
load_remote_errors(@remote_errors, true)
|
56
|
+
false
|
57
|
+
end
|
58
|
+
|
59
|
+
def load_remote_errors(remote_errors, save_cache = false)
|
60
|
+
error_data = self.class.format.decode(remote_errors.response.body)['errors'] || {}
|
61
|
+
if error_data.is_a?(Hash)
|
62
|
+
self.errors.from_hash(error_data)
|
63
|
+
elsif error_data.is_a?(Array)
|
64
|
+
self.errors.from_array(error_data)
|
65
|
+
else
|
66
|
+
raise Exception.new
|
67
|
+
end
|
68
|
+
rescue Exception
|
69
|
+
raise "Invalid response for invalid object: expected an array or hash got #{remote_errors}"
|
70
|
+
end
|
71
|
+
|
72
|
+
# This method runs any local validations but not remote ones
|
73
|
+
def valid?
|
74
|
+
super
|
75
|
+
errors.empty?
|
76
|
+
end
|
77
|
+
|
78
|
+
def errors
|
79
|
+
@errors ||= ApiResource::Errors.new(self)
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|