kinetic_cafe_error 1.1 → 1.2

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.
@@ -7,6 +7,9 @@ module Minitest #:nodoc:
7
7
  def assert_kc_error expected, actual, params = {}, msg = nil
8
8
  msg, params = params, {} if msg.nil? && params.kind_of?(String)
9
9
 
10
+ assert_kind_of KineticCafe::ErrorDSL, actual,
11
+ msg || "Expected #{actual} to be #{expected}, but it was not."
12
+
10
13
  assert_kind_of expected, actual,
11
14
  msg || "Expected #{actual} to be #{expected}, but it was not."
12
15
 
@@ -34,14 +37,36 @@ module Minitest #:nodoc:
34
37
  end
35
38
 
36
39
  # Assert that a reponse body (<tt>@response.body</tt>, useful from
37
- # ActionController::TestCase) is JSON for the expected error. This is a
40
+ # ActionController::TestCase) is HTML for the expected error.
38
41
  # convenience wrapper around #assert_kc_error_json.
42
+ def assert_response_kc_error_html expected, params = {}, msg = nil
43
+ msg, params = params, {} if msg.nil? && params.kind_of?(String)
44
+
45
+ msg ||= "Expected #{actual} to be HTML for #{expected}, but it was not."
46
+
47
+ assert_template 'kinetic_cafe_error/page', msg
48
+ assert_template 'kinetic_cafe_error/_table', msg
49
+
50
+ assert_match(/#{expected.i18n_key}/, @response.body, msg)
51
+ assert_response expected.new.status, msg
52
+ end
53
+
54
+ # Assert that a reponse body (<tt>@response.body</tt>, useful from
55
+ # ActionController::TestCase) is JSON for the expected error. This is a
56
+ # convenience wrapper around #assert_kc_error_json or
57
+ # #assert_kc_error_html, depending on whether or not the response is HTML
58
+ # or not.
39
59
  def assert_response_kc_error expected, params = {}, msg = nil
40
60
  if msg.nil? && params.kind_of?(String)
41
61
  msg, params = params, {}
42
62
  end
43
63
  msg ||= "Expected response to be #{expected}, but was not."
44
- assert_kc_error_json expected, @response.body, params, msg
64
+
65
+ if @request.format.html?
66
+ assert_response_kc_error_html expected, params, msg
67
+ else
68
+ assert_kc_error_json expected, @response.body, params, msg
69
+ end
45
70
  end
46
71
 
47
72
  Minitest::Test.send(:include, self)
@@ -66,22 +66,26 @@ module KineticCafe
66
66
  # define_error class: :object, status: :not_found
67
67
  #
68
68
  # will create an +ObjectNotFound+ error class.
69
- #
70
- # +header_only+:: Indicates that when this is caught, it should not be
71
- # returned with full details, but shoudl instead be treated
72
- # as a header-only API response.
69
+ # +header+:: Indicates that when this is caught, it should not be returned
70
+ # with full details, but should instead be treated as a
71
+ # header-only API response. Also available as +header_only+.
72
+ # +internal+:: Generates a response that indicates to clients that the
73
+ # error should not be shown to users.
74
+ # +i18n_params+:: An array of parameter names that are expected to be
75
+ # provided for translation. This helps document the
76
+ # expected translations.
73
77
  def define_error(options)
74
78
  fail ArgumentError, 'invalid options' unless options.kind_of?(Hash)
75
79
  fail ArgumentError, 'define what error?' if options.empty?
76
80
 
77
81
  options = options.dup
82
+ status = options[:status]
78
83
 
79
84
  klass = options.delete(:class)
80
85
  if klass
81
86
  if options.key?(:key)
82
87
  fail ArgumentError, ":key conflicts with class:#{klass}"
83
88
  end
84
- status = options[:status]
85
89
 
86
90
  key = if status.kind_of?(Symbol) or status.kind_of?(String)
87
91
  "#{klass}_#{KineticCafe::ErrorDSL.namify(status)}"
@@ -89,8 +93,7 @@ module KineticCafe
89
93
  "#{klass}_#{KineticCafe::ErrorDSL.namify(self.name)}"
90
94
  end
91
95
  else
92
- status = options[:status]
93
- key = options.fetch(:key) {
96
+ key = options.fetch(:key) {
94
97
  fail ArgumentError, 'one of :key or :class must be provided'
95
98
  }.to_s
96
99
  end
@@ -116,13 +119,19 @@ module KineticCafe
116
119
  error = Class.new(self)
117
120
  error.send :define_method, :name, -> { key }
118
121
  error.send :define_method, :i18n_key, -> { i18n_key }
122
+ error.send :define_singleton_method, :i18n_key, -> { i18n_key }
119
123
 
120
- if options[:header_only]
121
- error.send :define_method, :header_only?, -> { true }
124
+ if options[:header] || options[:header_only]
125
+ error.send :define_method, :header?, -> { true }
126
+ error.send :alias_method, :header_only?, :header?
122
127
  end
123
128
 
124
- if options[:internal]
125
- error.send :define_method, :internal?, -> { true }
129
+ error.send :define_method, :internal?, -> { true } if options[:internal]
130
+
131
+ i18n_params = options[:i18n_params]
132
+ if i18n_params || !error.respond_to?(:i18n_params)
133
+ i18n_params = Array(i18n_params).freeze
134
+ error.send :define_singleton_method, :i18n_params, -> { i18n_params }
126
135
  end
127
136
 
128
137
  status ||= defined?(Rack::Utils) && :bad_request || 400
@@ -134,10 +143,6 @@ module KineticCafe
134
143
  const_set(error_name, error)
135
144
  end
136
145
 
137
- ##
138
- #
139
-
140
-
141
146
  private
142
147
 
143
148
  ##
@@ -151,13 +156,28 @@ module KineticCafe
151
156
  fail "#{self} cannot extend #{base} (not a StandardError)"
152
157
  end
153
158
 
154
- if defined?(Rack::Utils)
159
+ rack_status = base.__rack_status if base.respond_to?(:__rack_status)
160
+
161
+ case rack_status
162
+ when Hash
163
+ rack_status = { methods: true, errors: true }.merge(rack_status)
164
+ when true, nil
165
+ rack_status = {}.freeze
166
+ when false
167
+ rack_status = { methods: false, errors: false }.freeze
168
+ end
169
+
170
+ if defined?(Rack::Utils) && rack_status
155
171
  Rack::Utils::SYMBOL_TO_STATUS_CODE.each do |name, value|
156
- base.singleton_class.send :define_method, name do |options = {}|
157
- define_error(options.merge(status: name))
172
+ if rack_status.fetch(:methods, true)
173
+ base.singleton_class.send :define_method, name do |options = {}|
174
+ define_error(options.merge(status: name))
175
+ end
158
176
  end
159
177
 
160
- base.send :define_error, status: name, key: name
178
+ if rack_status.fetch(:errors, true)
179
+ base.send :define_error, status: name, key: name
180
+ end
161
181
  end
162
182
  end
163
183
  end
@@ -2,5 +2,8 @@ module KineticCafe
2
2
  # If Rails is defined, KineticCafe::ErrorEngine will be loaded automatically,
3
3
  # providing access to the KineticCafe::ErrorHandler.
4
4
  class ErrorEngine < ::Rails::Engine
5
+ rake_tasks do
6
+ load "#{__dir__}/error_tasks.rake"
7
+ end
5
8
  end
6
9
  end
@@ -0,0 +1,192 @@
1
+ module KineticCafe # :nodoc:
2
+ # The core functionality provided by a KineticCafe::Error, extracted to a
3
+ # module to ensure that exceptions that are made hosts of error hierarchies
4
+ # have expected functionality.
5
+ module ErrorModule
6
+ # The HTTP status to be returned. If not provided in the constructor, uses
7
+ # #default_status.
8
+ attr_reader :status
9
+ # Extra data relevant to recipients of the exception, provided on
10
+ # construction.
11
+ attr_reader :extra
12
+ # The exception that caused this exception; provided on construction.
13
+ attr_reader :cause
14
+
15
+ # Create a new error with the given parameters.
16
+ #
17
+ # === Options
18
+ #
19
+ # +message+:: A message override. This may be provided either as the first
20
+ # parameter to the constructor or may be provided as an option.
21
+ # A value provided as the first parameter overrides any other
22
+ # value.
23
+ # +status+:: An override to the HTTP status code to be returned by this
24
+ # error by default.
25
+ # +i18n_params+:: The parameters to be sent to I18n.translate with the
26
+ # #i18n_key.
27
+ # +cause+:: The exception that caused this error. Used to wrap an earlier
28
+ # exception.
29
+ # +extra+:: Extra data to be returned in the API representation of this
30
+ # exception.
31
+ # +query+:: A hash of parameters added to +i18n_params+, typically from
32
+ # Rails controller +params+ or model +where+ query. This hash
33
+ # will be converted into a string value similar to
34
+ # ActiveSupport#to_query.
35
+ #
36
+ # Any unmatched options will be added to +i18n_params+. Because of this,
37
+ # the following constructors are identical:
38
+ #
39
+ # KineticCafe::Error.new(i18n_params: { x: 1 })
40
+ # KineticCafe::Error.new(x: 1)
41
+ #
42
+ # :call-seq:
43
+ # new(message, options = {})
44
+ # new(options)
45
+ def initialize(*args)
46
+ options = args.last.kind_of?(Hash) ? args.pop.dup : {}
47
+ @message = args.shift
48
+ @message = options.delete(:message) if @message.nil? || @message.empty?
49
+ options.delete(:message)
50
+
51
+ @message && @message.freeze
52
+
53
+ @status = options.delete(:status) || default_status
54
+ @i18n_params = options.delete(:i18n_params) || {}
55
+ @extra = options.delete(:extra)
56
+ @cause = options.delete(:cause)
57
+
58
+ @i18n_params.update(cause: cause.message) if cause
59
+
60
+ query = options.delete(:query)
61
+ @i18n_params.merge!(query: stringify(query)) if query
62
+ @i18n_params.merge!(options)
63
+ @i18n_params.freeze
64
+ end
65
+
66
+ # The message associated with this exception. If not provided, defaults to
67
+ # #i18n_message.
68
+ def message
69
+ @message || i18n_message
70
+ end
71
+
72
+ # The name of the error class.
73
+ def name
74
+ @name ||= KineticCafe::ErrorDSL.namify(self.class.name)
75
+ end
76
+
77
+ # The key used for I18n translation.
78
+ def i18n_key
79
+ @i18n_key ||= if self.class.respond_to? :i18n_key
80
+ self.class.i18n_key
81
+ else
82
+ [
83
+ i18n_key_base, (name)
84
+ ].join('.').freeze
85
+ end
86
+ end
87
+ alias_method :code, :i18n_key
88
+
89
+ # Indicates that this error should *not* have its details rendered to the
90
+ # user, but should use the +head+ method.
91
+ def header?
92
+ false
93
+ end
94
+ alias_method :header_only?, :header?
95
+
96
+ # Indicates that this error should be rendered to the client, but clients
97
+ # are advised *not* to display the message to the user.
98
+ def internal?
99
+ false
100
+ end
101
+
102
+ # The I18n translation of the message. If I18n.translate is defined,
103
+ # returns #i18n_key and the I18n parameters.
104
+ def i18n_message
105
+ @i18n_message ||= if defined?(I18n.translate)
106
+ I18n.translate(i18n_key, @i18n_params).freeze
107
+ else
108
+ [ i18n_key, @i18n_params ].freeze
109
+ end
110
+ end
111
+
112
+ # The details of this error as a hash. Values that are empty or nil are
113
+ # omitted.
114
+ def api_error(*)
115
+ {
116
+ message: @message,
117
+ status: status,
118
+ name: name,
119
+ internal: internal?,
120
+ i18n_message: i18n_message,
121
+ i18n_key: i18n_key,
122
+ i18n_params: @i18n_params,
123
+ cause: cause && cause.message,
124
+ extra: extra
125
+ }.delete_if { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
126
+ end
127
+ alias_method :as_json, :api_error
128
+
129
+ # An error result that can be passed as a response body.
130
+ def error_result
131
+ { error: api_error, message: message }
132
+ end
133
+
134
+ # A hash that can be passed to the Rails +render+ method with +status+ of
135
+ # #status and +layout+ false. The +json+ field is rendered as a hash of
136
+ # +error+ (calling #api_error) and +message+ (calling #message).
137
+ def json_result
138
+ { status: status, json: error_result, layout: false }
139
+ end
140
+ alias_method :render_json_for_rails, :json_result
141
+
142
+ # Nice debugging version of a KineticCafe::Error
143
+ def inspect
144
+ "#<#{self.class}: name=#{name} status=#{status} " \
145
+ "message=#{message.inspect} i18n_key=#{i18n_key} " \
146
+ "i18n_params=#{@i18n_params.inspect} extra=#{extra.inspect} " \
147
+ "cause=#{cause}>"
148
+ end
149
+
150
+ class << self
151
+ ##
152
+ # The base for I18n key resolution. Defaults to 'kcerrors'.
153
+ #
154
+ # :method: i18n_key_base
155
+
156
+ ##
157
+ # The names of the expected parameters for this error. Defaults to [].
158
+ #
159
+ # :method: i18n_params
160
+
161
+ ##
162
+ # The i18n_key for the parameter. Defaults to the combination of
163
+ # i18n_key_base and the namified version of the class (see
164
+ # KineticCafe::ErrorDSL.namify).
165
+ #
166
+ # :method: i18n_key
167
+
168
+ ##
169
+ def included(mod)
170
+ unless mod.respond_to?(:i18n_key_base)
171
+ mod.send :define_singleton_method, :i18n_key_base do
172
+ 'kcerrors'.freeze
173
+ end
174
+ end
175
+
176
+ unless mod.respond_to?(:i18n_params)
177
+ mod.send :define_singleton_method, :i18n_params do
178
+ [].freeze
179
+ end
180
+ end
181
+
182
+ unless mod.respond_to?(:i18n_key)
183
+ mod.send :define_singleton_method, :i18n_key do
184
+ @i18n_key ||= [
185
+ i18n_key_base, KineticCafe::ErrorDSL.namify(name)
186
+ ].join('.').freeze
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -12,6 +12,14 @@ module KineticCafe
12
12
  # +be_json_for+:: Verifies that the expected value is a JSON representation
13
13
  # of the actual value. If the actual value responds to
14
14
  # #body, the actual value is replaced with +actual.body+.
15
+ #
16
+ # +be_kc_error+:: Verifies that the expected value is a KineticCafe::Error.
17
+ #
18
+ # +be_kc_error_json+:: Verifies that the JSON value matches the output of
19
+ # KineticCafe::Error.
20
+ #
21
+ # +be_kc_error_html+:: Verifies that the rendered HTML matches the output of
22
+ # KineticCafe::Error.
15
23
  module ErrorRspec
16
24
  extend ::Rspec::Matchers::DSL
17
25
 
@@ -30,6 +38,7 @@ module KineticCafe
30
38
 
31
39
  matcher :be_kc_error do |expected, params = {}|
32
40
  match do |actual|
41
+ expect(actual).to be_kind_of(KineticCafe::ErrorModule)
33
42
  expect(actual).to be_kind_of(expected)
34
43
  expect(actual).to eq(expected.new(params))
35
44
  end
@@ -45,5 +54,13 @@ module KineticCafe
45
54
 
46
55
  diffable
47
56
  end
57
+
58
+ matcher :be_kc_error_html do |expected, params = {}|
59
+ match do |actual|
60
+ expect(actual).to render_template('kinetic_cafe_error/page')
61
+ expect(actual).to render_template('kinetic_cafe_error/_table')
62
+ expect(actual).to include(expected.i18n_key)
63
+ end
64
+ end
48
65
  end
49
66
  end
@@ -0,0 +1,59 @@
1
+ namespace :kcerror do
2
+ desc 'Show defined errors.'
3
+ task defined: 'kcerror:find' do
4
+ display = ->(root, prefix = '') {
5
+ puts "#{prefix}- #{root}"
6
+
7
+ if @descendants[root]
8
+ sorted = @descendants[root].sort_by(&:to_s)
9
+ sorted.each do |child|
10
+ s = (child == sorted.last) ? '`' : '|'
11
+ display.(child, "#{prefix.tr('|`', ' ')} #{s}")
12
+ end
13
+ end
14
+ }
15
+
16
+ @descendants[StandardError].sort_by(&:to_s).each { |d| display.(d) }
17
+ end
18
+
19
+ desc 'Generate a sample translation key file.'
20
+ task :translations, [ :output ] => 'kcerror:find' do |_, args|
21
+ translations = {}
22
+ traverse = ->(root) {
23
+ translation = (translations[root.i18n_key_base] ||= {})
24
+ name = KineticCafe::ErrorDSL.namify(root)
25
+
26
+ params = root.i18n_params.map { |param| "%{#{param}}" }.join(' ')
27
+
28
+ if params.empty?
29
+ translation[name] = %Q(Translation for #{name} with no params.)
30
+ else
31
+ translation[name] = %Q(Translation for #{name} with #{params}.)
32
+ end
33
+
34
+ if @descendants[root]
35
+ @descendants[root].sort_by(&:to_s).each { |child| traverse.(child) }
36
+ end
37
+ }
38
+
39
+ @descendants[StandardError].sort_by(&:to_s).each { |d| traverse.(d) }
40
+
41
+ require 'yaml'
42
+ translations = YAML.dump({ 'en' => translations })
43
+
44
+ if args.output
45
+ File.open(args.output, 'w') { |f| f.write translations }
46
+ else
47
+ puts translations
48
+ end
49
+ end
50
+
51
+ task :find do
52
+ @descendants = {}
53
+ ObjectSpace.each_object(Class) do |k|
54
+ next unless k.singleton_class < KineticCafe::ErrorDSL
55
+
56
+ (@descendants[k.superclass] ||= []) << k
57
+ end
58
+ end
59
+ end