api_resource 0.4.1 → 0.4.2

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 (116) hide show
  1. data/Gemfile +37 -0
  2. data/Gemfile.lock +190 -0
  3. data/Guardfile +27 -0
  4. data/Rakefile +49 -0
  5. data/VERSION +1 -0
  6. data/api_resource.gemspec +111 -0
  7. data/coverage/assets/0.5.3/app.js +88 -0
  8. data/coverage/assets/0.5.3/fancybox/blank.gif +0 -0
  9. data/coverage/assets/0.5.3/fancybox/fancy_close.png +0 -0
  10. data/coverage/assets/0.5.3/fancybox/fancy_loading.png +0 -0
  11. data/coverage/assets/0.5.3/fancybox/fancy_nav_left.png +0 -0
  12. data/coverage/assets/0.5.3/fancybox/fancy_nav_right.png +0 -0
  13. data/coverage/assets/0.5.3/fancybox/fancy_shadow_e.png +0 -0
  14. data/coverage/assets/0.5.3/fancybox/fancy_shadow_n.png +0 -0
  15. data/coverage/assets/0.5.3/fancybox/fancy_shadow_ne.png +0 -0
  16. data/coverage/assets/0.5.3/fancybox/fancy_shadow_nw.png +0 -0
  17. data/coverage/assets/0.5.3/fancybox/fancy_shadow_s.png +0 -0
  18. data/coverage/assets/0.5.3/fancybox/fancy_shadow_se.png +0 -0
  19. data/coverage/assets/0.5.3/fancybox/fancy_shadow_sw.png +0 -0
  20. data/coverage/assets/0.5.3/fancybox/fancy_shadow_w.png +0 -0
  21. data/coverage/assets/0.5.3/fancybox/fancy_title_left.png +0 -0
  22. data/coverage/assets/0.5.3/fancybox/fancy_title_main.png +0 -0
  23. data/coverage/assets/0.5.3/fancybox/fancy_title_over.png +0 -0
  24. data/coverage/assets/0.5.3/fancybox/fancy_title_right.png +0 -0
  25. data/coverage/assets/0.5.3/fancybox/fancybox-x.png +0 -0
  26. data/coverage/assets/0.5.3/fancybox/fancybox-y.png +0 -0
  27. data/coverage/assets/0.5.3/fancybox/fancybox.png +0 -0
  28. data/coverage/assets/0.5.3/fancybox/jquery.fancybox-1.3.1.css +363 -0
  29. data/coverage/assets/0.5.3/fancybox/jquery.fancybox-1.3.1.pack.js +44 -0
  30. data/coverage/assets/0.5.3/favicon_green.png +0 -0
  31. data/coverage/assets/0.5.3/favicon_red.png +0 -0
  32. data/coverage/assets/0.5.3/favicon_yellow.png +0 -0
  33. data/coverage/assets/0.5.3/highlight.css +129 -0
  34. data/coverage/assets/0.5.3/highlight.pack.js +1 -0
  35. data/coverage/assets/0.5.3/jquery-1.6.2.min.js +18 -0
  36. data/coverage/assets/0.5.3/jquery.dataTables.min.js +152 -0
  37. data/coverage/assets/0.5.3/jquery.timeago.js +141 -0
  38. data/coverage/assets/0.5.3/jquery.url.js +174 -0
  39. data/coverage/assets/0.5.3/loading.gif +0 -0
  40. data/coverage/assets/0.5.3/magnify.png +0 -0
  41. data/coverage/assets/0.5.3/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png +0 -0
  42. data/coverage/assets/0.5.3/smoothness/images/ui-bg_flat_75_ffffff_40x100.png +0 -0
  43. data/coverage/assets/0.5.3/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png +0 -0
  44. data/coverage/assets/0.5.3/smoothness/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
  45. data/coverage/assets/0.5.3/smoothness/images/ui-bg_glass_75_dadada_1x400.png +0 -0
  46. data/coverage/assets/0.5.3/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png +0 -0
  47. data/coverage/assets/0.5.3/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png +0 -0
  48. data/coverage/assets/0.5.3/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png +0 -0
  49. data/coverage/assets/0.5.3/smoothness/images/ui-icons_222222_256x240.png +0 -0
  50. data/coverage/assets/0.5.3/smoothness/images/ui-icons_2e83ff_256x240.png +0 -0
  51. data/coverage/assets/0.5.3/smoothness/images/ui-icons_454545_256x240.png +0 -0
  52. data/coverage/assets/0.5.3/smoothness/images/ui-icons_888888_256x240.png +0 -0
  53. data/coverage/assets/0.5.3/smoothness/images/ui-icons_cd0a0a_256x240.png +0 -0
  54. data/coverage/assets/0.5.3/smoothness/jquery-ui-1.8.4.custom.css +295 -0
  55. data/coverage/assets/0.5.3/stylesheet.css +383 -0
  56. data/coverage/index.html +3573 -0
  57. data/lib/api_resource.rb +130 -0
  58. data/lib/api_resource/association_activation.rb +19 -0
  59. data/lib/api_resource/associations.rb +218 -0
  60. data/lib/api_resource/associations/association_proxy.rb +116 -0
  61. data/lib/api_resource/associations/belongs_to_remote_object_proxy.rb +16 -0
  62. data/lib/api_resource/associations/dynamic_resource_scope.rb +23 -0
  63. data/lib/api_resource/associations/generic_scope.rb +68 -0
  64. data/lib/api_resource/associations/has_many_remote_object_proxy.rb +16 -0
  65. data/lib/api_resource/associations/has_many_through_remote_object_proxy.rb +13 -0
  66. data/lib/api_resource/associations/has_one_remote_object_proxy.rb +24 -0
  67. data/lib/api_resource/associations/multi_argument_resource_scope.rb +15 -0
  68. data/lib/api_resource/associations/multi_object_proxy.rb +84 -0
  69. data/lib/api_resource/associations/related_object_hash.rb +12 -0
  70. data/lib/api_resource/associations/relation_scope.rb +25 -0
  71. data/lib/api_resource/associations/resource_scope.rb +32 -0
  72. data/lib/api_resource/associations/scope.rb +132 -0
  73. data/lib/api_resource/associations/single_object_proxy.rb +82 -0
  74. data/lib/api_resource/attributes.rb +243 -0
  75. data/lib/api_resource/base.rb +717 -0
  76. data/lib/api_resource/callbacks.rb +45 -0
  77. data/lib/api_resource/connection.rb +195 -0
  78. data/lib/api_resource/core_extensions.rb +7 -0
  79. data/lib/api_resource/custom_methods.rb +117 -0
  80. data/lib/api_resource/decorators.rb +6 -0
  81. data/lib/api_resource/decorators/caching_decorator.rb +20 -0
  82. data/lib/api_resource/exceptions.rb +99 -0
  83. data/lib/api_resource/formats.rb +22 -0
  84. data/lib/api_resource/formats/json_format.rb +25 -0
  85. data/lib/api_resource/formats/xml_format.rb +36 -0
  86. data/lib/api_resource/local.rb +12 -0
  87. data/lib/api_resource/log_subscriber.rb +15 -0
  88. data/lib/api_resource/mocks.rb +285 -0
  89. data/lib/api_resource/model_errors.rb +82 -0
  90. data/lib/api_resource/observing.rb +27 -0
  91. data/lib/api_resource/railtie.rb +24 -0
  92. data/lib/api_resource/scopes.rb +48 -0
  93. data/nohup.out +63 -0
  94. data/spec/lib/api_resource_spec.rb +43 -0
  95. data/spec/lib/associations_spec.rb +751 -0
  96. data/spec/lib/attributes_spec.rb +191 -0
  97. data/spec/lib/base_spec.rb +655 -0
  98. data/spec/lib/callbacks_spec.rb +68 -0
  99. data/spec/lib/connection_spec.rb +137 -0
  100. data/spec/lib/local_spec.rb +20 -0
  101. data/spec/lib/mocks_spec.rb +74 -0
  102. data/spec/lib/model_errors_spec.rb +29 -0
  103. data/spec/lib/prefixes_spec.rb +107 -0
  104. data/spec/spec_helper.rb +82 -0
  105. data/spec/support/mocks/association_mocks.rb +63 -0
  106. data/spec/support/mocks/error_resource_mocks.rb +21 -0
  107. data/spec/support/mocks/prefix_model_mocks.rb +5 -0
  108. data/spec/support/mocks/test_resource_mocks.rb +44 -0
  109. data/spec/support/requests/association_requests.rb +31 -0
  110. data/spec/support/requests/error_resource_requests.rb +25 -0
  111. data/spec/support/requests/prefix_model_requests.rb +7 -0
  112. data/spec/support/requests/test_resource_requests.rb +38 -0
  113. data/spec/support/test_resource.rb +72 -0
  114. data/spec/tmp/DIR +0 -0
  115. data/spec/tmp/api_resource_test_db.sqlite +0 -0
  116. metadata +119 -3
