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,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,12 @@
1
+ module ApiResource
2
+ class Local < Base
3
+ # nothing to do here
4
+ def self.set_class_attributes_upon_load
5
+ true
6
+ end
7
+ # shouldn't do anything here either -
8
+ def self.reload_class_attributes
9
+ true
10
+ end
11
+ end
12
+ 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