error_normalizer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3a2b14320f32c8b5b9709d2a7963941ff27cebe857533a3a38401e4ccb08709a
4
+ data.tar.gz: 5d9f38f16ea5aac77e3a0318eb4cacac7b9aab5d24574e8d55c5c3de4b3cb08f
5
+ SHA512:
6
+ metadata.gz: ea13a01344de75bd4161ff70a70f268aaa2d6b393273f14e000da2b8213ca61eae426d12e97acdbe06eaf9f2dbbe4fa3d4427214e47ad46558de8422105dd058
7
+ data.tar.gz: 58fd33623c68e76af2bf94fbd7ec1cd8dea6a17baae99315bfddc687862c00e6f9dbdf38d3ebd6f83f69cb06b6c5232986548bac98c9e4ddb15baf4e4e118136
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,90 @@
1
+ # Common configuration.
2
+ AllCops:
3
+ TargetRubyVersion: 2.5
4
+ TargetRailsVersion: 5.1
5
+
6
+ #################### Style ###########################
7
+
8
+ Style/Documentation:
9
+ Enabled: true
10
+
11
+ Style/DoubleNegation:
12
+ Enabled: false
13
+
14
+ Style/EmptyMethod:
15
+ Enabled: false
16
+
17
+ Style/FrozenStringLiteralComment:
18
+ Enabled: true
19
+
20
+ Style/ModuleFunction:
21
+ Enabled: false
22
+
23
+ #################### Naming ##########################
24
+
25
+ Naming/AccessorMethodName:
26
+ Enabled: false
27
+
28
+ Naming/PredicateName:
29
+ Enabled: false
30
+
31
+ #################### Metrics #########################
32
+
33
+ Metrics/AbcSize:
34
+ Max: 15
35
+
36
+ Metrics/BlockLength:
37
+ CountComments: false
38
+ Max: 25
39
+ Exclude:
40
+ - 'error_normalizer.gemspec'
41
+ - 'spec/**/*_spec.rb'
42
+
43
+ Metrics/BlockNesting:
44
+ CountBlocks: false
45
+ Max: 3
46
+
47
+ Metrics/ClassLength:
48
+ CountComments: false
49
+ Max: 100
50
+
51
+ # Avoid complex methods.
52
+ Metrics/CyclomaticComplexity:
53
+ Max: 6
54
+
55
+ Metrics/LineLength:
56
+ Max: 100
57
+ # To make it possible to copy or click on URIs in the code, we allow lines
58
+ # containing a URI to be longer than Max.
59
+ AllowHeredoc: true
60
+ AllowURI: true
61
+ URISchemes:
62
+ - http
63
+ - https
64
+ # The IgnoreCopDirectives option causes the LineLength rule to ignore cop
65
+ # directives like '# rubocop: enable ...' when calculating a line's length.
66
+ IgnoreCopDirectives: true
67
+ # The IgnoredPatterns option is a list of !ruby/regexp and/or string
68
+ # elements. Strings will be converted to Regexp objects. A line that matches
69
+ # any regular expression listed in this option will be ignored by LineLength.
70
+ IgnoredPatterns: []
71
+
72
+ Metrics/MethodLength:
73
+ CountComments: false
74
+ Max: 12
75
+
76
+ Metrics/ModuleLength:
77
+ CountComments: false
78
+ Max: 200
79
+
80
+ Metrics/ParameterLists:
81
+ Max: 5
82
+ CountKeywordArgs: true
83
+
84
+ Metrics/PerceivedComplexity:
85
+ Max: 7
86
+
87
+ ##################### Layout ##########################
88
+
89
+ Layout/MultilineMethodCallIndentation:
90
+ Enabled: false
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in error_normalizer.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,83 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ error_normalizer (0.1.0)
5
+ dry-configurable (~> 0.7.0)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ ast (2.4.0)
11
+ concurrent-ruby (1.0.5)
12
+ diff-lcs (1.3)
13
+ dry-configurable (0.7.0)
14
+ concurrent-ruby (~> 1.0)
15
+ dry-container (0.6.0)
16
+ concurrent-ruby (~> 1.0)
17
+ dry-configurable (~> 0.1, >= 0.1.3)
18
+ dry-core (0.4.7)
19
+ concurrent-ruby (~> 1.0)
20
+ dry-equalizer (0.2.1)
21
+ dry-inflector (0.1.2)
22
+ dry-logic (0.4.2)
23
+ dry-container (~> 0.2, >= 0.2.6)
24
+ dry-core (~> 0.2)
25
+ dry-equalizer (~> 0.2)
26
+ dry-types (0.13.2)
27
+ concurrent-ruby (~> 1.0)
28
+ dry-container (~> 0.3)
29
+ dry-core (~> 0.4, >= 0.4.4)
30
+ dry-equalizer (~> 0.2)
31
+ dry-inflector (~> 0.1, >= 0.1.2)
32
+ dry-logic (~> 0.4, >= 0.4.2)
33
+ dry-validation (0.12.2)
34
+ concurrent-ruby (~> 1.0)
35
+ dry-configurable (~> 0.1, >= 0.1.3)
36
+ dry-core (~> 0.2, >= 0.2.1)
37
+ dry-equalizer (~> 0.2)
38
+ dry-logic (~> 0.4, >= 0.4.0)
39
+ dry-types (~> 0.13.1)
40
+ jaro_winkler (1.5.1)
41
+ parallel (1.12.1)
42
+ parser (2.5.1.2)
43
+ ast (~> 2.4.0)
44
+ powerpack (0.1.2)
45
+ rainbow (3.0.0)
46
+ rake (10.4.2)
47
+ rspec (3.8.0)
48
+ rspec-core (~> 3.8.0)
49
+ rspec-expectations (~> 3.8.0)
50
+ rspec-mocks (~> 3.8.0)
51
+ rspec-core (3.8.0)
52
+ rspec-support (~> 3.8.0)
53
+ rspec-expectations (3.8.1)
54
+ diff-lcs (>= 1.2.0, < 2.0)
55
+ rspec-support (~> 3.8.0)
56
+ rspec-mocks (3.8.0)
57
+ diff-lcs (>= 1.2.0, < 2.0)
58
+ rspec-support (~> 3.8.0)
59
+ rspec-support (3.8.0)
60
+ rubocop (0.59.2)
61
+ jaro_winkler (~> 1.5.1)
62
+ parallel (~> 1.10)
63
+ parser (>= 2.5, != 2.5.1.1)
64
+ powerpack (~> 0.1)
65
+ rainbow (>= 2.2.2, < 4.0)
66
+ ruby-progressbar (~> 1.7)
67
+ unicode-display_width (~> 1.0, >= 1.0.1)
68
+ ruby-progressbar (1.10.0)
69
+ unicode-display_width (1.4.0)
70
+
71
+ PLATFORMS
72
+ ruby
73
+
74
+ DEPENDENCIES
75
+ bundler (~> 1.16)
76
+ dry-validation (~> 0.12.2)
77
+ error_normalizer!
78
+ rake (~> 10.0)
79
+ rspec (~> 3.0)
80
+ rubocop (= 0.59.2)
81
+
82
+ BUNDLED WITH
83
+ 1.16.6
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 dikond
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # Hola :call_me_hand:
2
+
3
+ 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.
4
+
5
+ ## API error format
6
+
7
+ 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:
8
+
9
+ {
10
+ errors: [{
11
+ key: 'has_already_been_taken',
12
+ type: 'params',
13
+ message: 'has already been taken',
14
+ payload: {
15
+ path: 'user.email'
16
+ }
17
+ }]
18
+ }
19
+
20
+ Each error object **must have** 4 required fields: `key`, `type`, `message` and `payload`.
21
+
22
+ - `key` is a concise error code that will be used in a user-friendly translations
23
+ - `type` may be `params`, `custom` or something else
24
+ - `params` means that some parameter that the backend received was wrong
25
+ - `custom` covers everything else from the business validation composed from several parameters to something really special
26
+ - `message` is a "default" or "fallback" error message in plain English that may be used if client does not have translation for the error code
27
+ - `payload` contains other useful data that assists client error handling. For example, in case of `type: "params"` we can provide a _path_ to the invalid paramter.
28
+
29
+ ## Usage
30
+
31
+ ### dry-validation
32
+
33
+ GIVEN following [dry-validation](https://dry-rb.org/gems/dry-validation/) schema
34
+
35
+ schema = Dry::Validation.Schema do
36
+ required(:name).filled(size?: 3..15)
37
+ required(:email).filled(format?: /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i)
38
+ optional(:credit_card).filled(:bool?)
39
+ optional(:cash).filled(:bool?)
40
+
41
+ rule(payment: [:credit_card, :cash]) do |card, cash|
42
+ card.eql?(true) ^ cash.eql?(true)
43
+ end
44
+ end
45
+
46
+ AND following input
47
+
48
+ errors = schema.call(name: 'DK', email: 'dk<@>dark.net').errors
49
+ # {:name=>["length must be within 3 - 15"], :credit_card=>["must be filled"]}
50
+
51
+ THEN we can convert given errors to API error format
52
+
53
+ ErrorNormalizer.normalize(errors)
54
+ # [{
55
+ # :key=>"length_must_be_within",
56
+ # :message=>"length must be within 3 - 15",
57
+ # :payload=>{:path=>"name", :range=>["3", "15"]},
58
+ # :type=>"params"
59
+ # }, {
60
+ # :key=>"is_in_invalid_format",
61
+ # :message=>"is in invalid format",
62
+ # :payload=>{:path=>"email"},
63
+ # :type=>"params"
64
+ # }, {
65
+ # :key=>"must_be_equal_to",
66
+ # :message=>"must be equal to true",
67
+ # :payload=>{:path=>"payment", :value=>"true"},
68
+ # :type=>"params"
69
+ # }]
70
+
71
+ For more information about supported errors and how they would be parsed please check the spec.
72
+
73
+ #### Type-inference feature
74
+
75
+ **TL;DR:** Add `_rule` to the [custom validation block](https://dry-rb.org/gems/dry-validation/custom-validation-blocks/) names (adding this to the [high-level rules](https://dry-rb.org/gems/dry-validation/high-level-rules/) won't harm either, praise the consistency!).
76
+
77
+ **Long version**: When you're using [custom validation blocks](https://dry-rb.org/gems/dry-validation/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
78
+
79
+ schema = Dry::Validation.Schema do
80
+ configure do
81
+ def self.messages
82
+ super.merge(en: { errors: { email_required: 'provide email' } })
83
+ end
84
+ end
85
+
86
+ required(:email).maybe(:str?)
87
+ required(:newsletter).value(:bool?)
88
+
89
+ validate(email_required: %i[newsletter email]) do |newsletter, email|
90
+ if newsletter == true
91
+ !email.nil?
92
+ else
93
+ true
94
+ end
95
+ end
96
+ end
97
+
98
+ AND the following input
99
+
100
+ errors = schema.call(newsletter: true, email: nil).errors
101
+ # { email_required: ['provide email'] }
102
+
103
+ THEN we will get following format after normalization
104
+
105
+ ErrorNormalizer.normalie(errors)
106
+ # [{
107
+ # key: 'provide_email',
108
+ # message: 'provide email',
109
+ # payload: { path: 'email_required' }, # should be empty to not confuse ppl
110
+ # type: 'params' # should be "rule" or "custom" but definately not "params"
111
+ # }]
112
+
113
+ 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
114
+
115
+ validate(email_required_rule: %i[newsletter email]) do |newsletter, email|
116
+ false
117
+ end
118
+
119
+ Now validation will produce errors like this
120
+
121
+ { email_required_rule: ['provide email'] }
122
+
123
+ But we can easily spot keys which end with `_rule` and normalize such erros appropiately to the following format
124
+
125
+ ErrorNormalizer.normalize(email_required_rule: ['provide email'])
126
+ # [{
127
+ # key: 'provide_email',
128
+ # message: 'provide email',
129
+ # payload: {},
130
+ # type: 'rule'
131
+ # }]
132
+
133
+ You can customize rule name match pattern, type name or turn off this feature completely by specifying it in configuration block
134
+
135
+ ErrorNormalizer.configure do |config|
136
+ config.infer_type_from_rule_name = true
137
+ config.rule_matcher = /_rule\z/
138
+ config.type_name = 'rule'
139
+ end
140
+
141
+ ### ActiveModel::Validations
142
+
143
+ 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.
144
+
145
+ GIVEN we have a model like this
146
+
147
+ class TestUser
148
+ include ActiveModel::Validations
149
+
150
+ attr_reader :name, :email
151
+ def initialize(name:, email:)
152
+ @name = name
153
+ @email = email
154
+ end
155
+
156
+ validates :name, presence: true, length: { in: 3..15 }
157
+ validates :email, presence: true, format: { with: /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i }
158
+ end
159
+
160
+ AND initialzied object with invalid data
161
+
162
+ user = TestUser.new(name: 'DK', email: 'dk<@>dark.net').tap(&:validate)
163
+
164
+ THEN we can normalize object errors to API error format
165
+
166
+ ErrorNormalizer.normalize(user.errors.to_hash)
167
+ # [{
168
+ # :key=>"is_too_short_minimum_is_3_characters",
169
+ # :message=>"is too short (minimum is 3 characters)",
170
+ # :payload=>{:path=>"name"},
171
+ # :type=>"params"
172
+ # }, {
173
+ # :key=>"is_invalid",
174
+ # :message=>"is invalid",
175
+ # :payload=>{:path=>"email"},
176
+ # :type=>"params"
177
+ # }]
178
+
179
+ ## TODO
180
+
181
+ - plugin to make full error translation
182
+ - configure Gitlab CI
183
+ - parse ActiveModel error mesasges
184
+
185
+ ## License
186
+
187
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/error_normalizer/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'error_normalizer'
7
+ spec.version = ErrorNormalizer::VERSION
8
+ spec.authors = ['Denis Kondratenko']
9
+ spec.email = ['di.kondratenko@gmail.com']
10
+
11
+ spec.summary = 'Normalize dry-validation and ActiveModel errors to the universal format'
12
+ spec.homepage = 'https://gitlab.yalantis.com/web/error_normalizer'
13
+ spec.license = 'MIT'
14
+
15
+ # Specify which files should be added to the gem when it is released.
16
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
17
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
18
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
19
+ end
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_runtime_dependency 'dry-configurable', '~> 0.7.0'
23
+
24
+ spec.add_development_dependency 'bundler', '~> 1.16'
25
+ spec.add_development_dependency 'dry-validation', '~> 0.12.2'
26
+ spec.add_development_dependency 'rake', '~> 10.0'
27
+ spec.add_development_dependency 'rspec', '~> 3.0'
28
+ spec.add_development_dependency 'rubocop', '0.59.2'
29
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-configurable'
4
+ require 'error_normalizer/version'
5
+ require 'error_normalizer/normalizer'
6
+
7
+ #
8
+ # Intended to normalize errors to the single format:
9
+ #
10
+ # {
11
+ # key: 'has_already_been_taken',
12
+ # type: 'params',
13
+ # message: 'has already been taken',
14
+ # payload: {
15
+ # path: 'user.email'
16
+ # }
17
+ # }
18
+ #
19
+ # We shall be able to automatically convert dry-validation output to this format
20
+ # and since we're using rails also automatically convert ActiveModel::Errors.
21
+ #
22
+ class ErrorNormalizer
23
+ extend Dry::Configurable
24
+
25
+ setting :infer_type_from_rule_name, true
26
+ setting :rule_matcher, /_rule\z/
27
+ setting :type_name, 'rule'
28
+
29
+ #
30
+ # Normalize errors to flat array of structured errors.
31
+ #
32
+ # @param input [Hash]
33
+ # @param opts [Hash] for list of supported options check ErrorNormalizer::Normalizer#new
34
+ # @return [Array<Hash>]
35
+ #
36
+ def self.normalize(input, **opts)
37
+ defaults = config.to_hash
38
+ Normalizer.new(input, defaults.merge(opts)).normalize.to_a
39
+ end
40
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ErrorNormalizer
4
+ # Error struct which makes cosmetic normalization
5
+ # upon calling either #to_hash and #to_json.
6
+ # Supports case equality check (#===) for hash structs.
7
+ #
8
+ # @example
9
+ # Error.new('not_plausible', message: "can't recognize your phone", path: 'user.phone')
10
+ # Error.new('not_authorized').to_hash
11
+ # #=> { key: 'not_authorized', message: 'not authorized', type: 'params', payload: {} }
12
+ #
13
+ # # case equality works with hashes
14
+ # err = { key: 'err', message: 'err', type: 'custom', payload: {} }
15
+ # message =
16
+ # case err
17
+ # when Error
18
+ # 'YEP'
19
+ # else
20
+ # 'NOPE'
21
+ # end
22
+ # puts message #=> 'YEP'
23
+ #
24
+ class Error
25
+ def initialize(error_key, message: nil, type: 'params', **payload)
26
+ @key = error_key
27
+ @message = message
28
+ @type = type
29
+ @payload = payload
30
+ end
31
+
32
+ def self.===(other)
33
+ return true if other.is_a?(Error)
34
+ return false unless other.is_a?(Hash)
35
+
36
+ h = other.transform_keys(&:to_s)
37
+ h.key?('key') & h.key?('message') && h.key?('payload') && h.key?('type')
38
+ end
39
+
40
+ def to_hash
41
+ {
42
+ key: @key,
43
+ message: message,
44
+ payload: payload,
45
+ type: @type
46
+ }
47
+ end
48
+
49
+ def to_json
50
+ to_hash.to_json
51
+ end
52
+
53
+ private
54
+
55
+ def message
56
+ @message || @key.tr('_', ' ')
57
+ end
58
+
59
+ def payload
60
+ @payload.delete_if { |_k, v| v.nil? || v.empty? }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ErrorNormalizer
4
+ #
5
+ # Parse error messages and extract payload metadata.
6
+ #
7
+ # ActiveModel ignored for now because we don't plan to use its validations.
8
+ # In case message isn't recognized we set error to be a simple
9
+ # normalized message (no spaces and special characters).
10
+ #
11
+ # Here are the links to AM::Errors and Dry::Validation list of error messages:
12
+ # - Dry: https://github.com/dry-rb/dry-validation/blob/8417e8/config/errors.yml
13
+ # - AM: https://github.com/svenfuchs/rails-i18n/blob/70b38b/rails/locale/en-US.yml#L111
14
+ #
15
+ class MessageParser
16
+ VALUE_MATCHERS = [
17
+ /\A(?<err>must not include) (?<val>.+)/,
18
+ /\A(?<err>must be equal to) (?<val>.+)/,
19
+ /\A(?<err>must not be equal to) (?<val>.+)/,
20
+ /\A(?<err>must be greater than) (?<val>\d+)/,
21
+ /\A(?<err>must be greater than or equal to) (?<val>\d+)/,
22
+ /\A(?<err>must include) (?<val>.+)/,
23
+ /\A(?<err>must be less than) (?<val>\d+)/,
24
+ /\A(?<err>must be less than or equal to) (?<val>\d+)/,
25
+ /\A(?<err>size cannot be greater than) (?<val>\d+)/,
26
+ /\A(?<err>size cannot be less than) (?<val>\d+)/,
27
+ /\A(?<err>size must be) (?<val>\d+)/,
28
+ /\A(?<err>length must be) (?<val>\d+)/
29
+ ].freeze
30
+
31
+ LIST_MATCHERS = [
32
+ /\A(?<err>must not be one of): (?<val>.+)/,
33
+ /\A(?<err>must be one of): (?<val>.+)/,
34
+ /\A(?<err>size must be within) (?<val>.+)/,
35
+ /\A(?<err>length must be within) (?<val>.+)/
36
+ ].freeze
37
+
38
+ def initialize(message)
39
+ @message = message
40
+ @key = nil
41
+ @payload = {}
42
+ end
43
+
44
+ def parse
45
+ parse_value_message
46
+ return to_a if @key
47
+
48
+ parse_list_message
49
+ return to_a if @key
50
+
51
+ @key = to_key(@message)
52
+ to_a
53
+ end
54
+
55
+ def to_a
56
+ [@key, @message, @payload]
57
+ end
58
+
59
+ private
60
+
61
+ def parse_value_message
62
+ VALUE_MATCHERS.each do |matcher|
63
+ data = matcher.match(@message)
64
+ next if data.nil?
65
+
66
+ @key = to_key(data[:err])
67
+ @payload[:value] = data[:val]
68
+
69
+ break
70
+ end
71
+ end
72
+
73
+ def parse_list_message
74
+ LIST_MATCHERS.each do |matcher|
75
+ data = matcher.match(@message)
76
+ next if data.nil?
77
+
78
+ @key = to_key(data[:err])
79
+ @payload.merge!(parse_list_payload(data[:val]))
80
+
81
+ break
82
+ end
83
+ end
84
+
85
+ def to_key(msg)
86
+ msg.downcase.tr(' ', '_').gsub(/[^a-z0-9_]/, '')
87
+ end
88
+
89
+ def parse_list_payload(str)
90
+ if str.include?(' - ')
91
+ { range: str.split(' - ') }
92
+ else
93
+ { list: str.split(', ') }
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+ require_relative 'message_parser'
5
+
6
+ class ErrorNormalizer
7
+ #
8
+ # Responsible for converting input to the array of normalized errors.
9
+ #
10
+ # @example
11
+ # errors = { phone: ['not plausible'] }
12
+ # ErrorNormalizer::Normalizer.new(errors, namespace: 'customer').normalize
13
+ # # [{
14
+ # # key: 'not_plausible',
15
+ # # message: 'not plausible',
16
+ # # payload: { path: 'customer.phone' },
17
+ # # type: 'params'
18
+ # # }]
19
+ #
20
+ class Normalizer
21
+ UnsupportedInputType = Class.new(StandardError)
22
+
23
+ attr_reader :errors
24
+
25
+ def initialize(input, namespace: nil, **config)
26
+ @input = input
27
+ @namespace = namespace
28
+ @errors = []
29
+ @config = config
30
+ end
31
+
32
+ def add_error(error, path: nil, **options)
33
+ @errors <<
34
+ case error
35
+ when Error
36
+ error
37
+ when Symbol, String
38
+ parse_error(error, path, options)
39
+ end
40
+ end
41
+
42
+ def normalize
43
+ case @input
44
+ when Hash
45
+ normalize_hash(@input.dup)
46
+ when ActiveModel::Errors
47
+ normalize_hash(@input.to_hash)
48
+ else
49
+ raise "Don't know how to normalize errors"
50
+ end
51
+
52
+ self
53
+ end
54
+
55
+ def to_a
56
+ @errors.map(&:to_hash)
57
+ end
58
+
59
+ private
60
+
61
+ def normalize_hash(input) # rubocop:disable AbcSize
62
+ return add_error(input) if input.is_a?(Error)
63
+
64
+ input.each do |key, value|
65
+ if messages_ary?(value)
66
+ options = prepare_error_options(key)
67
+ value.each { |msg| add_error(msg, options) }
68
+ elsif value.is_a?(Hash)
69
+ ns = namespaced_path(key)
70
+ Normalizer.new(value, namespace: ns).normalize.errors.each { |e| add_error(e) }
71
+ else
72
+ raise UnsupportedInputType
73
+ end
74
+ end
75
+ end
76
+
77
+ def messages_ary?(ary)
78
+ return false unless ary.is_a? Array
79
+
80
+ ary.all? { |v| v.is_a? String }
81
+ end
82
+
83
+ def parse_error(err_message, path, options)
84
+ result = MessageParser.new(err_message).parse
85
+ key, msg, payload = result.to_a
86
+
87
+ Error.new(key, message: msg, path: namespaced_path(path), **payload, **options)
88
+ end
89
+
90
+ def namespaced_path(path)
91
+ return if path.nil?
92
+ return path.to_s if @namespace.nil?
93
+
94
+ [@namespace, path].compact.join('.')
95
+ end
96
+
97
+ def prepare_error_options(key)
98
+ type = 'params'
99
+ payload = {}
100
+
101
+ if @config[:infer_type_from_rule_name] && @config[:rule_matcher].match?(key)
102
+ type = @config[:type_name]
103
+ else
104
+ payload = { path: key }
105
+ end
106
+
107
+ payload.merge!(type: type)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ErrorNormalizer
4
+ VERSION = '0.1.0'
5
+ end
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: error_normalizer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Denis Kondratenko
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-11-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-configurable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.7.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.7.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.16'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.16'
41
+ - !ruby/object:Gem::Dependency
42
+ name: dry-validation
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.12.2
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.12.2
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '10.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '10.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '='
88
+ - !ruby/object:Gem::Version
89
+ version: 0.59.2
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '='
95
+ - !ruby/object:Gem::Version
96
+ version: 0.59.2
97
+ description:
98
+ email:
99
+ - di.kondratenko@gmail.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".gitignore"
105
+ - ".rspec"
106
+ - ".rubocop.yml"
107
+ - Gemfile
108
+ - Gemfile.lock
109
+ - LICENSE.txt
110
+ - README.md
111
+ - Rakefile
112
+ - error_normalizer.gemspec
113
+ - lib/error_normalizer.rb
114
+ - lib/error_normalizer/error.rb
115
+ - lib/error_normalizer/message_parser.rb
116
+ - lib/error_normalizer/normalizer.rb
117
+ - lib/error_normalizer/version.rb
118
+ homepage: https://gitlab.yalantis.com/web/error_normalizer
119
+ licenses:
120
+ - MIT
121
+ metadata: {}
122
+ post_install_message:
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubyforge_project:
138
+ rubygems_version: 2.7.7
139
+ signing_key:
140
+ specification_version: 4
141
+ summary: Normalize dry-validation and ActiveModel errors to the universal format
142
+ test_files: []