@@ -0,0 +1,22 @@
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
+ format_name = ActiveSupport::Inflector.camelize(mime_type_reference.to_s) + "Format"
12
+ begin
13
+ ApiResource::Formats.const_get(format_name)
14
+ rescue NameError => e
15
+ raise BadFormat.new("#{mime_type_reference} is not a valid format")
16
+ end
17
+ end
18
+
19
+ class BadFormat < StandardError
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,25 @@
1
+ require 'active_support/json'
2
+ require 'json'
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
+ JSON.dump(hash, options)
18
+ end
19
+
20
+ def decode(json)
21
+ JSON.parse(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,285 @@
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 = typecast_values(
191
+ Rack::Utils.parse_nested_query(@query || "")
192
+ )
193
+ end
194
+ end
195
+ @body = opts[:body]
196
+ @headers = opts[:headers] || {}
197
+ @headers["Content-Length"] = @body.blank? ? "0" : @body.size.to_s
198
+ end
199
+
200
+ #
201
+ def typecast_values(data)
202
+ if data.is_a?(Hash)
203
+ data.each_pair do |k,v|
204
+ data[k] = typecast_values(v)
205
+ end
206
+ elsif data.is_a?(Array)
207
+ data = data.collect{|v|
208
+ typecast_values(v)
209
+ }
210
+ else
211
+ data = if data.to_s =~ /^\d+$/
212
+ data.to_i
213
+ elsif data =~ /^[\d\.]+$/
214
+ data.to_f
215
+ elsif data == "true"
216
+ true
217
+ elsif data == "false"
218
+ false
219
+ else
220
+ data
221
+ end
222
+ end
223
+ data.nil? ? "" : data
224
+ end
225
+
226
+ # because of the context these come from, we can assume that the path already matches
227
+ def match?(request)
228
+ return false unless self.method == request.method
229
+ return false unless self.format == request.format || request.format.nil? || self.format.nil?
230
+ Comparator.diff(self.params, request.params) == {}
231
+ end
232
+ # string representation
233
+ def to_s
234
+ "#{self.method.upcase} #{self.format} #{self.path} #{self.params}"
235
+ end
236
+ end
237
+ class Connection
238
+
239
+ cattr_accessor :requests
240
+ self.requests = []
241
+
242
+ # body? methods
243
+ { true => %w(post put),
244
+ false => %w(get delete head) }.each do |has_body, methods|
245
+ methods.each do |method|
246
+ # def post(path, body, headers)
247
+ # request = ApiResource::Request.new(:post, path, body, headers)
248
+ # self.class.requests << request
249
+ # if response = LifebookerClient::Mocks.find_response(request)
250
+ # response
251
+ # else
252
+ # raise InvalidRequestError.new("Could not find a response
253
+ # recorded for #{request.to_s} - Responses recorded are: -
254
+ # #{inspect_responses}")
255
+ # end
256
+ # end
257
+ instance_eval <<-EOE, __FILE__, __LINE__ + 1
258
+ def #{method}(path, #{'body, ' if has_body}headers)
259
+ opts = {:headers => headers}
260
+ #{"opts[:body] = body" if has_body}
261
+ request = MockRequest.new(:#{method}, path, opts)
262
+ self.requests << request
263
+ if response = Mocks.find_response(request)
264
+ response[:response].tap{|resp|
265
+ resp.generate_response(
266
+ request.params
267
+ .with_indifferent_access
268
+ .merge(response[:params].with_indifferent_access)
269
+ )
270
+ }
271
+ else
272
+ raise ApiResource::ResourceNotFound.new(
273
+ MockResponse.new({}, {:headers => {"Content-type" => "application/json"}, :status_code => 404}),
274
+ :message => "\nCould not find a response recorded for \#{request.pretty_inspect}\n" +
275
+ "Potential Responses Are:\n" +
276
+ "\#{Array.wrap(Mocks.responses_for_path(request.path)[:responses]).collect(&:first).pretty_inspect}"
277
+ )
278
+ end
279
+ end
280
+ EOE
281
+ end
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,82 @@
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
+ def save_with_validations(*args)
39
+ # we want to leave the original intact
40
+ options = args.clone.extract_options!
41
+
42
+ perform_validation = options.blank? ? true : options[:validate]
43
+
44
+ @remote_errors = nil
45
+ if perform_validation && valid? || !perform_validation
46
+ save_without_validations(*args)
47
+ true
48
+ else
49
+ false
50
+ end
51
+ rescue ApiResource::UnprocessableEntity => error
52
+ @remote_errors = error
53
+ load_remote_errors(@remote_errors, true)
54
+ false
55
+ end
56
+
57
+ def load_remote_errors(remote_errors, save_cache = false)
58
+ error_data = self.class.format.decode(remote_errors.response.body)['errors'] || {}
59
+ if error_data.is_a?(Hash)
60
+ self.errors.from_hash(error_data)
61
+ elsif error_data.is_a?(Array)
62
+ self.errors.from_array(error_data)
63
+ else
64
+ raise Exception.new
65
+ end
66
+ rescue Exception
67
+ raise "Invalid response for invalid object: expected an array or hash got #{remote_errors}"
68
+ end
69
+
70
+ # This method runs any local validations but not remote ones
71
+ def valid?
72
+ super
73
+ errors.empty?
74
+ end
75
+
76
+ def errors
77
+ @errors ||= ApiResource::Errors.new(self)
78
+ end
79
+
80
+ end
81
+
82
+ end