Skip to content

Commit b549319

Browse files
authored
Merge pull request #441 from seanpdoyle/validation-error-parser
Validations: Decode response with format
2 parents c0fea1d + 09e0d7e commit b549319

File tree

2 files changed

+226
-12
lines changed

2 files changed

+226
-12
lines changed

lib/active_resource/validations.rb

Lines changed: 125 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,123 @@ def from_hash(messages, save_cache = false)
5454

5555
# Grabs errors from a json response.
5656
def from_json(json, save_cache = false)
57-
decoded = Formats[:json].decode(json, false) || {} rescue {}
58-
errors = decoded["errors"] || {}
59-
from_hash errors, save_cache
57+
from_body json, save_cache, format: Formats[:json]
6058
end
6159

6260
# Grabs errors from an XML response.
6361
def from_xml(xml, save_cache = false)
64-
array = Array.wrap(Formats[:xml].decode(xml, false)["errors"]["error"]) rescue []
65-
from_array array, save_cache
62+
from_body xml, save_cache, format: Formats[:xml]
63+
end
64+
65+
##
66+
# :method: from_body
67+
#
68+
# :call-seq:
69+
# from_body(body, save_cache = false)
70+
#
71+
# Grabs errors from a response body.
72+
def from_body(body, save_cache = false, format: @base.class.format)
73+
decoded = format.decode(body, false) || {} rescue {}
74+
errors = @base.class.errors_parser.new(decoded).tap do |parser|
75+
parser.format = format
76+
end.messages
77+
78+
if errors.is_a?(Array)
79+
from_array errors, save_cache
80+
else
81+
from_hash errors, save_cache
82+
end
83+
end
84+
end
85+
86+
# ActiveResource::ErrorsParser is a wrapper to handle parsing responses in
87+
# response to invalid requests that do not directly map to Active Model error
88+
# conventions.
89+
#
90+
# You can define a custom class that inherits from ActiveResource::ErrorsParser
91+
# in order to to set the elements instance.
92+
#
93+
# The initialize method will receive the ActiveResource::Formats parsed result
94+
# and should set @messages.
95+
#
96+
# ==== Example
97+
#
98+
# Consider a POST /posts.json request that results in a 422 Unprocessable
99+
# Content response with the following +application/json+ body:
100+
#
101+
# {
102+
# "error": true,
103+
# "messages": ["Something went wrong", "Title can't be blank"]
104+
# }
105+
#
106+
# A Post class can be setup to handle it with:
107+
#
108+
# class Post < ActiveResource::Base
109+
# self.errors_parser = PostErrorsParser
110+
# end
111+
#
112+
# A custom ActiveResource::ErrorsParser instance's +messages+ method should
113+
# return a mapping of attribute names (or +"base"+) to arrays of error message
114+
# strings:
115+
#
116+
# class PostErrorsParser < ActiveResource::ErrorsParser
117+
# def initialize(parsed)
118+
# @messages = Hash.new { |hash, attr_name| hash[attr_name] = [] }
119+
#
120+
# parsed["messages"].each do |message|
121+
# if message.starts_with?("Title")
122+
# @messages["title"] << message
123+
# else
124+
# @messages["base"] << message
125+
# end
126+
# end
127+
# end
128+
# end
129+
#
130+
# When the POST /posts.json request is submitted by calling +save+, the errors
131+
# are parsed from the body and assigned to the Post instance's +errors+
132+
# object:
133+
#
134+
# post = Post.new(title: "")
135+
# post.save # => false
136+
# post.valid? # => false
137+
# post.errors.messages_for(:base) # => ["Something went wrong"]
138+
# post.errors.messages_for(:title) # => ["Title can't be blank"]
139+
#
140+
# If the custom ActiveResource::ErrorsParser instance's +messages+ method
141+
# returns an array of error message strings, Active Resource will try to infer
142+
# the attribute name based on the contents of the error message string. If an
143+
# error starts with a known attribute name, Active Resource will add the
144+
# message to that attribute's error messages. If a known attribute name cannot
145+
# be inferred, the error messages will be added to the +:base+ errors:
146+
#
147+
# class PostErrorsParser < ActiveResource::ErrorsParser
148+
# def initialize(parsed)
149+
# @messages = parsed["messages"]
150+
# end
151+
# end
152+
#
153+
# post = Post.new(title: "")
154+
# post.save # => false
155+
# post.valid? # => false
156+
# post.errors.messages_for(:base) # => ["Something went wrong"]
157+
# post.errors.messages_for(:title) # => ["Title can't be blank"]
158+
class ErrorsParser
159+
attr_accessor :messages
160+
attr_accessor :format
161+
162+
def initialize(parsed)
163+
@messages = parsed
164+
end
165+
end
166+
167+
class ActiveModelErrorsParser < ErrorsParser # :nodoc:
168+
def messages
169+
if format.is_a?(Formats[:xml])
170+
Array.wrap(super["errors"]["error"]) rescue []
171+
else
172+
super["errors"] || {} rescue {}
173+
end
66174
end
67175
end
68176

