Hola :call_me_hand:
This gem was born out of need to have a establish a single universal error format to be consumed by frontend (JS), Android and iOS clients.
API error format
In our projects we have a convention, that in case of a failed request (422) backend shall return a JSON response which conforms to the following schema:
{
errors: [{
key: 'has_already_been_taken',
type: 'params',
message: 'has already been taken',
payload: {
path: 'user.email'
}
}]
}
Each error object must have 4 required fields: key, type, message and payload.
keyis a concise error code that will be used in a user-friendly translationstypemay beparams,customor something elseparamsmeans that some parameter that the backend received was wrongcustomcovers everything else from the business validation composed from several parameters to something really special
messageis a "default" or "fallback" error message in plain English that may be used if client does not have translation for the error codepayloadcontains other useful data that assists client error handling. For example, in case oftype: "params"we can provide a path to the invalid paramter.
Usage
dry-validation
GIVEN following dry-validation schema
schema = Dry::Validation.Schema do
required(:name).filled(size?: 3..15)
required(:email).filled(format?: /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i)
optional(:credit_card).filled(:bool?)
optional(:cash).filled(:bool?)
rule(payment: [:credit_card, :cash]) do |card, cash|
card.eql?(true) ^ cash.eql?(true)
end
end
AND following input
errors = schema.call(name: 'DK', email: 'dk<@>dark.net').errors
# {:name=>["length must be within 3 - 15"], :credit_card=>["must be filled"]}
THEN we can convert given errors to API error format
ErrorNormalizer.normalize(errors)
# [{
# :key=>"length_must_be_within",
# :message=>"length must be within 3 - 15",
# :payload=>{:path=>"name", :range=>["3", "15"]},
# :type=>"params"
# }, {
# :key=>"is_in_invalid_format",
# :message=>"is in invalid format",
# :payload=>{:path=>"email"},
# :type=>"params"
# }, {
# :key=>"must_be_equal_to",
# :message=>"must be equal to true",
# :payload=>{:path=>"payment", :value=>"true"},
# :type=>"params"
# }]
For more information about supported errors and how they would be parsed please check the spec.
Type-inference feature
TL;DR: Add _rule to the custom validation block names (adding this to the high-level rules won't harm either, praise the consistency!).
Long version: When you're using custom validation blocks the error output is slightly diffenet. Instead of attribute name it will have a rule name as a key. For example, GIVEN this schema
schema = Dry::Validation.Schema do
configure do
def self.
super.merge(en: { errors: { email_required: 'provide email' } })
end
end
required(:email).maybe(:str?)
required(:newsletter).value(:bool?)
validate(email_required: %i[newsletter email]) do |, email|
if == true
!email.nil?
else
true
end
end
end
AND the following input
errors = schema.call(newsletter: true, email: nil).errors
# { email_required: ['provide email'] }
THEN we will get following format after normalization
ErrorNormalizer.normalie(errors)
# [{
# key: 'provide_email',
# message: 'provide email',
# payload: { path: 'email_required' }, # should be empty to not confuse ppl
# type: 'params' # should be "rule" or "custom" but definately not "params"
# }]
The solution to this problem would be to use type inference from the rule name feature. Just add a _rule to the name of a custom block validation, like this
validate(email_required_rule: %i[newsletter email]) do |, email|
false
end
Now validation will produce errors like this
{ email_required_rule: ['provide email'] }
But we can easily spot keys which end with _rule and normalize such erros appropiately to the following format
ErrorNormalizer.normalize(email_required_rule: ['provide email'])
# [{
# key: 'provide_email',
# message: 'provide email',
# payload: {},
# type: 'rule'
# }]
You can customize rule name match pattern, type name or turn off this feature completely by specifying it in configuration block
ErrorNormalizer.configure do |config|
config.infer_type_from_rule_name = true
config.rule_matcher = /_rule\z/
config.type_name = 'rule'
end
ActiveModel::Validations
ActiveModel errors aren't fully supported. By that I mean errors will be converted to the single format, however you won't see really unique error key or payload with additional info.
GIVEN we have a model like this
class TestUser
include ActiveModel::Validations
attr_reader :name, :email
def initialize(name:, email:)
@name = name
@email = email
end
validates :name, presence: true, length: { in: 3..15 }
validates :email, presence: true, format: { with: /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i }
end
AND initialzied object with invalid data
user = TestUser.new(name: 'DK', email: 'dk<@>dark.net').tap(&:validate)
THEN we can normalize object errors to API error format
ErrorNormalizer.normalize(user.errors.to_hash)
# [{
# :key=>"is_too_short_minimum_is_3_characters",
# :message=>"is too short (minimum is 3 characters)",
# :payload=>{:path=>"name"},
# :type=>"params"
# }, {
# :key=>"is_invalid",
# :message=>"is invalid",
# :payload=>{:path=>"email"},
# :type=>"params"
# }]
TODO
- plugin to make full error translation
- configure Gitlab CI
- parse ActiveModel error mesasges
License
The gem is available as open source under the terms of the MIT License.