LG-4398: Support ActiveModel::Errors instance for FormResponse#5048
LG-4398: Support ActiveModel::Errors instance for FormResponse#5048
Conversation
**Why**: An ActiveModel::Errors instance can represent both the "message" and "detail" of an error, where the message is the translated, human-readable text shown to the user, and the detail is typically the locale-independent, machine-readable unique identifier of a particular error. By supporting an ActiveModel::Errors instance in FormResponse, we can retain current behaviors of showing and logging a human-readable error message, while also allowing us to identify and group instances of an error across locales in a CloudWatch query. With few exceptions, this also aligns to how we most often use FormResponse, where it's initialized using errors from an instance of ActiveModel::Errors. The difference is a simplification to pass the errors instance itself, rather than the result of its "messages" method (i.e. `errors: errors` instead of `errors: errors.messages`). It may be possible to consolidate to avoid overloading and instead supporting _only_ the ActiveModel::Errors instance in this class, but (a) there are a handful of existing cases where we create hashes ad-hoc for logging and (b) in those cases, the ActiveModel::Errors equivalent implementation is relatively clunky.
…, omitting errors
app/services/form_response.rb
Outdated
| @errors = errors.is_a?(ActiveModel::Errors) ? errors.messages.to_hash : errors | ||
| @extra = extra | ||
| @extra.merge!( | ||
| error_details: flatten_details(errors.details), |
There was a problem hiding this comment.
- what if we made
error_detailsan attribute, and only put it in the hash at the end? then we could simplifymergebecause we wouldn't need to special-case theerror_detailskey and could just append the two arrays in that method? - Do we need to a check that errors has
#detailsbefore calling this? Because plain hashes will not have#details
There was a problem hiding this comment.
- what if we made
error_detailsan attribute, and only put it in the hash at the end? then we could simplifymergebecause we wouldn't need to special-case theerror_detailskey and could just append the two arrays in that method?
The original motivation for this approach was to avoid making those details part of the public interface, where nobody should ever need to explicitly pass error_details, instead relying on it to be handled internally when given an ActiveModel::Errors instance. Though I suppose if this were attr_accessor, we wouldn't necessarily need to make this part of the initialize signature, but could still handle it in the merge? I'll poke at it a bit more.
- Do we need to a check that errors has
#detailsbefore calling this? Because plain hashes will not have#details
The condition on the next line should avoid any issues here?
Maybe there's some weird cases in merge if someone manually defines an error_details property of a hash, but the problem might go away depending on changes regarding your first point.
There was a problem hiding this comment.
The condition on the next line should avoid any issues here?
🤦 somehow didn't see that line ya that works
There was a problem hiding this comment.
The original motivation for this approach was to avoid making those details part of the public interface, where nobody should ever need to explicitly pass
error_details, instead relying on it to be handled internally when given anActiveModel::Errorsinstance. Though I suppose if this wereattr_accessor, we wouldn't necessarily need to make this part of the initialize signature, but could still handle it in the merge? I'll poke at it a bit more.
We could make it an attr_reader so it's a read-only property from outside the class?
There was a problem hiding this comment.
The original motivation for this approach was to avoid making those details part of the public interface, where nobody should ever need to explicitly pass
error_details, instead relying on it to be handled internally when given anActiveModel::Errorsinstance. Though I suppose if this wereattr_accessor, we wouldn't necessarily need to make this part of the initialize signature, but could still handle it in the merge? I'll poke at it a bit more.We could make it an
attr_readerso it's a read-only property from outside the class?
We'd still need to set it in the new instance created in merge. There's a few ways we could do this:
- Add
error_detailsas a property argument toinitialize - Make
error_detailsan accessor and set on the new instance before returning inmerge - Create an
ActiveModel::Errorsfrom the merged errors & details to be passed as theerrorsof the new instance.
The first is what I'd mentioned as wanting to avoid because of the weird redundancy in the interface. The third is maybe "cleanest", though more complicated logic in determining how to merge what are potentially two objects of mixed types.
There was a problem hiding this comment.
Maybe some instance_eval to get around the property access? It's ugly but gets the job done... 😬
class A
attr_accessor :a
def initialize(a)
@a = a
@b = a + 1
end
def merge(other)
self.class.new(self.a + other.a).tap do |merged|
outer_b = b
merged.instance_eval { @b = outer_b + other.instance_eval { b } }
end
end
private
attr_accessor :b
end
puts A.new(1).merge(A.new(2))| it 'merges multiple errors for key' do | ||
| response1 = FormResponse.new(success: false, errors: { front: 'front-error-1' }) | ||
| response2 = IdentityDocAuth::Response.new(success: true, errors: { front: ['front-error-2'] }) | ||
|
|
||
| combined_response = response1.merge(response2) | ||
| expect(combined_response.errors).to eq(front: ['front-error-1', 'front-error-2']) | ||
| end |
There was a problem hiding this comment.
FYI I'd expect this test case to pass on main as well, but it doesn't:
Failures:
1) FormResponse#merge merges multiple errors for key
Failure/Error: expect(combined_response.errors).to eq(front: ['front-error-1', 'front-error-2'])
expected: {:front=>["front-error-1", "front-error-2"]}
got: {:front=>["front-error-2"]}
(compared using ==)
Diff:
@@ -1 +1 @@
-:front => ["front-error-1", "front-error-2"],
+:front => ["front-error-2"],
# ./spec/services/form_response_spec.rb:56:in `block (3 levels) in <top (required)>'
So we incidentally fix an issue where we may be dropping some errors in merging two responses.
Why: An ActiveModel::Errors instance can represent both the "message" and "type" of an error, where the message is the translated, human-readable text shown to the user, and the type is typically the locale-independent, machine-readable unique identifier of a particular error. By supporting an ActiveModel::Errors instance in FormResponse, we can retain current behaviors of showing and logging a human-readable error message, while also allowing us to identify and group instances of an error across locales in a CloudWatch query.
With few exceptions, this also aligns to how we most often use FormResponse, where it's initialized using errors from an instance of ActiveModel::Errors. The difference is a simplification to pass the errors instance itself, rather than the result of its
messagesmethod (i.e.errors: errorsinstead oferrors: errors.messages).It may be possible to consolidate to avoid overloading and instead supporting only the ActiveModel::Errors instance in this class, but (a) there are a handful of existing cases where we create hashes ad-hoc for logging and (b) in those cases, the ActiveModel::Errors equivalent implementation is relatively clunky.
Publishing this as a draft, since there are a number of specs which need to be updated, and I'd not want to start down that path if there's questions about the viability of this approach. For ease of review, 445c4ad contains only the relevant changes for FormResponse.