@@ -138,6 +246,7 @@ module Validations
138246
alias_method :save_without_validation, :save
139247
alias_method :save, :save_with_validation
140248
class_attribute :_remote_errors, instance_accessor: false
249+
class_attribute :_errors_parser, instance_accessor: false
141250
end
142251

143252
class_methods do
@@ -153,6 +262,16 @@ def remote_errors=(errors)
153262
def remote_errors
154263
_remote_errors.presence || ResourceInvalid
155264
end
265+
266+
# Sets the parser to use when a response with errors is returned.
267+
def errors_parser=(parser_class)
268+
parser_class = parser_class.constantize if parser_class.is_a?(String)
269+
self._errors_parser = parser_class
270+
end
271+
272+
def errors_parser
273+
_errors_parser || ActiveResource::ActiveModelErrorsParser
274+
end
156275
end
157276

158277
# Validate a resource and save (POST) it to the remote web service.
@@ -183,12 +302,7 @@ def save_with_validation(options = {})
183302
# Loads the set of remote errors into the object's Errors based on the
184303
# content-type of the error-block received.
185304
def load_remote_errors(remote_errors, save_cache = false) # :nodoc:
186-
case self.class.format
187-
when ActiveResource::Formats[:xml]
188-
errors.from_xml(remote_errors.response.body, save_cache)
189-
when ActiveResource::Formats[:json]
190-
errors.from_json(remote_errors.response.body, save_cache)
191-
end
305+
errors.from_body(remote_errors.response.body, save_cache)
192306
end
193307

194308
# Checks for errors on an object (i.e., is resource.errors empty?).

test/cases/base_errors_test.rb

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def test_should_mark_as_invalid_when_content_type_is_unavailable_in_response_hea
111111
end
112112
end
113113

