Skip to content

Conversation

@seanpdoyle
Copy link
Contributor

@seanpdoyle seanpdoyle commented Oct 23, 2025

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:

{
  "error": true,
  "messages": ["Something went wrong", "Title can't be blank"]
}

A Post class can be setup to handle it with:

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:

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:

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:

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.

@rafaelfranca
Copy link
Member

Can you break the changes to ActiveResource::Formats::JsonFormat and ActiveResource::Formats::XmlFormat in another PR? They don't look related with the error parser

@seanpdoyle
Copy link
Contributor Author

Can you break the changes to ActiveResource::Formats::JsonFormat and ActiveResource::Formats::XmlFormat in another PR? They don't look related with the error parser

Sure! That change is required for this behavior to work, but I'm happy to open a smaller PR to merge it ahead of reviewing this one.

seanpdoyle added a commit to seanpdoyle/activeresource that referenced this pull request Oct 23, 2025
Related to rails#441

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.
@rafaelfranca
Copy link
Member

Yeah, let's break in two. I intended to merge both, but I don't think that change on its own makes sense.

@seanpdoyle
Copy link
Contributor Author

I've opened #446.

@seanpdoyle seanpdoyle force-pushed the validation-error-parser branch from 472ae3f to 71dd0cb Compare October 23, 2025 19:18
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.
@seanpdoyle seanpdoyle force-pushed the validation-error-parser branch from 71dd0cb to 09e0d7e Compare October 23, 2025 20:23
@rafaelfranca rafaelfranca merged commit b549319 into rails:main Oct 23, 2025
19 checks passed
@seanpdoyle seanpdoyle deleted the validation-error-parser branch October 23, 2025 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants