haveapi 0.28.3 → 0.29.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 +4 -4
- data/README.md +139 -0
- data/Rakefile +1 -0
- data/haveapi.gemspec +2 -1
- data/lib/haveapi/action.rb +26 -9
- data/lib/haveapi/actions/default.rb +5 -2
- data/lib/haveapi/actions/paginable.rb +8 -4
- data/lib/haveapi/authentication/oauth2/provider.rb +1 -1
- data/lib/haveapi/authentication/token/config.rb +10 -3
- data/lib/haveapi/authentication/token/provider.rb +22 -21
- data/lib/haveapi/context.rb +4 -1
- data/lib/haveapi/extensions/exception_mailer.rb +136 -17
- data/lib/haveapi/i18n.rb +125 -0
- data/lib/haveapi/locales/cs.yml +167 -0
- data/lib/haveapi/locales/en.yml +168 -0
- data/lib/haveapi/metadata.rb +25 -3
- data/lib/haveapi/model_adapters/active_record.rb +40 -26
- data/lib/haveapi/output_formatter.rb +2 -2
- data/lib/haveapi/parameters/metadata_i18n.rb +179 -0
- data/lib/haveapi/parameters/resource.rb +18 -7
- data/lib/haveapi/parameters/typed.rb +27 -20
- data/lib/haveapi/params.rb +76 -7
- data/lib/haveapi/resource.rb +1 -1
- data/lib/haveapi/resources/action_state.rb +47 -27
- data/lib/haveapi/server.rb +287 -94
- data/lib/haveapi/spec/api_builder.rb +25 -0
- data/lib/haveapi/spec/spec_methods.rb +10 -0
- data/lib/haveapi/tasks/i18n.rb +198 -0
- data/lib/haveapi/validator_chain.rb +1 -1
- data/lib/haveapi/validators/acceptance.rb +5 -2
- data/lib/haveapi/validators/confirmation.rb +5 -2
- data/lib/haveapi/validators/exclusion.rb +2 -2
- data/lib/haveapi/validators/format.rb +5 -2
- data/lib/haveapi/validators/inclusion.rb +2 -2
- data/lib/haveapi/validators/length.rb +5 -5
- data/lib/haveapi/validators/numericality.rb +20 -22
- data/lib/haveapi/validators/presence.rb +4 -2
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi.rb +1 -0
- data/spec/authentication/oauth2_spec.rb +10 -0
- data/spec/extensions/exception_mailer_spec.rb +195 -0
- data/spec/i18n_spec.rb +520 -0
- data/spec/params_spec.rb +183 -0
- data/spec/server/integration_spec.rb +34 -0
- metadata +30 -7
- data/doc/create-client.md +0 -107
- data/doc/json-schema.html +0 -1182
- data/doc/protocol.md +0 -535
- data/doc/protocol.png +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
|
|
3
|
+
module HaveAPI
|
|
4
|
+
module Tasks
|
|
5
|
+
class I18nHealth
|
|
6
|
+
KEY_LITERAL_PATTERN = /['"](haveapi\.[a-z0-9_.]+)['"]/
|
|
7
|
+
|
|
8
|
+
RAW_MESSAGE_PATTERNS = [
|
|
9
|
+
/report_error\([^#\n]*,\s*['"]/,
|
|
10
|
+
/error!\(\s*['"]/,
|
|
11
|
+
/HaveAPI::ValidationError(?:\.new)?\(\s*['"]/,
|
|
12
|
+
/raise\s+HaveAPI::ValidationError,\s*['"]/,
|
|
13
|
+
/ret\[:message\]\s*=\s*['"]/,
|
|
14
|
+
/\|\|\s*['"][^'"]+(?:failed|error|denied|not found|invalid|requires|unsupported|missing)/,
|
|
15
|
+
/@message\s*=\s*take\(\s*:message\s*,\s*['"]/
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
PARAMETER_METHODS = %w[
|
|
19
|
+
bool custom datetime float id integer password resource string text
|
|
20
|
+
].freeze
|
|
21
|
+
PARAMETER_CALL_PATTERN = /\A\s*(?:#{PARAMETER_METHODS.join('|')})\b/
|
|
22
|
+
RAW_PARAMETER_METADATA_PATTERN = /\b(?:label|desc):\s*['"]/
|
|
23
|
+
|
|
24
|
+
def initialize(root:)
|
|
25
|
+
@root = root
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check!
|
|
29
|
+
errors = []
|
|
30
|
+
errors.concat(missing_key_errors)
|
|
31
|
+
errors.concat(unused_key_errors)
|
|
32
|
+
errors.concat(raw_message_errors)
|
|
33
|
+
|
|
34
|
+
return true if errors.empty?
|
|
35
|
+
|
|
36
|
+
raise "i18n health check failed:\n#{errors.join("\n")}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def normalize!
|
|
40
|
+
locale_data.each do |locale, data|
|
|
41
|
+
file = File.join(locale_dir, "#{locale}.yml")
|
|
42
|
+
File.write(file, YAML.dump(locale.to_s => deep_sort(data)))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def missing_key_errors
|
|
49
|
+
reference_locale = :en
|
|
50
|
+
reference_keys = locale_keys.fetch(reference_locale)
|
|
51
|
+
|
|
52
|
+
locale_keys.flat_map do |locale, keys|
|
|
53
|
+
next [] if locale == reference_locale
|
|
54
|
+
|
|
55
|
+
(reference_keys - keys).sort.map do |key|
|
|
56
|
+
"#{locale}: missing #{key}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def unused_key_errors
|
|
62
|
+
keys = locale_keys.fetch(:en)
|
|
63
|
+
used = used_keys
|
|
64
|
+
|
|
65
|
+
(keys - used).sort.map { |key| "unused translation key #{key}" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def raw_message_errors
|
|
69
|
+
source_files.flat_map do |file|
|
|
70
|
+
rel = relative_path(file)
|
|
71
|
+
|
|
72
|
+
File.readlines(file, chomp: true).each_with_index.filter_map do |line, index|
|
|
73
|
+
stripped = line.strip
|
|
74
|
+
next if stripped.empty? || stripped.start_with?('#')
|
|
75
|
+
|
|
76
|
+
if RAW_MESSAGE_PATTERNS.any? { |pattern| pattern.match?(line) }
|
|
77
|
+
"#{rel}:#{index + 1}: raw user-facing framework message"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end + raw_parameter_metadata_errors
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def raw_parameter_metadata_errors
|
|
84
|
+
source_files.flat_map do |file|
|
|
85
|
+
rel = relative_path(file)
|
|
86
|
+
inside_parameter = false
|
|
87
|
+
remaining_lines = 0
|
|
88
|
+
|
|
89
|
+
File.readlines(file, chomp: true).each_with_index.filter_map do |line, index|
|
|
90
|
+
stripped = line.strip
|
|
91
|
+
next if stripped.empty? || stripped.start_with?('#')
|
|
92
|
+
|
|
93
|
+
if PARAMETER_CALL_PATTERN.match?(stripped)
|
|
94
|
+
inside_parameter = true
|
|
95
|
+
remaining_lines = 12
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if inside_parameter && RAW_PARAMETER_METADATA_PATTERN.match?(line)
|
|
99
|
+
"#{rel}:#{index + 1}: raw action parameter label/description"
|
|
100
|
+
end
|
|
101
|
+
ensure
|
|
102
|
+
if inside_parameter
|
|
103
|
+
remaining_lines -= 1
|
|
104
|
+
inside_parameter = false if remaining_lines <= 0 || parameter_call_end?(stripped)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parameter_call_end?(line)
|
|
111
|
+
return false if line.nil?
|
|
112
|
+
|
|
113
|
+
stripped = line.strip
|
|
114
|
+
return false if stripped.empty?
|
|
115
|
+
return false if stripped.end_with?(',', '\\')
|
|
116
|
+
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def used_keys
|
|
121
|
+
@used_keys ||= source_files.each_with_object(Set.new) do |file, ret|
|
|
122
|
+
File.read(file).scan(KEY_LITERAL_PATTERN) do |match|
|
|
123
|
+
ret << match.first
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def locale_keys
|
|
129
|
+
@locale_keys ||= locale_data.transform_values do |data|
|
|
130
|
+
flatten_keys(data)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def locale_data
|
|
135
|
+
@locale_data ||= Dir[File.join(locale_dir, '*.yml')].to_h do |file|
|
|
136
|
+
locale = File.basename(file, '.yml').to_sym
|
|
137
|
+
data = YAML.safe_load_file(file, aliases: true) || {}
|
|
138
|
+
|
|
139
|
+
[locale, data.fetch(locale.to_s)]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def flatten_keys(hash, prefix = nil)
|
|
144
|
+
hash.each_with_object(Set.new) do |(key, value), ret|
|
|
145
|
+
name = [prefix, key].compact.join('.')
|
|
146
|
+
|
|
147
|
+
if value.is_a?(Hash)
|
|
148
|
+
ret.merge(flatten_keys(value, name))
|
|
149
|
+
else
|
|
150
|
+
ret << name
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def deep_sort(value)
|
|
156
|
+
case value
|
|
157
|
+
when Hash
|
|
158
|
+
value.keys.sort.to_h do |key|
|
|
159
|
+
[key, deep_sort(value[key])]
|
|
160
|
+
end
|
|
161
|
+
else
|
|
162
|
+
value
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def source_files
|
|
167
|
+
@source_files ||= Dir[File.join(@root, 'lib/haveapi/**/*.rb')].reject do |file|
|
|
168
|
+
rel = relative_path(file)
|
|
169
|
+
rel.start_with?('lib/haveapi/public/') ||
|
|
170
|
+
rel.start_with?('lib/haveapi/views/') ||
|
|
171
|
+
rel.start_with?('lib/haveapi/client_examples/') ||
|
|
172
|
+
rel.start_with?('lib/haveapi/spec/') ||
|
|
173
|
+
rel.start_with?('lib/haveapi/tasks/i18n.rb')
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def relative_path(file)
|
|
178
|
+
file.delete_prefix("#{@root}/")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def locale_dir
|
|
182
|
+
File.join(@root, 'lib/haveapi/locales')
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
namespace :i18n do
|
|
189
|
+
desc 'Check HaveAPI translation coverage and raw framework messages'
|
|
190
|
+
task :health do
|
|
191
|
+
HaveAPI::Tasks::I18nHealth.new(root: File.expand_path('../../..', __dir__)).check!
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
desc 'Normalize HaveAPI locale files'
|
|
195
|
+
task :normalize do
|
|
196
|
+
HaveAPI::Tasks::I18nHealth.new(root: File.expand_path('../../..', __dir__)).normalize!
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -23,13 +23,16 @@ module HaveAPI
|
|
|
23
23
|
take(:value)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
@message = take(
|
|
26
|
+
@message = take(
|
|
27
|
+
:message,
|
|
28
|
+
HaveAPI.message('haveapi.validators.acceptance.accepted', value: @value)
|
|
29
|
+
)
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
def describe
|
|
30
33
|
{
|
|
31
34
|
value: @value,
|
|
32
|
-
message: @message
|
|
35
|
+
message: HaveAPI.localize(@message)
|
|
33
36
|
}
|
|
34
37
|
end
|
|
35
38
|
|
|
@@ -24,7 +24,10 @@ module HaveAPI
|
|
|
24
24
|
@equal = take(:equal, true)
|
|
25
25
|
@message = take(
|
|
26
26
|
:message,
|
|
27
|
-
|
|
27
|
+
HaveAPI.message(
|
|
28
|
+
@equal ? 'haveapi.validators.confirmation.same' : 'haveapi.validators.confirmation.different',
|
|
29
|
+
parameter: @param
|
|
30
|
+
)
|
|
28
31
|
)
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -32,7 +35,7 @@ module HaveAPI
|
|
|
32
35
|
{
|
|
33
36
|
equal: @equal ? true : false,
|
|
34
37
|
parameter: @param,
|
|
35
|
-
message: @message
|
|
38
|
+
message: HaveAPI.localize(@message)
|
|
36
39
|
}
|
|
37
40
|
end
|
|
38
41
|
|
|
@@ -23,13 +23,13 @@ module HaveAPI
|
|
|
23
23
|
v.is_a?(::Symbol) ? v.to_s : v
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
@message = take(:message, '
|
|
26
|
+
@message = take(:message, HaveAPI.message('haveapi.validators.exclusion.excluded'))
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def describe
|
|
30
30
|
{
|
|
31
31
|
values: @values,
|
|
32
|
-
message: @message
|
|
32
|
+
message: HaveAPI.localize(@message)
|
|
33
33
|
}
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -20,7 +20,10 @@ module HaveAPI
|
|
|
20
20
|
@rx = simple? ? take : take(:rx)
|
|
21
21
|
@match = take(:match, true)
|
|
22
22
|
@desc = take(:desc)
|
|
23
|
-
@message = take(
|
|
23
|
+
@message = take(
|
|
24
|
+
:message,
|
|
25
|
+
@desc || HaveAPI.message('haveapi.validators.format.invalid')
|
|
26
|
+
)
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def describe
|
|
@@ -28,7 +31,7 @@ module HaveAPI
|
|
|
28
31
|
rx: @rx.source,
|
|
29
32
|
match: @match,
|
|
30
33
|
description: @desc,
|
|
31
|
-
message: @message
|
|
34
|
+
message: HaveAPI.localize(@message)
|
|
32
35
|
}
|
|
33
36
|
end
|
|
34
37
|
|
|
@@ -31,13 +31,13 @@ module HaveAPI
|
|
|
31
31
|
@values = values.map { |v| v.is_a?(::Symbol) ? v.to_s : v }
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
@message = take(:message, '
|
|
34
|
+
@message = take(:message, HaveAPI.message('haveapi.validators.inclusion.included'))
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def describe
|
|
38
38
|
{
|
|
39
39
|
values: @values,
|
|
40
|
-
message: @message
|
|
40
|
+
message: HaveAPI.localize(@message)
|
|
41
41
|
}
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -30,16 +30,16 @@ module HaveAPI
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
msg = if @equals
|
|
33
|
-
|
|
33
|
+
HaveAPI.message('haveapi.validators.length.equals', equals: @equals)
|
|
34
34
|
|
|
35
35
|
elsif @min && !@max
|
|
36
|
-
|
|
36
|
+
HaveAPI.message('haveapi.validators.length.min', min: @min)
|
|
37
37
|
|
|
38
38
|
elsif !@min && @max
|
|
39
|
-
|
|
39
|
+
HaveAPI.message('haveapi.validators.length.max', max: @max)
|
|
40
40
|
|
|
41
41
|
else
|
|
42
|
-
|
|
42
|
+
HaveAPI.message('haveapi.validators.length.range', min: @min, max: @max)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
@message = take(:message, msg)
|
|
@@ -47,7 +47,7 @@ module HaveAPI
|
|
|
47
47
|
|
|
48
48
|
def describe
|
|
49
49
|
ret = {
|
|
50
|
-
message: @message
|
|
50
|
+
message: HaveAPI.localize(@message)
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
if @equals
|
|
@@ -32,49 +32,47 @@ module HaveAPI
|
|
|
32
32
|
@even = take(:even)
|
|
33
33
|
@odd = take(:odd)
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
end
|
|
35
|
+
requirements = []
|
|
36
|
+
|
|
37
|
+
requirements << if @min && !@max
|
|
38
|
+
HaveAPI.message('haveapi.validators.numericality.min', min: @min)
|
|
39
|
+
elsif !@min && @max
|
|
40
|
+
HaveAPI.message('haveapi.validators.numericality.max', max: @max)
|
|
41
|
+
elsif @min && @max
|
|
42
|
+
HaveAPI.message('haveapi.validators.numericality.range', min: @min, max: @max)
|
|
43
|
+
else
|
|
44
|
+
HaveAPI.message('haveapi.validators.numericality.number')
|
|
45
|
+
end
|
|
47
46
|
|
|
48
47
|
if @step
|
|
49
|
-
|
|
50
|
-
msg += "in steps of #{@step}"
|
|
48
|
+
requirements << HaveAPI.message('haveapi.validators.numericality.step', step: @step)
|
|
51
49
|
end
|
|
52
50
|
|
|
53
51
|
if @mod
|
|
54
|
-
|
|
55
|
-
msg += "mod #{@step} must equal zero"
|
|
52
|
+
requirements << HaveAPI.message('haveapi.validators.numericality.mod', mod: @mod)
|
|
56
53
|
end
|
|
57
54
|
|
|
58
55
|
if @odd
|
|
59
|
-
|
|
60
|
-
msg += 'odd'
|
|
56
|
+
requirements << HaveAPI.message('haveapi.validators.numericality.odd')
|
|
61
57
|
end
|
|
62
58
|
|
|
63
59
|
if @even
|
|
64
|
-
|
|
65
|
-
msg += 'even'
|
|
60
|
+
requirements << HaveAPI.message('haveapi.validators.numericality.even')
|
|
66
61
|
end
|
|
67
62
|
|
|
68
63
|
if @odd && @even
|
|
69
64
|
raise 'cannot be both odd and even at the same time'
|
|
70
65
|
end
|
|
71
66
|
|
|
72
|
-
@message = take(
|
|
67
|
+
@message = take(
|
|
68
|
+
:message,
|
|
69
|
+
HaveAPI.message('haveapi.validators.numericality.composite', requirements:)
|
|
70
|
+
)
|
|
73
71
|
end
|
|
74
72
|
|
|
75
73
|
def describe
|
|
76
74
|
ret = {
|
|
77
|
-
message: @message
|
|
75
|
+
message: HaveAPI.localize(@message)
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
ret[:min] = @min if @min
|
|
@@ -21,14 +21,16 @@ module HaveAPI
|
|
|
21
21
|
@empty = take(:empty, false)
|
|
22
22
|
@message = take(
|
|
23
23
|
:message,
|
|
24
|
-
|
|
24
|
+
HaveAPI.message(
|
|
25
|
+
@empty ? 'haveapi.validators.presence.present' : 'haveapi.validators.presence.non_empty'
|
|
26
|
+
)
|
|
25
27
|
)
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
def describe
|
|
29
31
|
{
|
|
30
32
|
empty: @empty,
|
|
31
|
-
message: @message
|
|
33
|
+
message: HaveAPI.localize(@message)
|
|
32
34
|
}
|
|
33
35
|
end
|
|
34
36
|
|
data/lib/haveapi/version.rb
CHANGED
data/lib/haveapi.rb
CHANGED
|
@@ -218,6 +218,16 @@ describe HaveAPI::Authentication::OAuth2 do
|
|
|
218
218
|
expect(api_response.message).to match(/too many|multiple/i)
|
|
219
219
|
end
|
|
220
220
|
|
|
221
|
+
it 'localizes multiple token conflicts' do
|
|
222
|
+
header 'Accept-Language', 'cs'
|
|
223
|
+
header 'Authorization', 'Bearer abc'
|
|
224
|
+
call_api(:post, '/v1/secures/ping?access_token=abc', {})
|
|
225
|
+
|
|
226
|
+
expect(last_response.status).to eq(400)
|
|
227
|
+
expect(api_response).to be_failed
|
|
228
|
+
expect(api_response.message).to eq('Bylo poskytnuto více OAuth2 tokenů')
|
|
229
|
+
end
|
|
230
|
+
|
|
221
231
|
it 'ignores structured access_token query values before backend lookup' do
|
|
222
232
|
expect do
|
|
223
233
|
call_api(:post, '/v1/secures/ping?access_token[]=abc', {})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
6
|
+
module ExceptionMailerSpec
|
|
7
|
+
class RequestError < StandardError; end
|
|
8
|
+
class ActionError < StandardError; end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
describe HaveAPI::Extensions::ExceptionMailer do
|
|
12
|
+
api do
|
|
13
|
+
define_resource(:Test) do
|
|
14
|
+
version 1
|
|
15
|
+
auth false
|
|
16
|
+
|
|
17
|
+
define_action(:RequestError) do
|
|
18
|
+
route 'request_error'
|
|
19
|
+
http_method :get
|
|
20
|
+
|
|
21
|
+
authorize do
|
|
22
|
+
raise ExceptionMailerSpec::RequestError, 'request boom'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def exec
|
|
26
|
+
ok!
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
define_action(:InputRequestError) do
|
|
31
|
+
route 'input_request_error'
|
|
32
|
+
http_method :post
|
|
33
|
+
|
|
34
|
+
authorize do
|
|
35
|
+
raise ExceptionMailerSpec::RequestError, 'request boom'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def exec
|
|
39
|
+
ok!
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
define_action(:ActionError) do
|
|
44
|
+
route 'action_error'
|
|
45
|
+
http_method :get
|
|
46
|
+
authorize { allow }
|
|
47
|
+
|
|
48
|
+
def exec
|
|
49
|
+
raise ExceptionMailerSpec::ActionError, 'action boom'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
default_version 1
|
|
56
|
+
|
|
57
|
+
def with_exception_mailer(mailer)
|
|
58
|
+
action_hooks = HaveAPI::Hooks.hooks[HaveAPI::Action][:exec_exception]
|
|
59
|
+
original_action_listeners = action_hooks[:listeners].dup
|
|
60
|
+
server = app.settings.api_server
|
|
61
|
+
original_server_hooks = dup_instance_hooks(server)
|
|
62
|
+
|
|
63
|
+
mailer.enabled(server)
|
|
64
|
+
yield
|
|
65
|
+
ensure
|
|
66
|
+
action_hooks[:listeners] = original_action_listeners
|
|
67
|
+
restore_instance_hooks(server, original_server_hooks)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def dup_instance_hooks(server)
|
|
71
|
+
hooks = server.instance_variable_get(HaveAPI::Hooks::INSTANCE_VARIABLE)
|
|
72
|
+
return unless hooks
|
|
73
|
+
|
|
74
|
+
hooks.transform_values do |hook|
|
|
75
|
+
hook.merge(listeners: hook[:listeners].dup)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def restore_instance_hooks(server, hooks)
|
|
80
|
+
if hooks
|
|
81
|
+
server.instance_variable_set(HaveAPI::Hooks::INSTANCE_VARIABLE, hooks)
|
|
82
|
+
elsif server.instance_variable_defined?(HaveAPI::Hooks::INSTANCE_VARIABLE)
|
|
83
|
+
server.remove_instance_variable(HaveAPI::Hooks::INSTANCE_VARIABLE)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def collect_mail_calls(mailer)
|
|
88
|
+
calls = []
|
|
89
|
+
|
|
90
|
+
allow(mailer).to receive(:mail) do |context, exception, body|
|
|
91
|
+
calls << [context, exception, body]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
calls
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def new_mailer
|
|
98
|
+
described_class.new(
|
|
99
|
+
from: 'from@example.test',
|
|
100
|
+
to: 'to@example.test',
|
|
101
|
+
subject: '[spec] %s',
|
|
102
|
+
smtp: false
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'mails request-level exceptions outside action execution' do
|
|
107
|
+
mailer = new_mailer
|
|
108
|
+
calls = collect_mail_calls(mailer)
|
|
109
|
+
|
|
110
|
+
with_exception_mailer(mailer) do
|
|
111
|
+
get '/v1/tests/request_error'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
expect(last_response.status).to eq(500)
|
|
115
|
+
expect(api_response).not_to be_ok
|
|
116
|
+
expect(api_response.message).to eq('Server error occurred')
|
|
117
|
+
|
|
118
|
+
expect(calls.size).to eq(1)
|
|
119
|
+
context, exception, body = calls.first
|
|
120
|
+
expect(context.action.to_s).to include('RequestError')
|
|
121
|
+
expect(exception).to be_a(ExceptionMailerSpec::RequestError)
|
|
122
|
+
expect(body).to include('request boom')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'redacts secrets from request-level exception emails' do
|
|
126
|
+
mailer = new_mailer
|
|
127
|
+
calls = collect_mail_calls(mailer)
|
|
128
|
+
header_secret = %w[HEADER SECRET].join('_')
|
|
129
|
+
header_token_secret = %w[HEADER TOKEN SECRET].join('_')
|
|
130
|
+
cookie_secret = %w[COOKIE SECRET].join('_')
|
|
131
|
+
query_secret = %w[QUERY SECRET].join('_')
|
|
132
|
+
body_secret = %w[BODY SECRET].join('_')
|
|
133
|
+
nested_secret = %w[NESTED SECRET].join('_')
|
|
134
|
+
|
|
135
|
+
with_exception_mailer(mailer) do
|
|
136
|
+
header 'Accept', 'application/json'
|
|
137
|
+
header 'Content-Type', 'application/json'
|
|
138
|
+
header 'Authorization', "Bearer #{header_secret}"
|
|
139
|
+
header 'Cookie', "session=#{cookie_secret}"
|
|
140
|
+
header 'X-Auth-Token', header_token_secret
|
|
141
|
+
post "/v1/tests/input_request_error?_auth_token=#{query_secret}", JSON.generate({
|
|
142
|
+
password: body_secret,
|
|
143
|
+
nested: {
|
|
144
|
+
token: nested_secret,
|
|
145
|
+
visible: 'VISIBLE_VALUE'
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
body = calls.first[2]
|
|
151
|
+
|
|
152
|
+
expect(body).to include('VISIBLE_VALUE')
|
|
153
|
+
expect(body).to include(HaveAPI::Extensions::ExceptionMailer::FILTERED_VALUE)
|
|
154
|
+
[
|
|
155
|
+
header_secret,
|
|
156
|
+
header_token_secret,
|
|
157
|
+
cookie_secret,
|
|
158
|
+
query_secret,
|
|
159
|
+
body_secret,
|
|
160
|
+
nested_secret
|
|
161
|
+
].each do |secret|
|
|
162
|
+
expect(body).not_to include(secret)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'mails action execution exceptions' do
|
|
167
|
+
mailer = new_mailer
|
|
168
|
+
calls = collect_mail_calls(mailer)
|
|
169
|
+
|
|
170
|
+
with_exception_mailer(mailer) do
|
|
171
|
+
get '/v1/tests/action_error'
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
expect(last_response.status).to eq(500)
|
|
175
|
+
expect(api_response).not_to be_ok
|
|
176
|
+
expect(calls.size).to eq(1)
|
|
177
|
+
expect(calls.first[1]).to be_a(ExceptionMailerSpec::ActionError)
|
|
178
|
+
expect(calls.first[2]).to include('action boom')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'does not replace the API response when mail delivery fails' do
|
|
182
|
+
mailer = new_mailer
|
|
183
|
+
allow(mailer).to receive(:mail).and_raise(StandardError, 'smtp down')
|
|
184
|
+
|
|
185
|
+
with_exception_mailer(mailer) do
|
|
186
|
+
expect do
|
|
187
|
+
get '/v1/tests/request_error'
|
|
188
|
+
end.to output(/ExceptionMailer failed: StandardError: smtp down/).to_stderr
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
expect(last_response.status).to eq(500)
|
|
192
|
+
expect(api_response).not_to be_ok
|
|
193
|
+
expect(api_response.message).to eq('Server error occurred')
|
|
194
|
+
end
|
|
195
|
+
end
|