white_noise 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,64 @@
1
+ # frozen_string_literal: true
2
+ module Noise
3
+ # Determines how to render exception
4
+ #
5
+ # @example
6
+ # class ApplicationExceptionRenderer < ExceptionRenderer
7
+ # def render(responder)
8
+ # if env[Rack::PATH_INFO] =~ %r{\A/partners}
9
+ # {
10
+ # id: error_id,
11
+ # error: message,
12
+ # code: code
13
+ # }
14
+ # else
15
+ # super
16
+ # end
17
+ # end
18
+ #
19
+ # def message
20
+ # if error.is_a?(PublicError)
21
+ # error.message
22
+ # else
23
+ # 'Internal Server Error'
24
+ # end
25
+ # end
26
+ #
27
+ # def code
28
+ # if error.is_a?(PublicError)
29
+ # error.message_id
30
+ # else
31
+ # :internal_server_error
32
+ # end
33
+ # end
34
+ # end
35
+ #
36
+ ExceptionRenderer = Struct.new(:env) do
37
+ # @param responder [ExceptionResponder]
38
+ # @return [String] error representation
39
+ def render(responder)
40
+ ActiveModel::SerializableResource.new(
41
+ Array(error),
42
+ each_serializer: error_serializer,
43
+ adapter: :json,
44
+ root: 'errors',
45
+ meta: { 'status' => responder.status_code },
46
+ scope: { http_status: responder.status_code, id: error_id },
47
+ ).as_json.to_json
48
+ end
49
+
50
+ def error_serializer
51
+ error.is_a?(PublicError) ? PublicErrorSerializer : ErrorSerializer
52
+ end
53
+
54
+ # @return [StandardError]
55
+ def error
56
+ env['action_dispatch.exception']
57
+ end
58
+
59
+ # @return [String] error identifier, UUID
60
+ def error_id
61
+ env['action_dispatch.request_id']
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ require 'rack/utils'
3
+ require 'uber/inheritable_attr'
4
+ require 'action_dispatch'
5
+ require 'action_dispatch/middleware/exception_wrapper'
6
+ require 'active_support/core_ext/object/json'
7
+ require 'active_model_serializers'
8
+
9
+ module Noise
10
+ # Constructs error response (status, body)
11
+ class ExceptionResponder
12
+ class << self
13
+ # @param error [StandardError]
14
+ # @param status [Integer, Symbol] HTTP status to use for response
15
+ # @api private
16
+ #
17
+ def register(error, status:)
18
+ ActionDispatch::ExceptionWrapper.rescue_responses[error.to_s] = status
19
+ end
20
+ end
21
+
22
+ # @param env [Hash] rack env
23
+ # @param exception_renderer [ExceptionRenderer]
24
+ def initialize(env, exception_renderer = Noise.config.exception_renderer.new(env))
25
+ @env = env
26
+ @exception_renderer = exception_renderer
27
+ end
28
+
29
+ attr_reader :env, :exception_renderer
30
+ protected :env
31
+
32
+ # @return [Hash] JSON-serializable body
33
+ def body
34
+ @body ||= exception_renderer.render(self)
35
+ end
36
+
37
+ # @return [Hash] headers
38
+ def headers
39
+ {
40
+ 'Content-Type' => "#{::Mime::JSON}; charset=#{ActionDispatch::Response.default_charset}",
41
+ 'Content-Length' => body.bytesize.to_s,
42
+ }
43
+ end
44
+
45
+ # @return [Integer] HTTP status code
46
+ def status_code
47
+ status_symbol = ActionDispatch::ExceptionWrapper.rescue_responses[error.class.name]
48
+ # calls `status_code` from Rack::Utils
49
+ Rack::Utils.status_code(status_symbol)
50
+ end
51
+
52
+ def error
53
+ env['action_dispatch.exception']
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'exception_responder'
3
+
4
+ module Noise
5
+ # Custom rails exception app to render all API level errors as JSON.
6
+ #
7
+ # Why it needed: in case we use default ActionController's `rescue_from`
8
+ # we will not be able to properly handle and render exceptions raised in middlewares (like Warden),
9
+ # so for processing of all possible exceptions we configure Rails' `config.exceptions_app`
10
+ # to use our own API-specific implementation.
11
+ #
12
+ class ExceptionsApp
13
+ # @param env [Hash] rack env
14
+ def call(env)
15
+ responder = build_responder(env)
16
+ [responder.status_code, responder.headers, [responder.body]]
17
+ end
18
+
19
+ private
20
+
21
+ def build_responder(env)
22
+ error = env['action_dispatch.exception']
23
+ responder_class = error.respond_to?(:responder_class) ? error.responder_class : ExceptionResponder
24
+ responder_class.new(env)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+ require 'active_support/core_ext/hash/transform_values'
3
+ require 'active_support/core_ext/module/attribute_accessors'
4
+ require 'active_support/core_ext/hash/except'
5
+ require 'active_support/hash_with_indifferent_access'
6
+ require 'active_support/concern'
7
+ require 'action_dispatch/http/request'
8
+
9
+ module Noise
10
+ # Provides detailed information about exception
11
+ # and request context.
12
+ #
13
+ class Notification
14
+ WARNING = :warning
15
+ INFO = :info
16
+ ERROR = :error
17
+ SEVERITIES = [WARNING, INFO, ERROR].freeze
18
+
19
+ cattr_accessor :severities, instance_writer: false
20
+ self.severities = Hash.new(ERROR)
21
+
22
+ cattr_accessor :extractors, instance_writer: false
23
+ self.extractors = HashWithIndifferentAccess.new
24
+
25
+ class << self
26
+ # @param error_class [Class<StandardError>, String]
27
+ # @param severity [Symbol] severity constant
28
+ def register(error_class, severity:)
29
+ if SEVERITIES.include?(severity)
30
+ severities[error_class.to_s] = severity
31
+ else
32
+ fail ArgumentError, "Wrong severity `#{severity}`, allowed: #{SEVERITIES}"
33
+ end
34
+ end
35
+
36
+ # Extract info from request and it to Bugsnag notification
37
+ # @param key [Symbol, String] name of the parameter
38
+ # @param extractor [#call]
39
+ # @return [void]
40
+ #
41
+ # class ApiClientExtractor
42
+ # def call(env)
43
+ # env.slice(:client_version, :client_id)
44
+ # end
45
+ # end
46
+ # Notification.extract(:api_client, ApiClientExtractor)
47
+ #
48
+ def extract(key, extractor)
49
+ extractors[key] = extractor
50
+ end
51
+ end
52
+
53
+ # @param error [StandardError]
54
+ # @param env [Hash] rack env
55
+ # @see http://www.rubydoc.info/github/rack/rack/master/file/SPEC
56
+ #
57
+ def initialize(error, env)
58
+ @error = error
59
+ @env = env
60
+ end
61
+
62
+ # @return [{String, Any}]
63
+ def to_hash
64
+ extractors.except('user').transform_values do |extractor|
65
+ extractor.new.call(@env)
66
+ end
67
+ end
68
+
69
+ # @return [String] Error severity
70
+ def severity
71
+ severities[@error.class.name].to_s
72
+ end
73
+
74
+ # @return [Hash] User info
75
+ def user_info
76
+ user =
77
+ if extractors.key?('user')
78
+ extractors['user'].new.call(@env)
79
+ else
80
+ {}
81
+ end
82
+ fail '`name` key is reserved to identify error itself' if user.key?('name')
83
+ user.merge('name' => request.uuid)
84
+ end
85
+
86
+ private
87
+
88
+ def request
89
+ @request ||= ActionDispatch::Request.new(@env)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+ require 'noise/notification'
3
+ require 'noise/exception_responder'
4
+ require 'active_support/core_ext/object/blank'
5
+ require 'active_support/core_ext/string/inflections'
6
+ require 'i18n'
7
+
8
+ #
9
+ module Noise
10
+ # Base class for all api level errors
11
+ #
12
+ class PublicError < StandardError
13
+ attr_reader :message_id
14
+ attr_reader :options
15
+
16
+ # @overload new(message_id, message)
17
+ # Instantiate error with given message_id and message
18
+ # @param message_id [Symbol]
19
+ # @param message_or_options [String]
20
+ # @overload new(message_id, options)
21
+ # Instantiate error with given message_id and options.
22
+ # Options would be passed to I18n key
23
+ # @param message_id [Symbol]
24
+ # @param message_or_options [Hash{Symbol => any}]
25
+ # @example
26
+ # Given the following I18n key exists:
27
+ # noise:
28
+ # public_error:
29
+ # unknown_fields: "Server does not know how to recognize these fields: %{fields}"
30
+ #
31
+ # To render error with this message:
32
+ # PublicError.new(:unknown_fields, fields: 'nickname, phone')
33
+ #
34
+ def initialize(message_id, message_or_options = nil)
35
+ @message_id = message_id.to_sym
36
+ case message_or_options
37
+ when Hash
38
+ @options = message_or_options
39
+ @message = nil
40
+ else
41
+ @options = {}
42
+ @message = message_or_options
43
+ end
44
+ end
45
+
46
+ # @return [String]
47
+ def message
48
+ @message.presence || I18n.t("noise.#{self.class.name.demodulize.underscore}.#{@message_id}", @options)
49
+ end
50
+
51
+ # @return [String]
52
+ def inspect
53
+ "#<#{self.class}: #{message}>"
54
+ end
55
+
56
+ # @return [ExceptionResponder]
57
+ # @api private
58
+ def responder_class
59
+ ExceptionResponder
60
+ end
61
+
62
+ class << self
63
+ # @param status [Symbol, Integer]
64
+ # @see http://apidock.com/rails/ActionController/Base/render#254-List-of-status-codes-and-their-symbols
65
+ # @param severity [Symbol, Integer]
66
+ # @see `Noise::Notification::SEVERITIES`
67
+ #
68
+ # @example
69
+ # GoneError.register_as(:gone, :info)
70
+ #
71
+ def register_as(status, severity:)
72
+ Noise::ExceptionResponder.register(name, status: status)
73
+ Noise::Notification.register(name, severity: severity)
74
+ end
75
+ end
76
+ end
77
+
78
+ # 400
79
+ BadRequestError = Class.new(PublicError)
80
+ BadRequestError.register_as :bad_request, severity: :info
81
+
82
+ # 401
83
+ UnauthorizedError = Class.new(PublicError)
84
+ UnauthorizedError.register_as :unauthorized, severity: :warning
85
+
86
+ # 403
87
+ ForbiddenError = Class.new(PublicError)
88
+ ForbiddenError.register_as :forbidden, severity: :warning
89
+
90
+ # 404
91
+ class NotFoundError < PublicError
92
+ def initialize(message_id = 'not_found', message = nil)
93
+ super
94
+ end
95
+ end
96
+ NotFoundError.register_as :not_found, severity: :info
97
+
98
+ # 410
99
+ GoneError = Class.new(PublicError)
100
+ GoneError.register_as :gone, severity: :info
101
+
102
+ # 415
103
+ class UnsupportedMediaTypeError < PublicError
104
+ def initialize(message_id = 'unsupported_media_type', message = nil)
105
+ super
106
+ end
107
+ end
108
+ UnsupportedMediaTypeError.register_as :unsupported_media_type, severity: :info
109
+
110
+ # 422
111
+ class UnprocessableEntityError < PublicError
112
+ def initialize(message_id = :unprocessable_entity, message = nil)
113
+ super
114
+ end
115
+ end
116
+ UnprocessableEntityError.register_as :unprocessable_entity, severity: :info
117
+
118
+ # 503
119
+ ServiceUnavailableError = Class.new(PublicError)
120
+ ServiceUnavailableError.register_as :service_unavailable, severity: :warning
121
+
122
+ # 504
123
+ GatewayTimeoutError = Class.new(PublicError)
124
+ GatewayTimeoutError.register_as :gateway_timeout, severity: :warning
125
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noise
4
+ # Errors api representation.
5
+ # Serializes api level errors to general errors format.
6
+ #
7
+ class PublicErrorSerializer < ErrorSerializer
8
+ def code
9
+ object.message_id
10
+ end
11
+
12
+ def title
13
+ object.message
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ require 'rails/railtie'
3
+ require 'noise'
4
+
5
+ module Noise
6
+ # Rails initializers
7
+ class Railtie < Rails::Railtie
8
+ initializer 'noise.exceptions_app' do
9
+ require 'noise/exceptions_app'
10
+
11
+ Rails.application.config.exceptions_app = Noise::ExceptionsApp.new
12
+ end
13
+
14
+ initializer 'noise.bugsnag' do
15
+ if Noise.config.bugsnag_enabled
16
+ require 'noise/bugsnag_middleware'
17
+
18
+ Bugsnag.configure do |config|
19
+ config.middleware.use Noise::BugsnagMiddleware
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ require 'noise/public_error'
3
+ require 'noise/rate_limit_error_responder'
4
+
5
+ module Noise
6
+ # Rate limit error.
7
+ #
8
+ class RateLimitError < PublicError
9
+ attr_reader :retry_after
10
+
11
+ # @param message_id [Symbol]
12
+ # @param [String] retry_after
13
+ #
14
+ def initialize(message_id, retry_after:)
15
+ super(message_id)
16
+
17
+ @retry_after = retry_after
18
+ end
19
+
20
+ def responder_class
21
+ RateLimitErrorResponder
22
+ end
23
+ end
24
+ end
25
+
26
+ Noise::RateLimitError.register_as :too_many_requests, severity: :info
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ require 'noise/exception_responder'
3
+
4
+ module Noise
5
+ # Special error responder with Retry-After header render support.
6
+ #
7
+ class RateLimitErrorResponder < ExceptionResponder
8
+ # @return [Hash]
9
+ def headers
10
+ super.merge(
11
+ 'Retry-After' => error.retry_after.to_s,
12
+ )
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ module Noise
4
+ VERSION = '1.0.0'
5
+ end
data/lib/noise.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ require 'noise/version'
3
+ require 'noise/public_error'
4
+ require 'noise/rate_limit_error'
5
+ require 'active_support/configurable'
6
+
7
+ #
8
+ module Noise
9
+ include ActiveSupport::Configurable
10
+ extend ActiveSupport::Autoload
11
+
12
+ autoload :ErrorSerializer
13
+ autoload :ExceptionRenderer
14
+ autoload :PublicErrorSerializer
15
+
16
+ config.bugsnag_enabled = true
17
+ config.bugsnag_organization = nil
18
+ config.bugsnag_project = nil
19
+ config.exception_renderer = ExceptionRenderer
20
+ end
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'noise'
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'noise/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'white_noise'
9
+ spec.version = Noise::VERSION
10
+ spec.authors = ['Tema Bolshakov']
11
+ spec.email = ['abolshakov@spbtv.com']
12
+ spec.license = 'Apache-2.0'
13
+ spec.summary = 'Defines middleware which renders exceptions and notifies Bugsnag.'
14
+ spec.homepage = 'https://github.com/SPBTV/white_noise'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ spec.bindir = 'exe'
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_runtime_dependency 'i18n', '~> 0.7.0'
22
+ spec.add_runtime_dependency 'activesupport', '~> 4.2'
23
+ spec.add_runtime_dependency 'actionpack', '~> 4.2'
24
+ spec.add_runtime_dependency 'active_model_serializers', '0.10.0.rc4'
25
+ spec.add_runtime_dependency 'uber', '~> 0.0.15'
26
+ spec.add_development_dependency 'bundler', '~> 1.10'
27
+ spec.add_development_dependency 'rake', '~> 10.0'
28
+ spec.add_development_dependency 'rspec', '~> 3.4'
29
+ spec.add_development_dependency 'spbtv_code_style', '1.4.1'
30
+ spec.add_development_dependency 'bugsnag', '~> 6.6.3'
31
+ spec.add_development_dependency 'addressable', '~> 2.3'
32
+ spec.add_development_dependency 'rspec_junit_formatter', '~> 0.2.3'
33
+ end