Skip to content

Commit 09e0d7e

Browse files
committed
Validations: Decode response with format
First, extract the `ActiveResource::ErrorsParser` class along with the internal `ActiveResource::ActiveModelErrorsParser` class (that inherits from `ActiveResource::ErrorsParser`). Configure a `errors_parser` resource class attribute to control how errors are extracted from decoded payloads. The `errors_parser` pattern and the `ErrorsParser` class are directly inspired by the `collection_parser` and `ActiveResource::Collection` class. ActiveResource::ErrorsParser --- `ActiveResource::ErrorsParser` is a wrapper to handle parsing responses in response to invalid requests that do not directly map to Active Model error conventions. You can define a custom class that inherits from `ActiveResource::ErrorsParser` in order to to set the elements instance. The initialize method will receive the `ActiveResource::Formats` parsed result and should set `@messages`. Consider a `POST /posts.json` request that results in a `422 Unprocessable Content` response with the following `application/json` body: ```json { "error": true, "messages": ["Something went wrong", "Title can't be blank"] } ``` A Post class can be setup to handle it with: ```ruby class Post < ActiveResource::Base self.errors_parser = PostErrorsParser end ``` A custom `ActiveResource::ErrorsParser` instance's `messages` method should return a mapping of attribute names (or `"base"`) to arrays of error message strings: ```ruby class PostErrorsParser < ActiveResource::ErrorsParser def initialize(parsed) @messages = Hash.new { |hash, attr_name| hash[attr_name] = [] } parsed["messages"].each do |message| if message.starts_with?("Title") @messages["title"] << message else @messages["base"] << message end end end end ``` When the `POST /posts.json` request is submitted by calling `save`, the errors are parsed from the body and assigned to the Post instance's `errors` object: ```ruby post = Post.new(title: "") post.save # => false post.valid? # => false post.errors.messages_for(:base) # => ["Something went wrong"] post.errors.messages_for(:title) # => ["Title can't be blank"] ``` If the custom `ActiveResource::ErrorsParser` instance's `messages` method returns an array of error message strings, Active Resource will try to infer the attribute name based on the contents of the error message string. If an error starts with a known attribute name, Active Resource will add the message to that attribute's error messages. If a known attribute name cannot be inferred, the error messages will be added to the `:base` errors: ```ruby class PostErrorsParser < ActiveResource::ErrorsParser def initialize(parsed) @messages = parsed["messages"] end end ``` Changes to ActiveResource::Formats::JsonFormat and ActiveResource::Formats::XmlFormat --- This commit changes the `ActiveResource::Errors#from_xml` and `ActiveResource::Errors#from_json` methods to be implemented in terms of a new `#from_body` method. The `#from_body` method is flexible enough to support any application-side custom formats, while internally flexible enough to rely on the built-in JSON and XML formats.
1 parent c0fea1d commit 09e0d7e

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)