error_normalizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []