Skip to content

Commit 472ae3f

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) parsed["messages"] end end ``` Changes to ActiveResource::Formats::JsonFormat and ActiveResource::Formats::XmlFormat --- This commit also adds the `remove_root = true` optional argument to the `JsonFormat` and `XmlFormat` modules' `#decode` method. The name and positional argument style draw direct inspiration from the `ActiveResource::Base#load` method's optional `remove_root = true` argument. The change is in support of replacing internal calls to `Hash.from_xml` and `ActiveSupport::JSON.decode`. Those method invocations are replaced with the appropriate format's `.decode` method. 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 62b941e commit 472ae3f

4 files changed

Lines changed: 233 additions & 16 deletions

File tree

lib/active_resource/formats/json_format.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ def encode(resource, options = nil)
1919
resource.to_json(options)
2020
end
2121

22-
def decode(json)
22+
def decode(json, remove_root = true)
2323
return nil if json.nil?
24-
Formats.remove_root(ActiveSupport::JSON.decode(json))
24+
hash = ActiveSupport::JSON.decode(json)
25+
remove_root ? Formats.remove_root(hash) : hash
2526
end
2627
end
2728
end

lib/active_resource/formats/xml_format.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ def encode(resource, options = {})
1919
resource.to_xml(options)
2020
end
2121

22-
def decode(xml)
23-
Formats.remove_root(Hash.from_xml(xml))
22+
def decode(xml, remove_root = true)
23+
hash = Hash.from_xml(xml)
24+
remove_root ? Formats.remove_root(hash) : hash
2425
end
2526
end
2627
end

lib/active_resource/validations.rb

Lines changed: 127 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 = ActiveSupport::JSON.decode(json) || {} 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(Hash.from_xml(xml)["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+
# 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

@@ -93,6 +201,19 @@ module Validations
93201
included do
94202
alias_method :save_without_validation, :save
95203
alias_method :save, :save_with_validation
204+
class_attribute :_errors_parser
205+
end
206+
207+
class_methods do
208+
# Sets the parser to use when a response with errors is returned.
209+
def errors_parser=(parser_class)
210+
parser_class = parser_class.constantize if parser_class.is_a?(String)
211+
self._errors_parser = parser_class
212+
end
213+
214+
def errors_parser
215+
_errors_parser || ActiveResource::ActiveModelErrorsParser
216+
end
96217
end
97218

98219
# Validate a resource and save (POST) it to the remote web service.
@@ -123,12 +244,7 @@ def save_with_validation(options = {})
123244
# Loads the set of remote errors into the object's Errors based on the
124245
# content-type of the error-block received.
125246
def load_remote_errors(remote_errors, save_cache = false) # :nodoc:
126-
case self.class.format
127-
when ActiveResource::Formats[:xml]
128-
errors.from_xml(remote_errors.response.body, save_cache)
129-
when ActiveResource::Formats[:json]
130-
errors.from_json(remote_errors.response.body, save_cache)
131-
end
247+
errors.from_body(remote_errors.response.body, save_cache)
132248
end
133249

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

test/cases/base_errors_test.rb

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,82 @@ def test_should_mark_as_invalid_when_content_type_is_unavailable_in_response_hea
111111
end
112112
end
113113

114+
def test_gracefully_recovers_from_unrecognized_errors_from_response
115+
ActiveResource::HttpMock.respond_to do |mock|
116+
mock.post "/people.xml", {}, %q(<?xml version="1.0" encoding="UTF-8"?><error>Age can't be blank</error>), 422, {}
117+
mock.post "/people.json", {}, %q({"error":"can't be blank"}), 422, {}
118+
end
119+
120+
[ :json, :xml ].each do |format|
121+
invalid_user_using_format format do
122+
assert_predicate @person, :valid?
123+
assert_empty @person.errors
124+
end
125+
end
126+
end
127+
128+
def test_parses_errors_from_response_with_custom_errors_parser
129+
ActiveResource::HttpMock.respond_to do |mock|
130+
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, {}
131+
mock.post "/people.json", {}, %q({"error":{"messages":["Age can't be blank", "Name can't be blank"]}}), 422, {}
132+
end
133+
errors_parser = Class.new(ActiveResource::ErrorsParser) do
134+
def messages
135+
@messages.dig("error", "messages")
136+
end
137+
end
138+
139+
[ :json, :xml ].each do |format|
140+
using_errors_parser errors_parser do
141+
invalid_user_using_format format do
142+
assert_not_predicate @person, :valid?
143+
assert_equal [ "can't be blank" ], @person.errors[:age]
144+
assert_equal [ "can't be blank" ], @person.errors[:name]
145+
end
146+
end
147+
end
148+
end
149+
150+
def test_parses_errors_from_response_with_XmlFormat
151+
using_errors_parser ->(errors) { errors.reject { |e| e =~ /name/i } } do
152+
invalid_user_using_format :xml do
153+
assert_not_predicate @person, :valid?
154+
assert_equal [], @person.errors[:name]
155+
assert_equal [ "can't be blank" ], @person.errors[:phone_work]
156+
end
157+
end
158+
end
159+
160+
def test_parses_errors_from_response_with_JsonFormat
161+
using_errors_parser ->(errors) { errors.except("name") } do
162+
invalid_user_using_format :json do
163+
assert_not_predicate @person, :valid?
164+
assert_empty @person.errors[:name]
165+
assert_equal [ "can't be blank" ], @person.errors[:phone_work]
166+
end
167+
end
168+
end
169+
170+
def test_parses_errors_from_response_with_custom_format
171+
ActiveResource::HttpMock.respond_to do |mock|
172+
mock.post "/people.json", {}, %q({"errors":{"name":["can't be blank", "must start with a letter"],"phoneWork":["can't be blank"]}}), 422, {}
173+
end
174+
175+
using_errors_parser ->(errors) { errors.except("name") } do
176+
invalid_user_using_format ->(json) { json.deep_transform_keys!(&:underscore) } do
177+
assert_not_predicate @person, :valid?
178+
assert_equal [], @person.errors[:name]
179+
assert_equal [ "can't be blank" ], @person.errors[:phone_work]
180+
end
181+
end
182+
end
183+
114184
private
115185
def invalid_user_using_format(mime_type_reference)
116186
previous_format = Person.format
117187
previous_schema = Person.schema
118188

119-
Person.format = mime_type_reference
189+
Person.format = mime_type_reference.respond_to?(:call) ? decode_with(&mime_type_reference) : mime_type_reference
120190
Person.schema = { "known_attribute" => "string" }
121191
@person = Person.new(name: "", age: "", phone: "", phone_work: "")
122192
assert_equal false, @person.save
@@ -126,4 +196,33 @@ def invalid_user_using_format(mime_type_reference)
126196
Person.format = previous_format
127197
Person.schema = previous_schema
128198
end
199+
200+
def using_errors_parser(errors_parser)
201+
previous_errors_parser = Person.errors_parser
202+
203+
Person.errors_parser =
204+
if errors_parser.is_a?(Proc)
205+
Class.new ActiveResource::ActiveModelErrorsParser do
206+
define_method :messages do
207+
errors_parser.call(super())
208+
end
209+
end
210+
else
211+
errors_parser
212+
end
213+
214+
yield
215+
ensure
216+
Person.errors_parser = previous_errors_parser
217+
end
218+
219+
def decode_with(&block)
220+
Module.new do
221+
extend self, ActiveResource::Formats[:json]
222+
223+
define_method :decode do |json, remove_root = true|
224+
block.call(super(json, remove_root))
225+
end
226+
end
227+
end
129228
end

0 commit comments

Comments
 (0)