api_resource 0.4.1 → 0.4.2

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