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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +457 -0
- data/config/locales/en.yml +22 -0
- data/config/locales/es.yml +22 -0
- data/lib/zappb/exceptions.rb +3 -0
- data/lib/zappb_exception/base_catalog.rb +34 -0
- data/lib/zappb_exception/class_factory.rb +73 -0
- data/lib/zappb_exception/configuration.rb +86 -0
- data/lib/zappb_exception/definition.rb +101 -0
- data/lib/zappb_exception/describer.rb +33 -0
- data/lib/zappb_exception/detail_sanitizer.rb +18 -0
- data/lib/zappb_exception/envelope.rb +117 -0
- data/lib/zappb_exception/errors.rb +95 -0
- data/lib/zappb_exception/handler.rb +30 -0
- data/lib/zappb_exception/nats.rb +49 -0
- data/lib/zappb_exception/notifier.rb +75 -0
- data/lib/zappb_exception/rack.rb +15 -0
- data/lib/zappb_exception/railtie.rb +11 -0
- data/lib/zappb_exception/registry.rb +191 -0
- data/lib/zappb_exception/remote_error.rb +133 -0
- data/lib/zappb_exception/resolver.rb +17 -0
- data/lib/zappb_exception/rspec.rb +102 -0
- data/lib/zappb_exception/testing.rb +41 -0
- data/lib/zappb_exception/version.rb +5 -0
- data/lib/zappb_exception.rb +96 -0
- metadata +83 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZappbException
|
|
4
|
+
module ClassFactory
|
|
5
|
+
module Namespace; end
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def call(definition, aliases = [])
|
|
10
|
+
unless definition.external || definition.klass
|
|
11
|
+
keys = definition.payload if definition.declared_payload?
|
|
12
|
+
ensure_constant(definition.name, base_class(definition), keys)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
parent = parent_for(definition)
|
|
16
|
+
aliases.each do |alias_name|
|
|
17
|
+
next if alias_name.include?("::")
|
|
18
|
+
|
|
19
|
+
ensure_constant(alias_name, parent)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parent_for(definition)
|
|
24
|
+
return base_class(definition) if definition.external || definition.klass
|
|
25
|
+
|
|
26
|
+
ZappbException.const_get(definition.name, false)
|
|
27
|
+
rescue ::NameError
|
|
28
|
+
base_class(definition)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def base_class(definition)
|
|
32
|
+
definition.payload && !definition.external && !definition.klass ? PayloadError : BaseError
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ensure_constant(name, superclass, payload_keys = nil)
|
|
36
|
+
*path, leaf = name.split("::")
|
|
37
|
+
namespace = path.inject(ZappbException) { |parent, segment| namespace_for(parent, segment) }
|
|
38
|
+
|
|
39
|
+
if namespace.const_defined?(leaf, false)
|
|
40
|
+
existing = namespace.const_get(leaf, false)
|
|
41
|
+
return existing if existing.is_a?(::Class) && existing <= ::Exception
|
|
42
|
+
return promote(namespace, leaf, existing, superclass, payload_keys) if existing.is_a?(Namespace)
|
|
43
|
+
|
|
44
|
+
raise ConfigurationError, "ZappbException::#{name} already exists and is not an error class"
|
|
45
|
+
end
|
|
46
|
+
namespace.const_set(leaf, build(superclass, payload_keys))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def namespace_for(parent, segment)
|
|
50
|
+
unless parent.const_defined?(segment, false)
|
|
51
|
+
return parent.const_set(segment, ::Module.new.extend(Namespace))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
existing = parent.const_get(segment, false)
|
|
55
|
+
return existing if existing.is_a?(::Module)
|
|
56
|
+
|
|
57
|
+
raise ConfigurationError, "#{parent.name}::#{segment} already exists and is neither a module nor a class"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def promote(namespace, leaf, old, superclass, payload_keys)
|
|
61
|
+
klass = build(superclass, payload_keys)
|
|
62
|
+
old.constants(false).each { |const| klass.const_set(const, old.const_get(const, false)) }
|
|
63
|
+
namespace.send(:remove_const, leaf)
|
|
64
|
+
namespace.const_set(leaf, klass)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build(superclass, payload_keys)
|
|
68
|
+
klass = ::Class.new(superclass)
|
|
69
|
+
klass.payload_keys = payload_keys if payload_keys
|
|
70
|
+
klass
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module ZappbException
|
|
6
|
+
class Configuration
|
|
7
|
+
UNSET = Object.new.freeze
|
|
8
|
+
private_constant :UNSET
|
|
9
|
+
|
|
10
|
+
attr_reader :code_prefix, :safe_detail, :meta_key, :locale
|
|
11
|
+
attr_writer :logger
|
|
12
|
+
|
|
13
|
+
def initialize(registry)
|
|
14
|
+
@registry = registry
|
|
15
|
+
@code_prefix = ""
|
|
16
|
+
@service_label = UNSET
|
|
17
|
+
@meta_key = nil
|
|
18
|
+
@safe_detail = false
|
|
19
|
+
@locale = nil
|
|
20
|
+
@logger = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def code_prefix=(value)
|
|
24
|
+
@code_prefix = value.to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def meta_key=(value)
|
|
28
|
+
unless value.nil? || ((value.is_a?(::String) || value.is_a?(::Symbol)) && !value.to_s.empty?)
|
|
29
|
+
raise ConfigurationError, "meta_key must be nil, or a non-empty String or Symbol"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@meta_key = value&.to_sym
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def service_label
|
|
36
|
+
return @service_label unless @service_label.equal?(UNSET)
|
|
37
|
+
|
|
38
|
+
default_service_label
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def service_label=(value)
|
|
42
|
+
@service_label = value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def safe_detail=(value)
|
|
46
|
+
unless [true, false].include?(value) || value.respond_to?(:call)
|
|
47
|
+
raise ConfigurationError, "safe_detail must be true, false or a callable"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@safe_detail = value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def locale=(value)
|
|
54
|
+
unless value.nil? || ((value.is_a?(::String) || value.is_a?(::Symbol)) && !value.to_s.empty?)
|
|
55
|
+
raise ConfigurationError, "locale must be nil, or a non-empty String or Symbol"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@locale = value&.to_sym
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def logger
|
|
62
|
+
@logger || default_logger
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def register(name, **options)
|
|
66
|
+
@registry.register(name, **options)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def alias_external(class_name, to:)
|
|
70
|
+
@registry.alias_external(class_name, to: to)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def default_service_label
|
|
76
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
77
|
+
|
|
78
|
+
::Rails.application.class.module_parent_name
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def default_logger
|
|
82
|
+
rails_logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
83
|
+
rails_logger || (@fallback_logger ||= ::Logger.new($stdout))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZappbException
|
|
4
|
+
NAME_SEGMENT = /\A[A-Z]\w*\z/
|
|
5
|
+
PAYLOAD_KEY = /\A[a-z_]\w*\z/
|
|
6
|
+
RESERVED_PAYLOAD_KEYS = %i[meta exception detail].freeze
|
|
7
|
+
|
|
8
|
+
Definition = Data.define(:name, :status, :number, :message, :code, :external, :payload, :klass, :base, :fallback,
|
|
9
|
+
:safe_detail) do
|
|
10
|
+
def initialize(name:, status:, message:, number: nil, code: nil, external: false, payload: false,
|
|
11
|
+
klass: nil, base: false, fallback: false, safe_detail: nil)
|
|
12
|
+
name = name.to_s
|
|
13
|
+
raise ConfigurationError, "The error name cannot be empty" if name.empty?
|
|
14
|
+
if name.start_with?("ZappbException::")
|
|
15
|
+
raise ConfigurationError, "The name '#{name}' must not have the 'ZappbException::' prefix"
|
|
16
|
+
end
|
|
17
|
+
unless status.is_a?(::Integer) && (400..599).cover?(status)
|
|
18
|
+
raise ConfigurationError, "#{name}: status must be an integer between 400 and 599 (got #{status.inspect})"
|
|
19
|
+
end
|
|
20
|
+
unless message.is_a?(::String) && !message.empty?
|
|
21
|
+
raise ConfigurationError, "#{name}: message is required"
|
|
22
|
+
end
|
|
23
|
+
unless code.nil? || (code.is_a?(::String) && !code.empty?)
|
|
24
|
+
raise ConfigurationError, "#{name}: code must be a non-empty String"
|
|
25
|
+
end
|
|
26
|
+
if klass && !(klass.is_a?(::Class) || klass.respond_to?(:call))
|
|
27
|
+
raise ConfigurationError, "#{name}: klass must be a class or a callable returning one"
|
|
28
|
+
end
|
|
29
|
+
unless fallback || code
|
|
30
|
+
unless number.is_a?(::Integer) && number >= 1
|
|
31
|
+
raise ConfigurationError, "#{name}: number must be an integer >= 1 (got #{number.inspect})"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
unless number.nil? || (number.is_a?(::Integer) && number >= 0)
|
|
35
|
+
raise ConfigurationError, "#{name}: number must be an integer >= 1 (got #{number.inspect})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
external = external || (klass && name.include?("::")) ? true : false
|
|
39
|
+
if !external && !name.split("::", -1).all? { |segment| segment.match?(NAME_SEGMENT) }
|
|
40
|
+
raise ConfigurationError, "#{name}: invalid constant name"
|
|
41
|
+
end
|
|
42
|
+
unless safe_detail.nil? || [true, false].include?(safe_detail) || safe_detail.respond_to?(:call)
|
|
43
|
+
raise ConfigurationError, "#{name}: safe_detail must be true, false or a callable"
|
|
44
|
+
end
|
|
45
|
+
payload = self.class.normalize_payload(name, payload)
|
|
46
|
+
|
|
47
|
+
super(name: name.dup.freeze, status: status, number: number, message: message.dup.freeze,
|
|
48
|
+
code: code&.dup&.freeze, external: external, payload: payload,
|
|
49
|
+
klass: klass, base: base ? true : false, fallback: fallback ? true : false,
|
|
50
|
+
safe_detail: safe_detail)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.normalize_payload(name, payload)
|
|
54
|
+
return payload ? true : false unless payload.is_a?(::Array)
|
|
55
|
+
|
|
56
|
+
keys = payload.map do |key|
|
|
57
|
+
unless key.is_a?(::Symbol) || key.is_a?(::String)
|
|
58
|
+
raise ConfigurationError, "#{name}: payload keys must be Symbol or String (got #{key.inspect})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
key.to_sym
|
|
62
|
+
end.uniq
|
|
63
|
+
raise ConfigurationError, "#{name}: payload cannot be an empty Array" if keys.empty?
|
|
64
|
+
|
|
65
|
+
keys.each do |key|
|
|
66
|
+
unless key.match?(PAYLOAD_KEY)
|
|
67
|
+
raise ConfigurationError, "#{name}: invalid payload key (#{key.inspect})"
|
|
68
|
+
end
|
|
69
|
+
if RESERVED_PAYLOAD_KEYS.include?(key) || PayloadError.method_defined?(key)
|
|
70
|
+
raise ConfigurationError, "#{name}: payload key '#{key}' is reserved"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
keys.freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def declared_payload?
|
|
77
|
+
payload.is_a?(::Array)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def internal?
|
|
81
|
+
!external
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def error_code(prefix = "")
|
|
85
|
+
return code if code
|
|
86
|
+
|
|
87
|
+
"#{status}-#{prefix}#{format("%02d", number || 0)}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def resolve_klass
|
|
91
|
+
return nil unless klass
|
|
92
|
+
|
|
93
|
+
klass.respond_to?(:call) ? klass.call : klass
|
|
94
|
+
rescue ::NameError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
Definition::UNHANDLED = Definition.new(name: "Unhandled", status: 500, number: 0,
|
|
100
|
+
message: "Unhandled exception", fallback: true)
|
|
101
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZappbException
|
|
4
|
+
module Describer
|
|
5
|
+
Description = Data.define(:status, :code, :message) do
|
|
6
|
+
def level
|
|
7
|
+
Describer.level_for(status)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def level_for(status)
|
|
14
|
+
status.to_i >= 500 ? :error : :warn
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def level(exception, config: ZappbException.configuration)
|
|
18
|
+
call(exception, config: config).level
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(exception, config: ZappbException.configuration)
|
|
22
|
+
if exception.is_a?(ForwardedError) && exception.error_hash.is_a?(::Hash)
|
|
23
|
+
hash = exception.error_hash
|
|
24
|
+
return Description.new(status: Envelope.forwarded_status(hash), code: Envelope.forwarded_code(hash),
|
|
25
|
+
message: RemoteError.from(hash).message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
definition = Resolver.call(exception)
|
|
29
|
+
Description.new(status: definition.status, code: definition.error_code(config.code_prefix),
|
|
30
|
+
message: Envelope.message_for(definition, config: config))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZappbException
|
|
4
|
+
module DetailSanitizer
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(exception, setting)
|
|
8
|
+
case setting
|
|
9
|
+
when true
|
|
10
|
+
exception.is_a?(BaseError) ? exception.message : exception.class.name.to_s
|
|
11
|
+
when false, nil
|
|
12
|
+
exception.message
|
|
13
|
+
else
|
|
14
|
+
setting.call(exception).to_s
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZappbException
|
|
4
|
+
module Envelope
|
|
5
|
+
Result = Data.define(:status, :body, :definition)
|
|
6
|
+
|
|
7
|
+
I18N_SCOPE = "zappb_exception.errors"
|
|
8
|
+
RESERVED_ERROR_KEYS = %i[exception detail].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def call(exception, meta: {}, definition: nil, config: ZappbException.configuration)
|
|
13
|
+
signed = signed_meta(exception, meta, config)
|
|
14
|
+
|
|
15
|
+
if exception.is_a?(ForwardedError) && exception.error_hash.is_a?(::Hash)
|
|
16
|
+
hash = exception.error_hash
|
|
17
|
+
body = hash.symbolize_keys.except(:meta)
|
|
18
|
+
final = merge_meta(remote_meta(hash), signed)
|
|
19
|
+
return Result.new(status: forwarded_status(hash), body: with_meta(body, final), definition: nil)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
definition ||= Resolver.call(exception)
|
|
23
|
+
body = {
|
|
24
|
+
success: false,
|
|
25
|
+
status: definition.status,
|
|
26
|
+
code: definition.error_code(config.code_prefix),
|
|
27
|
+
message: message_for(definition, config: config),
|
|
28
|
+
error: error_for(exception, config, definition)
|
|
29
|
+
}
|
|
30
|
+
Result.new(status: definition.status, body: with_meta(body, signed), definition: definition)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def signed_meta(exception, meta, config)
|
|
34
|
+
own = exception.respond_to?(:meta) ? exception.meta : nil
|
|
35
|
+
local = deep_symbolize(hash_or_empty(own)).merge(deep_symbolize(hash_or_empty(meta)))
|
|
36
|
+
return {} if local.empty?
|
|
37
|
+
|
|
38
|
+
key = config.meta_key
|
|
39
|
+
key ? { key.to_sym => local } : local
|
|
40
|
+
rescue ::StandardError
|
|
41
|
+
{}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def remote_meta(hash)
|
|
45
|
+
value = hash.key?("meta") ? hash["meta"] : hash[:meta]
|
|
46
|
+
deep_symbolize(hash_or_empty(value))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def merge_meta(remote, local)
|
|
50
|
+
remote.merge(local) do |_key, remote_value, local_value|
|
|
51
|
+
if remote_value.is_a?(::Hash) && local_value.is_a?(::Hash)
|
|
52
|
+
merge_meta(remote_value, local_value)
|
|
53
|
+
else
|
|
54
|
+
local_value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def with_meta(body, meta)
|
|
60
|
+
meta.empty? ? body : body.merge(meta: meta)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def hash_or_empty(value)
|
|
64
|
+
value.is_a?(::Hash) ? value : {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def deep_symbolize(value)
|
|
68
|
+
case value
|
|
69
|
+
when ::Hash
|
|
70
|
+
value.each_with_object({}) do |(key, item), acc|
|
|
71
|
+
acc[key.respond_to?(:to_sym) ? key.to_sym : key] = deep_symbolize(item)
|
|
72
|
+
end
|
|
73
|
+
when ::Array then value.map { |item| deep_symbolize(item) }
|
|
74
|
+
else value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def error_for(exception, config, definition = nil)
|
|
79
|
+
{ exception: exception.class.name, detail: detail_for(exception, config, definition) }
|
|
80
|
+
.merge(extra_for(exception))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extra_for(exception)
|
|
84
|
+
extra = exception.respond_to?(:extra_data) ? exception.extra_data : nil
|
|
85
|
+
return {} unless extra.is_a?(::Hash) && !extra.empty?
|
|
86
|
+
|
|
87
|
+
extra.transform_keys(&:to_sym).except(*RESERVED_ERROR_KEYS)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def forwarded_status(hash)
|
|
91
|
+
status = Integer(hash["status"] || hash[:status] || 500, exception: false)
|
|
92
|
+
status && (100..599).cover?(status) ? status : 500
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def forwarded_code(hash)
|
|
96
|
+
hash["code"] || hash[:code]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def message_for(definition, config: ZappbException.configuration)
|
|
100
|
+
return definition.message unless defined?(::I18n)
|
|
101
|
+
|
|
102
|
+
options = { default: definition.message }
|
|
103
|
+
options[:locale] = config.locale if config.locale
|
|
104
|
+
::I18n.t("#{I18N_SCOPE}.#{definition.name}", **options)
|
|
105
|
+
rescue ::StandardError
|
|
106
|
+
definition.message
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def detail_for(exception, config, definition = nil)
|
|
110
|
+
setting = definition.nil? || definition.safe_detail.nil? ? config.safe_detail : definition.safe_detail
|
|
111
|
+
setting = true if definition&.fallback && definition.safe_detail.nil? && !setting
|
|
112
|
+
text = DetailSanitizer.call(exception, setting)
|
|
113
|
+
label = config.service_label
|
|
114
|
+
label.nil? || label.to_s.empty? ? text : "[#{label}] #{text}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZappbException
|
|
4
|
+
class ConfigurationError < ::StandardError; end
|
|
5
|
+
|
|
6
|
+
class FrozenRegistryError < ConfigurationError; end
|
|
7
|
+
|
|
8
|
+
class BaseError < ::StandardError
|
|
9
|
+
def extra_data
|
|
10
|
+
{}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def meta
|
|
14
|
+
@meta ||= {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(message = nil, meta: {})
|
|
18
|
+
@meta = meta.is_a?(::Hash) ? meta.dup : {}
|
|
19
|
+
super(message)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def with_meta(hash)
|
|
23
|
+
meta.merge!(hash) if hash.is_a?(::Hash)
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class PayloadError < BaseError
|
|
29
|
+
class << self
|
|
30
|
+
def payload_keys
|
|
31
|
+
return @payload_keys if defined?(@payload_keys)
|
|
32
|
+
|
|
33
|
+
superclass.respond_to?(:payload_keys) ? superclass.payload_keys : nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def payload_keys=(keys)
|
|
37
|
+
@payload_keys = keys.freeze
|
|
38
|
+
keys.each { |key| define_method(key) { payload[key] } }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attr_reader :payload
|
|
43
|
+
|
|
44
|
+
def initialize(message = nil, meta: {}, **payload)
|
|
45
|
+
keys = self.class.payload_keys
|
|
46
|
+
if keys
|
|
47
|
+
unknown = payload.keys - keys
|
|
48
|
+
unless unknown.empty?
|
|
49
|
+
raise ::ArgumentError, "Undeclared payload keys: #{unknown.join(", ")} (declared: #{keys.join(", ")})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
payload = keys.to_h { |key| [key, payload[key]] }
|
|
53
|
+
end
|
|
54
|
+
@payload = payload
|
|
55
|
+
super(message, meta: meta)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def extra_data
|
|
59
|
+
payload
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class ForwardedError < BaseError
|
|
64
|
+
attr_reader :error_hash
|
|
65
|
+
|
|
66
|
+
def initialize(error_hash = nil, meta: {}, **rest)
|
|
67
|
+
error_hash = rest if error_hash.nil? && !rest.empty?
|
|
68
|
+
@error_hash = error_hash
|
|
69
|
+
message = if error_hash.is_a?(::Hash)
|
|
70
|
+
error_hash["message"] || error_hash[:message] || error_hash.to_s
|
|
71
|
+
else
|
|
72
|
+
error_hash.to_s
|
|
73
|
+
end
|
|
74
|
+
super(message, meta: meta)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def remote
|
|
78
|
+
@remote ||= RemoteError.from(error_hash)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def status = remote.status
|
|
82
|
+
|
|
83
|
+
def code = remote.code
|
|
84
|
+
|
|
85
|
+
def kind?(*names) = remote.kind?(*names)
|
|
86
|
+
|
|
87
|
+
def success? = remote.success?
|
|
88
|
+
|
|
89
|
+
def extra = remote.extra
|
|
90
|
+
|
|
91
|
+
def detail(strip_label: false) = remote.detail(strip_label: strip_label)
|
|
92
|
+
|
|
93
|
+
def remote_meta = remote.meta
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZappbException
|
|
4
|
+
module Handler
|
|
5
|
+
HANDLER = proc { |exception| ZappbException::Handler.handle(self, exception) }
|
|
6
|
+
|
|
7
|
+
def self.included(base)
|
|
8
|
+
return unless base.respond_to?(:rescue_from)
|
|
9
|
+
|
|
10
|
+
base.rescue_from ::StandardError, with: HANDLER
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.handle(controller, exception, meta: {}, config: ZappbException.configuration)
|
|
14
|
+
result = Envelope.call(exception, meta: meta, config: config)
|
|
15
|
+
|
|
16
|
+
Notifier.notify(exception, result, config: config)
|
|
17
|
+
|
|
18
|
+
render(controller, result)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.render(controller, result)
|
|
22
|
+
return unless controller.respond_to?(:render)
|
|
23
|
+
|
|
24
|
+
controller.render status: result.status, json: result.body.compact
|
|
25
|
+
rescue ::StandardError => e
|
|
26
|
+
config = ZappbException.configuration
|
|
27
|
+
config.logger.error "ZappbException::Handler render failed: #{e.class}: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZappbException
|
|
4
|
+
module Nats
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def reply(meta: {}, notify: true, config: ZappbException.configuration)
|
|
8
|
+
yield
|
|
9
|
+
rescue ::StandardError => e
|
|
10
|
+
result = Envelope.call(e, meta: meta, config: config)
|
|
11
|
+
notify_safely(e, result, config) if notify
|
|
12
|
+
result.body.compact
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def error_response?(response)
|
|
16
|
+
return false unless response.is_a?(::Hash)
|
|
17
|
+
|
|
18
|
+
success = RemoteError.read(response, :success)
|
|
19
|
+
return true if success == false || success.to_s == "false"
|
|
20
|
+
|
|
21
|
+
present?(RemoteError.read(response, :error))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def raise_on_error!(response, on_nil: GatewayTimeout, meta: {})
|
|
25
|
+
if response.nil?
|
|
26
|
+
valid = on_nil.is_a?(::Exception) || (on_nil.is_a?(::Class) && on_nil <= ::Exception)
|
|
27
|
+
raise ::ArgumentError, "on_nil must be an Exception class or instance, got #{on_nil.inspect}" unless valid
|
|
28
|
+
|
|
29
|
+
raise on_nil.new(meta: meta) if on_nil.is_a?(::Class) && on_nil <= BaseError
|
|
30
|
+
raise on_nil.with_meta(meta) if on_nil.is_a?(BaseError)
|
|
31
|
+
|
|
32
|
+
raise on_nil
|
|
33
|
+
end
|
|
34
|
+
raise ForwardedError.new(response, meta: meta) if error_response?(response)
|
|
35
|
+
|
|
36
|
+
response
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def present?(value)
|
|
40
|
+
value.respond_to?(:empty?) ? !value.empty? : !!value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def notify_safely(exception, result, config)
|
|
44
|
+
Notifier.notify(exception, result, config: config)
|
|
45
|
+
rescue ::StandardError => e
|
|
46
|
+
config.logger.error "ZappbException::Nats notify failed: #{e.class}: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ZappbException
|
|
6
|
+
module Notifier
|
|
7
|
+
ERROR_EVENT = "zappb_exception.error"
|
|
8
|
+
FORWARDED_EVENT = "zappb_exception.forwarded_error"
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def error(exception, definition, meta: nil, config: ZappbException.configuration)
|
|
13
|
+
status = definition.status
|
|
14
|
+
server_error = status >= 500
|
|
15
|
+
emit(ERROR_EVENT, status, config, {
|
|
16
|
+
exception_class: exception.class.name,
|
|
17
|
+
http_status: status,
|
|
18
|
+
error_code: definition.error_code(config.code_prefix),
|
|
19
|
+
message: parse_message(exception.message),
|
|
20
|
+
backtrace: server_error ? exception.backtrace&.first(10) : exception.backtrace&.first(1),
|
|
21
|
+
meta: presence(meta),
|
|
22
|
+
extra: presence(Envelope.extra_for(exception))
|
|
23
|
+
}.compact)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def forwarded(exception, status:, error_code:, meta: nil, config: ZappbException.configuration)
|
|
27
|
+
emit(FORWARDED_EVENT, status, config, {
|
|
28
|
+
http_status: status,
|
|
29
|
+
error_code: error_code,
|
|
30
|
+
message: exception.message,
|
|
31
|
+
meta: presence(meta),
|
|
32
|
+
extra: presence(remote_extra(exception))
|
|
33
|
+
}.compact)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def notify(exception, result, config: ZappbException.configuration)
|
|
37
|
+
if result.definition
|
|
38
|
+
error(exception, result.definition, meta: result.body[:meta], config: config)
|
|
39
|
+
elsif exception.is_a?(ForwardedError) && exception.error_hash.is_a?(::Hash)
|
|
40
|
+
forwarded(exception, status: result.status,
|
|
41
|
+
error_code: Envelope.forwarded_code(exception.error_hash),
|
|
42
|
+
meta: result.body[:meta], config: config)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def presence(meta)
|
|
47
|
+
meta.is_a?(::Hash) && !meta.empty? ? meta : nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def remote_extra(exception)
|
|
51
|
+
hash = exception.respond_to?(:error_hash) ? exception.error_hash : nil
|
|
52
|
+
hash.is_a?(::Hash) ? RemoteError.from(hash).extra : {}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_message(message)
|
|
56
|
+
JSON.parse(message)
|
|
57
|
+
rescue ::StandardError
|
|
58
|
+
message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def event_reporter
|
|
62
|
+
::Rails.event if defined?(::Rails) && ::Rails.respond_to?(:event)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def emit(name, status, config, payload)
|
|
66
|
+
level = Describer.level_for(status)
|
|
67
|
+
reporter = event_reporter
|
|
68
|
+
if reporter
|
|
69
|
+
reporter.tagged(level: level) { reporter.notify(name, payload) }
|
|
70
|
+
else
|
|
71
|
+
config.logger.public_send(level, JSON.generate({ event: name }.merge(payload)))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ZappbException
|
|
6
|
+
module Rack
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def render(exception, meta: {}, notify: true, config: ZappbException.configuration)
|
|
10
|
+
result = Envelope.call(exception, meta: meta, config: config)
|
|
11
|
+
Notifier.notify(exception, result, config: config) if notify
|
|
12
|
+
[result.status, { "content-type" => "application/json" }, [JSON.generate(result.body.compact)]]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|