From 62bee80a9242ea4a98449189afe761bcddd3b304 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Thu, 23 Oct 2025 08:34:23 -0400 Subject: [PATCH] Validations: Configure which status codes to rescue from The problem --- The established convention is for resources to rescue from `422 Unprocessable Content` responses by raising an `ActiveResource::ResourceInvalid` exception. The mixed-in `ActiveResource::Validation` module rescues from `ActiveResource::ResourceInvalid`, then parses error messages from the response body. This works for many remote services, but there are other status codes that remote services might respond with that include resource-specific error messages. For example, a service could respond with a more generic status of `400 Bad Request` to indicate that the request was invalid. If the response includes error messages, it could be important to import them into the resource's error messages. The proposal --- Introduce the `Base.remote_errors` attribute (named to match the style of the `@remote_errors` instance variable and the `#load_remote_errors` private method) to control which errors to rescue from. The default value remains `ActiveResource::ResourceInvalid`. --- lib/active_resource/validations.rb | 24 ++++++++++++++++++++-- test/cases/base_errors_test.rb | 33 +++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/lib/active_resource/validations.rb b/lib/active_resource/validations.rb index e5ec962eef..be9bb31395 100644 --- a/lib/active_resource/validations.rb +++ b/lib/active_resource/validations.rb @@ -67,10 +67,14 @@ def from_xml(xml, save_cache = false) end # Module to support validation and errors with Active Resource objects. The module overrides - # Base#save to rescue ActiveResource::ResourceInvalid exceptions and parse the errors returned + # Base#save to rescue exceptions and parse the errors returned # in the web service response. The module also adds an +errors+ collection that mimics the interface # of the errors provided by ActiveModel::Errors. # + # By default, Active Resource will raise, then rescue from ActiveResource::ResourceInvalid + # exceptions for a response with a +422+ status code. Set the +remote_errors+ + # class attribute to rescue from other exceptions. + # # ==== Example # # Consider a Person resource on the server requiring both a +first_name+ and a +last_name+ with a @@ -133,6 +137,22 @@ module Validations included do alias_method :save_without_validation, :save alias_method :save, :save_with_validation + class_attribute :_remote_errors, instance_accessor: false + end + + class_methods do + # Sets the exception classes to rescue from during Base#save. + def remote_errors=(errors) + errors = Array.wrap(errors) + errors.map! { |error| error.is_a?(String) ? error.constantize : error } + self._remote_errors = errors + end + + # Returns the exception classes rescued from during Base#save. Defaults to + # ActiveResource::ResourceInvalid. + def remote_errors + _remote_errors.presence || ResourceInvalid + end end # Validate a resource and save (POST) it to the remote web service. @@ -150,7 +170,7 @@ def save_with_validation(options = {}) else false end - rescue ResourceInvalid => error + rescue *self.class.remote_errors => error # cache the remote errors because every call to valid? clears # all errors. We must keep a copy to add these back after local # validations. diff --git a/test/cases/base_errors_test.rb b/test/cases/base_errors_test.rb index d0ed1541aa..bfbff56ea3 100644 --- a/test/cases/base_errors_test.rb +++ b/test/cases/base_errors_test.rb @@ -111,13 +111,43 @@ def test_should_mark_as_invalid_when_content_type_is_unavailable_in_response_hea end end + def test_rescues_from_configured_exception_class_name + ActiveResource::HttpMock.respond_to do |mock| + mock.post "/people.xml", {}, %q(Age can't be blank), 400, {} + mock.post "/people.json", {}, %q({"errors":{"age":["can't be blank"]}}), 400, {} + end + + [ :json, :xml ].each do |format| + invalid_user_using_format(format, rescue_from: "ActiveResource::BadRequest") do + assert_not_predicate @person, :valid? + assert_equal [ "can't be blank" ], @person.errors[:age] + end + end + end + + def test_rescues_from_configured_array_of_exception_classes + [ :json, :xml ].product([ 400, 422 ]).each do |format, error_status| + ActiveResource::HttpMock.respond_to do |mock| + mock.post "/people.xml", {}, %q(Age can't be blank), error_status, {} + mock.post "/people.json", {}, %q({"errors":{"age":["can't be blank"]}}), error_status, {} + end + + invalid_user_using_format(format, rescue_from: [ ActiveResource::BadRequest, ActiveResource::ResourceInvalid ]) do + assert_not_predicate @person, :valid? + assert_equal [ "can't be blank" ], @person.errors[:age] + end + end + end + private - def invalid_user_using_format(mime_type_reference) + def invalid_user_using_format(mime_type_reference, rescue_from: nil) previous_format = Person.format previous_schema = Person.schema + previous_remote_errors = Person.remote_errors Person.format = mime_type_reference Person.schema = { "known_attribute" => "string" } + Person.remote_errors = rescue_from @person = Person.new(name: "", age: "", phone: "", phone_work: "") assert_equal false, @person.save @@ -125,5 +155,6 @@ def invalid_user_using_format(mime_type_reference) ensure Person.format = previous_format Person.schema = previous_schema + Person.remote_errors = previous_remote_errors end end