kinetic_cafe_error 1.1 → 1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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