zappb-exceptions 1.0.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.
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZappbException
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "zappb_exception.i18n" do
6
+ ::I18n.load_path |= ZappbException::LOCALE_FILES
7
+ end
8
+
9
+ config.after_initialize { ZappbException.finalize! }
10
+ end
11
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZappbException
4
+ class Registry
5
+ def initialize(view: nil)
6
+ @definitions = {}
7
+ @aliases = {}
8
+ @class_aliases = {}
9
+ @frozen = false
10
+ @view = view
11
+ @index = nil
12
+ end
13
+
14
+ def load_base
15
+ BASE_CATALOG.each { |entry| register(entry[:name], **entry.except(:name), base: true) }
16
+ self
17
+ end
18
+
19
+ def register(name, aliases: [], override: false, **attrs)
20
+ raise FrozenRegistryError, frozen_message(name) if @frozen
21
+
22
+ definition = Definition.new(name: name, **attrs)
23
+ alias_names = Array(aliases).map { |a| validate_alias!(a, definition) }
24
+ check_name!(definition, override)
25
+ check_numbering!(definition)
26
+
27
+ ClassFactory.call(definition, alias_names)
28
+ @definitions[definition.name] = definition
29
+ alias_names.each { |a| @aliases[a] = definition.name }
30
+ changed!
31
+ definition
32
+ end
33
+
34
+ def alias_external(class_name, to:)
35
+ raise FrozenRegistryError, frozen_message(class_name) if @frozen
36
+
37
+ class_name = validate_class_alias!(class_name)
38
+ target = lookup(to)
39
+ raise ConfigurationError, "alias_external '#{class_name}': target entry '#{to}' does not exist" unless target
40
+
41
+ @class_aliases[class_name] = target.name
42
+ changed!
43
+ target
44
+ end
45
+
46
+ def freeze!
47
+ @frozen = true
48
+ self
49
+ end
50
+
51
+ def frozen?
52
+ @frozen
53
+ end
54
+
55
+ def definitions
56
+ @definitions.values
57
+ end
58
+
59
+ def aliases
60
+ @aliases.dup
61
+ end
62
+
63
+ def class_aliases
64
+ @class_aliases.dup
65
+ end
66
+
67
+ def lookup(name)
68
+ name = name.to_s.delete_prefix("ZappbException::")
69
+ @definitions[name] || (@aliases[name] && @definitions[@aliases[name]]) ||
70
+ (@class_aliases[name] && @definitions[@class_aliases[name]])
71
+ end
72
+
73
+ def definition_for_class(klass)
74
+ name = klass.name
75
+ found = index[name] if name
76
+ return found if found
77
+
78
+ @definitions.each_value do |definition|
79
+ return definition if definition.klass && definition.resolve_klass.equal?(klass)
80
+ end
81
+ nil
82
+ end
83
+
84
+ def to_exceptions
85
+ @definitions.transform_values do |d|
86
+ entry = { status: d.status, error_code: d.number, message: d.message }
87
+ entry[:code] = d.code if d.code
88
+ entry[:external] = true if d.external
89
+ entry[:payload] = d.payload if d.payload
90
+ entry.freeze
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def changed!
97
+ @index = nil
98
+ @view&.replace(to_exceptions)
99
+ end
100
+
101
+ def frozen_message(name)
102
+ "Cannot register '#{name}': the catalog is already frozen (it is frozen in after_initialize " \
103
+ "or by ZappbException.finalize!). Register errors in config/initializers."
104
+ end
105
+
106
+ def validate_alias!(alias_name, definition)
107
+ alias_name = alias_name.to_s
108
+ if alias_name.start_with?("ZappbException::")
109
+ raise ConfigurationError, "The alias '#{alias_name}' must not have the 'ZappbException::' prefix"
110
+ end
111
+ if alias_name == definition.name
112
+ raise ConfigurationError, "The alias '#{alias_name}' matches the definition name"
113
+ end
114
+ if @definitions.key?(alias_name) || (@aliases.key?(alias_name) && @aliases[alias_name] != definition.name)
115
+ raise ConfigurationError, "The alias '#{alias_name}' is already registered"
116
+ end
117
+ unless alias_name.include?("::") || alias_name.match?(/\A[A-Z]\w*\z/)
118
+ raise ConfigurationError, "#{alias_name}: invalid alias name"
119
+ end
120
+
121
+ alias_name
122
+ end
123
+
124
+ def validate_class_alias!(class_name)
125
+ class_name = class_name.to_s
126
+ if class_name.start_with?("ZappbException::")
127
+ raise ConfigurationError, "The alias '#{class_name}' must not have the 'ZappbException::' prefix"
128
+ end
129
+ unless class_name.split("::", -1).all? { |segment| segment.match?(NAME_SEGMENT) }
130
+ raise ConfigurationError, "#{class_name}: invalid class name"
131
+ end
132
+ if @definitions.key?(class_name) || @aliases.key?(class_name) || @class_aliases.key?(class_name)
133
+ raise ConfigurationError, "The alias '#{class_name}' is already registered"
134
+ end
135
+
136
+ class_name
137
+ end
138
+
139
+ def check_name!(definition, override)
140
+ if @class_aliases.key?(definition.name)
141
+ raise ConfigurationError, "'#{definition.name}' is already registered as an alias of #{@class_aliases[definition.name]}"
142
+ end
143
+ existing = @definitions[definition.name]
144
+ if @aliases.key?(definition.name)
145
+ raise ConfigurationError, "'#{definition.name}' is already registered as an alias of #{@aliases[definition.name]}"
146
+ end
147
+ return unless existing
148
+ return if override
149
+
150
+ origin = existing.base ? "a base entry" : "a registered entry"
151
+ raise ConfigurationError, "'#{definition.name}' already exists as #{origin}; use override: true to redefine it"
152
+ end
153
+
154
+ def check_numbering!(definition)
155
+ return if definition.code
156
+
157
+ clash = @definitions.values.find do |other|
158
+ other.name != definition.name && other.code.nil? &&
159
+ other.status == definition.status && other.number == definition.number
160
+ end
161
+ return unless clash
162
+
163
+ raise ConfigurationError, "#{definition.name}: #{definition.status}-#{definition.number} is already used by " \
164
+ "#{clash.name}; use another number or define a code:"
165
+ end
166
+
167
+ def index
168
+ @index ||= build_index
169
+ end
170
+
171
+ def build_index
172
+ idx = {}
173
+ @definitions.each_value do |d|
174
+ next if d.klass
175
+
176
+ idx[d.external ? d.name : "ZappbException::#{d.name}"] = d
177
+ idx[d.name] = d if !d.external && d.name != "Exception" && !d.name.include?("::") && core_class?(d.name)
178
+ end
179
+ @class_aliases.each { |class_name, target| idx[class_name] = @definitions.fetch(target) }
180
+ @aliases.each do |alias_name, target|
181
+ idx[alias_name.include?("::") ? alias_name : "ZappbException::#{alias_name}"] = @definitions.fetch(target)
182
+ end
183
+ idx
184
+ end
185
+
186
+ def core_class?(name)
187
+ ::Object.const_defined?(name, false) && ::Object.const_get(name, false).is_a?(::Class) &&
188
+ ::Object.const_source_location(name) == []
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZappbException
4
+ class RemoteError
5
+ PREFIX = "ZappbException::"
6
+ LABEL = /\A\[[^\]]*\]\s*/
7
+
8
+ attr_reader :raw
9
+
10
+ def self.from(response)
11
+ response.is_a?(::Hash) ? new(response) : NoResponse.new
12
+ end
13
+
14
+ def self.read(hash, key)
15
+ return unless hash.is_a?(::Hash)
16
+
17
+ hash.key?(key.to_s) ? hash[key.to_s] : hash[key.to_sym]
18
+ end
19
+
20
+ def self.normalize_name(name)
21
+ text = name.to_s
22
+ text.start_with?(PREFIX) ? text.delete_prefix(PREFIX) : text
23
+ end
24
+
25
+ def initialize(raw)
26
+ @raw = raw
27
+ end
28
+
29
+ def no_response? = false
30
+
31
+ def error? = Nats.error_response?(raw)
32
+
33
+ def ok? = !error?
34
+
35
+ def success?
36
+ return false unless raw.is_a?(::Hash)
37
+
38
+ [true, "true"].include?(self.class.read(raw, :success))
39
+ end
40
+
41
+ def status
42
+ Integer(self.class.read(raw, :status), exception: false)
43
+ end
44
+
45
+ def code
46
+ string_or_nil(self.class.read(raw, :code))
47
+ end
48
+
49
+ def message
50
+ string_or_nil(self.class.read(raw, :message))
51
+ end
52
+
53
+ def exception_name
54
+ name = string_or_nil(error_hash_value(:exception))
55
+ name && self.class.normalize_name(name)
56
+ end
57
+
58
+ def kind?(*names)
59
+ current = exception_name
60
+ return false if current.nil?
61
+
62
+ names.flatten.any? { |name| self.class.normalize_name(name) == current }
63
+ end
64
+
65
+ def detail(strip_label: false)
66
+ text = string_or_nil(error_hash_value(:detail))
67
+ strip_label && text ? text.sub(LABEL, "") : text
68
+ end
69
+
70
+ def meta
71
+ value = self.class.read(raw, :meta)
72
+ value.is_a?(::Hash) ? value : {}
73
+ end
74
+
75
+ def extra
76
+ error = self.class.read(raw, :error)
77
+ return {} unless error.is_a?(::Hash)
78
+
79
+ error.each_with_object({}) do |(key, value), acc|
80
+ name = key.to_sym
81
+ acc[name] = value unless %i[exception detail].include?(name)
82
+ end
83
+ end
84
+
85
+ def to_forwarded_error
86
+ ForwardedError.new(raw)
87
+ end
88
+
89
+ private
90
+
91
+ def error_hash_value(key)
92
+ self.class.read(self.class.read(raw, :error), key)
93
+ end
94
+
95
+ def string_or_nil(value)
96
+ value.nil? ? nil : value.to_s
97
+ end
98
+
99
+ class NoResponse < RemoteError
100
+ def initialize(_raw = nil)
101
+ super(nil)
102
+ end
103
+
104
+ def no_response? = true
105
+
106
+ def error? = false
107
+
108
+ def ok? = false
109
+
110
+ def success? = false
111
+
112
+ def extra = {}
113
+
114
+ def status = nil
115
+
116
+ def code = nil
117
+
118
+ def message = nil
119
+
120
+ def exception_name = nil
121
+
122
+ def kind?(*) = false
123
+
124
+ def meta = {}
125
+
126
+ def detail(strip_label: false) = nil # rubocop:disable Lint/UnusedMethodArgument
127
+
128
+ def to_forwarded_error
129
+ GatewayTimeout.new
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZappbException
4
+ module Resolver
5
+ module_function
6
+
7
+ def call(exception, registry: ZappbException.registry)
8
+ exception.class.ancestors.each do |ancestor|
9
+ next unless ancestor.is_a?(::Class)
10
+
11
+ definition = registry.definition_for_class(ancestor)
12
+ return definition if definition
13
+ end
14
+ Definition::UNHANDLED
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rspec/expectations"
5
+ require "zappb_exception/testing"
6
+
7
+ module ZappbException
8
+ module RSpecHelpers
9
+ module_function
10
+
11
+ def resolved_name(exception)
12
+ ZappbException::Resolver.call(exception).name
13
+ end
14
+
15
+ def envelope_hash(actual)
16
+ actual = JSON.parse(actual) if actual.is_a?(::String)
17
+ actual.is_a?(::Hash) ? actual.deep_symbolize_keys : {}
18
+ end
19
+
20
+ def deep_symbolize(value)
21
+ value.is_a?(::Hash) ? value.deep_symbolize_keys : value
22
+ end
23
+
24
+ def expected_fields(code:, status:, message:, exception:, detail:, meta:)
25
+ { code: code, status: status, message: message,
26
+ exception: exception && ZappbException::RemoteError.normalize_name(exception),
27
+ detail: detail, meta: meta && deep_symbolize(meta) }.compact
28
+ end
29
+
30
+ def actual_fields(hash)
31
+ error = hash[:error].is_a?(::Hash) ? hash[:error] : {}
32
+ meta = hash[:meta].is_a?(::Hash) ? hash[:meta] : {}
33
+ exception = error[:exception] && ZappbException::RemoteError.normalize_name(error[:exception])
34
+ status = Integer(hash[:status], exception: false)
35
+ { code: hash[:code], status: status, message: hash[:message], exception: exception,
36
+ detail: error[:detail], meta: meta }
37
+ end
38
+ end
39
+ end
40
+
41
+ RSpec::Matchers.define :be_zappb_error do |name = nil|
42
+ match do |actual|
43
+ if name.nil?
44
+ actual.is_a?(ZappbException::BaseError)
45
+ else
46
+ actual.is_a?(::StandardError) &&
47
+ ZappbException::RSpecHelpers.resolved_name(actual) == ZappbException::RemoteError.normalize_name(name)
48
+ end
49
+ end
50
+
51
+ failure_message do |actual|
52
+ got = actual.is_a?(::StandardError) ? ZappbException::RSpecHelpers.resolved_name(actual) : actual.inspect
53
+ "expected #{actual.class} to resolve to #{name.inspect}, got #{got}"
54
+ end
55
+
56
+ description { "be a ZappbException error#{" #{name}" if name}" }
57
+ end
58
+
59
+ RSpec::Matchers.define :raise_zappb_error do |name, message = nil|
60
+ supports_block_expectations
61
+
62
+ match do |block|
63
+ @error = nil
64
+ begin
65
+ block.call
66
+ rescue ::StandardError => e
67
+ @error = e
68
+ end
69
+ @error &&
70
+ ZappbException::RSpecHelpers.resolved_name(@error) == ZappbException::RemoteError.normalize_name(name) &&
71
+ (message.nil? || values_match?(message, @error.message))
72
+ end
73
+
74
+ failure_message do
75
+ if @error
76
+ "expected a #{name} error#{" with message #{message.inspect}" if message}, " \
77
+ "got #{@error.class}: #{@error.message}"
78
+ else
79
+ "expected a #{name} error, but nothing was raised"
80
+ end
81
+ end
82
+
83
+ description { "raise a ZappbException #{name} error" }
84
+ end
85
+
86
+ RSpec::Matchers.define :match_error_envelope do |code: nil, status: nil, message: nil, exception: nil,
87
+ detail: nil, meta: nil|
88
+ match do |actual|
89
+ helpers = ZappbException::RSpecHelpers
90
+ @expected = helpers.expected_fields(code: code, status: status, message: message, exception: exception,
91
+ detail: detail, meta: meta)
92
+ @actual = helpers.actual_fields(helpers.envelope_hash(actual))
93
+ @expected.all? { |key, value| values_match?(value, @actual[key]) }
94
+ end
95
+
96
+ failure_message do
97
+ diff = @expected.reject { |key, value| values_match?(value, @actual[key]) }
98
+ "expected error envelope to match #{diff.inspect}, got #{@actual.slice(*diff.keys).inspect}"
99
+ end
100
+
101
+ description { "match error envelope #{@expected.inspect}" }
102
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zappb_exception"
4
+
5
+ module ZappbException
6
+ module Testing
7
+ module_function
8
+
9
+ def error_response(name, detail: nil, meta: {}, extra: {}, config: ZappbException.configuration)
10
+ key = RemoteError.normalize_name(name)
11
+ definition = ZappbException.registry.lookup(key)
12
+ raise ::ArgumentError, "Unregistered error: #{key}" unless definition
13
+
14
+ text = detail || Envelope.message_for(definition, config: config)
15
+ label = config.service_label
16
+ body = {
17
+ success: false,
18
+ status: definition.status,
19
+ code: definition.error_code(config.code_prefix),
20
+ message: Envelope.message_for(definition, config: config),
21
+ error: {
22
+ exception: exception_name(definition),
23
+ detail: label.nil? || label.to_s.empty? ? text : "[#{label}] #{text}"
24
+ }.merge(extra.to_h.transform_keys(&:to_sym).except(*Envelope::RESERVED_ERROR_KEYS))
25
+ }
26
+ meta.is_a?(::Hash) && !meta.empty? ? body.merge(meta: meta) : body
27
+ end
28
+
29
+ def exception_name(definition)
30
+ return definition.resolve_klass&.name || definition.name if definition.klass
31
+ return definition.name unless definition.internal?
32
+
33
+ "#{RemoteError::PREFIX}#{definition.name}"
34
+ end
35
+ private_class_method :exception_name
36
+
37
+ def success_response(data = {})
38
+ { success: true, status: 200, message: nil, data: data, meta: {} }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZappbException
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/hash/keys"
5
+ require "active_support/i18n"
6
+
7
+ require "zappb_exception/version"
8
+ require "zappb_exception/errors"
9
+ require "zappb_exception/definition"
10
+ require "zappb_exception/base_catalog"
11
+ require "zappb_exception/class_factory"
12
+ require "zappb_exception/registry"
13
+ require "zappb_exception/configuration"
14
+ require "zappb_exception/resolver"
15
+ require "zappb_exception/detail_sanitizer"
16
+ require "zappb_exception/envelope"
17
+ require "zappb_exception/describer"
18
+ require "zappb_exception/notifier"
19
+ require "zappb_exception/remote_error"
20
+ require "zappb_exception/nats"
21
+ require "zappb_exception/handler"
22
+ require "zappb_exception/rack"
23
+
24
+ module ZappbException
25
+ LOCALE_FILES = Dir[File.expand_path("../config/locales/*.yml", __dir__)].freeze
26
+
27
+ EXCEPTIONS = {}
28
+
29
+ class << self
30
+ def registry
31
+ @registry ||= Registry.new(view: EXCEPTIONS).load_base
32
+ end
33
+
34
+ def configuration
35
+ @configuration ||= Configuration.new(registry)
36
+ end
37
+
38
+ def configure
39
+ yield configuration
40
+ configuration
41
+ end
42
+
43
+ def finalize!
44
+ registry.freeze!
45
+ self
46
+ end
47
+
48
+ def reset!
49
+ @registry = nil
50
+ @configuration = nil
51
+ EXCEPTIONS.clear
52
+ registry
53
+ self
54
+ end
55
+
56
+ def error_code_for(entry)
57
+ prefix = configuration.code_prefix
58
+ return entry.error_code(prefix) if entry.is_a?(Definition)
59
+
60
+ entry[:code] || "#{entry[:status]}-#{prefix}#{format("%02d", entry[:error_code])}"
61
+ end
62
+
63
+ def generate_json(exception, name = exception.class.name, meta: {})
64
+ definition = unless name == exception.class.name
65
+ registry.lookup(name) || Definition::UNHANDLED
66
+ end
67
+ Envelope.call(exception, meta: meta, definition: definition).body
68
+ end
69
+
70
+ def describe(exception, config: configuration)
71
+ Describer.call(exception, config: config)
72
+ end
73
+
74
+ def log_level(exception, config: configuration)
75
+ Describer.level(exception, config: config)
76
+ end
77
+
78
+ def define_exceptions!
79
+ EXCEPTIONS
80
+ end
81
+
82
+ def const_missing(name)
83
+ definition = registry.lookup(name.to_s)
84
+ klass = definition&.resolve_klass
85
+ return const_set(name, klass) if klass
86
+
87
+ super
88
+ end
89
+ end
90
+
91
+ I18n.load_path |= LOCALE_FILES
92
+
93
+ registry
94
+ end
95
+
96
+ require "zappb_exception/railtie" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zappb-exceptions
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - BPO Advisors
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ description: Exception handling for microservices in an MSA architecture that communicate
27
+ through messaging. Each service declares its own error catalog; errors travel between
28
+ services and to clients as a stable JSON contract. Includes capture for Rails controllers,
29
+ Rack middlewares and NATS listeners, plus instrumentation events.
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.txt
36
+ - README.md
37
+ - config/locales/en.yml
38
+ - config/locales/es.yml
39
+ - lib/zappb/exceptions.rb
40
+ - lib/zappb_exception.rb
41
+ - lib/zappb_exception/base_catalog.rb
42
+ - lib/zappb_exception/class_factory.rb
43
+ - lib/zappb_exception/configuration.rb
44
+ - lib/zappb_exception/definition.rb
45
+ - lib/zappb_exception/describer.rb
46
+ - lib/zappb_exception/detail_sanitizer.rb
47
+ - lib/zappb_exception/envelope.rb
48
+ - lib/zappb_exception/errors.rb
49
+ - lib/zappb_exception/handler.rb
50
+ - lib/zappb_exception/nats.rb
51
+ - lib/zappb_exception/notifier.rb
52
+ - lib/zappb_exception/rack.rb
53
+ - lib/zappb_exception/railtie.rb
54
+ - lib/zappb_exception/registry.rb
55
+ - lib/zappb_exception/remote_error.rb
56
+ - lib/zappb_exception/resolver.rb
57
+ - lib/zappb_exception/rspec.rb
58
+ - lib/zappb_exception/testing.rb
59
+ - lib/zappb_exception/version.rb
60
+ homepage: https://rubygems.org/gems/zappb-exceptions
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ rubygems_mfa_required: 'true'
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.2'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 4.0.14
80
+ specification_version: 4
81
+ summary: 'Exception handling for messaging-based microservices: per-service error
82
+ catalog and a stable JSON contract.'
83
+ test_files: []