api_resource 0.2.1

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