114+
114115
def test_rescues_from_configured_exception_class_name
115116
ActiveResource::HttpMock.respond_to do |mock|
116117
mock.post "/people.xml", {}, %q(<?xml version="1.0" encoding="UTF-8"?><errors><error>Age can't be blank</error></errors>), 400, {}
@@ -139,13 +140,83 @@ def test_rescues_from_configured_array_of_exception_classes
139140
end
140141
end
141142

143+
def test_gracefully_recovers_from_unrecognized_errors_from_response
144+
ActiveResource::HttpMock.respond_to do |mock|
145+
mock.post "/people.xml", {}, %q(<?xml version="1.0" encoding="UTF-8"?><error>Age can't be blank</error>), 422, {}
146+
mock.post "/people.json", {}, %q({"error":"can't be blank"}), 422, {}
147+
end
148+
149+
[ :json, :xml ].each do |format|
150+
invalid_user_using_format format do
151+
assert_predicate @person, :valid?
152+
assert_empty @person.errors
153+
end
154+
end
155+
end
156+
157+
def test_parses_errors_from_response_with_custom_errors_parser
158+
ActiveResource::HttpMock.respond_to do |mock|
159+
mock.post "/people.xml", {}, %q(<?xml version="1.0" encoding="UTF-8"?><error><messages>Age can't be blank</messages><messages>Name can't be blank</messages></error>), 422, {}
160+
mock.post "/people.json", {}, %q({"error":{"messages":["Age can't be blank", "Name can't be blank"]}}), 422, {}
161+
end
162+
errors_parser = Class.new(ActiveResource::ErrorsParser) do
163+
def messages
164+
@messages.dig("error", "messages")
165+
end
166+
end
167+
168+
[ :json, :xml ].each do |format|
169+
using_errors_parser errors_parser do
170+
invalid_user_using_format format do
171+
assert_not_predicate @person, :valid?
172+
assert_equal [ "can't be blank" ], @person.errors[:age]
173+
assert_equal [ "can't be blank" ], @person.errors[:name]
174+
end
175+
end
176+
end
177+
end
178+
179+
def test_parses_errors_from_response_with_XmlFormat
180+
using_errors_parser ->(errors) { errors.reject { |e| e =~ /name/i } } do
181+
invalid_user_using_format :xml do
182+
assert_not_predicate @person, :valid?
183+
assert_equal [], @person.errors[:name]
184+
assert_equal [ "can't be blank" ], @person.errors[:phone_work]
185+
end
186+
end
187+
end
188+
189+
def test_parses_errors_from_response_with_JsonFormat
190+
using_errors_parser ->(errors) { errors.except("name") } do
191+
invalid_user_using_format :json do
192+
assert_not_predicate @person, :valid?
193+
assert_empty @person.errors[:name]
194+
assert_equal [ "can't be blank" ], @person.errors[:phone_work]
195+
end
196+
end
197+
end
198+
199+
def test_parses_errors_from_response_with_custom_format
200+
ActiveResource::HttpMock.respond_to do |mock|
201+
mock.post "/people.json", {}, %q({"errors":{"name":["can't be blank", "must start with a letter"],"phoneWork":["can't be blank"]}}), 422, {}
202+
end
203+
204+
using_errors_parser ->(errors) { errors.except("name") } do
205+
invalid_user_using_format ->(json) { json.deep_transform_keys!(&:underscore) } do
206+
assert_not_predicate @person, :valid?
207+
assert_equal [], @person.errors[:name]
208+
assert_equal [ "can't be blank" ], @person.errors[:phone_work]
209+
end
210+
end
211+
end
212+
142213
private
143214
def invalid_user_using_format(mime_type_reference, rescue_from: nil)
144215
previous_format = Person.format
145216
previous_schema = Person.schema
146217
previous_remote_errors = Person.remote_errors
147218

148-
Person.format = mime_type_reference
219+
Person.format = mime_type_reference.respond_to?(:call) ? decode_with(&mime_type_reference) : mime_type_reference
149220
Person.schema = { "known_attribute" => "string" }
150221
Person.remote_errors = rescue_from
151222
@person = Person.new(name: "", age: "", phone: "", phone_work: "")
@@ -157,4 +228,33 @@ def invalid_user_using_format(mime_type_reference, rescue_from: nil)
157228
Person.schema = previous_schema
158229
Person.remote_errors = previous_remote_errors
159230
end
231+
232+
def using_errors_parser(errors_parser)
233+
previous_errors_parser = Person.errors_parser
234+
235+
Person.errors_parser =
236+
if errors_parser.is_a?(Proc)
237+
Class.new ActiveResource::ActiveModelErrorsParser do
238+
define_method :messages do
239+
errors_parser.call(super())
240+
end
241+
end
242+
else
243+
errors_parser
244+
end
245+
246+
yield
247+
ensure
248+
Person.errors_parser = previous_errors_parser
249+
end
250+
251+
def decode_with(&block)
252+
Module.new do
253+
extend self, ActiveResource::Formats[:json]
254+
255+
define_method :decode do |json, remove_root = true|
256+
block.call(super(json, remove_root))
257+
end
258+
end
259+
end
160260
end

0 commit comments

Comments
 (0)