haveapi 0.28.3 → 0.28.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca76bb1c32eebc45f28bc5d4c76381715224685dd216fc896ff51d463c4ad810
4
- data.tar.gz: 9db66d118d3cbb7d2869f752c5a88c80cc41db2b939403e36955efbee2ccf42f
3
+ metadata.gz: 12a166f4b2cfae2e1046011ec072c2d3cfe2bae2c6e22aa7ccbb8a3a772fc4cd
4
+ data.tar.gz: 919fcc67646a1c52b1410500a74e27b3628403e97f97c095523d78bef4046210
5
5
  SHA512:
6
- metadata.gz: 752993a312ae16ed8522639eaf7e8a7f7cb1b621d113a8251cb1ab00b036ff4d2dbcbe90d484ecb047ca25ec001555b6dca13a8e03782d942cc2746127770580
7
- data.tar.gz: 61843c1e9498fd7557d85a879d2f60f95f491184084f59da37f52a965ff94513bcaaaaee73b2745e887debe96041fd4d5c4826593c4c78c45da62383353608dd
6
+ metadata.gz: 8f818bba5c50921fa6dc188345aabed20988fa499af2de594ad3f44af0614bd0ffe94aec7a13558bc72492f46b2fb1ce6c92b24d7a53af5be964e056d0a1a2fa
7
+ data.tar.gz: 38095fce8f5b1abfa3d630f5188bbed4d1203ea5ce70ec2f8a2f59faab16c1edfb863441a77a87cd1a5155e623331e32a45e434f163d3ed03cb4c3ac3cb77a79
data/haveapi.gemspec CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |s|
15
15
  s.required_ruby_version = ">= #{File.read('../../.ruby-version').strip}"
16
16
 
17
17
  s.add_dependency 'activesupport', '>= 7.1'
18
- s.add_dependency 'haveapi-client', '~> 0.28.3'
18
+ s.add_dependency 'haveapi-client', '~> 0.28.4'
19
19
  s.add_dependency 'json'
20
20
  s.add_dependency 'mail'
21
21
  s.add_dependency 'nesty', '~> 1.0'
@@ -11,6 +11,21 @@ module HaveAPI::Extensions
11
11
  # is added. Some helper methods are taken either from Sinatra or Rack.
12
12
  class ExceptionMailer < Base
13
13
  Frame = Struct.new(:filename, :lineno, :function, :context_line)
14
+ FILTERED_VALUE = '[FILTERED]'.freeze
15
+ SENSITIVE_KEY_PATTERN = /
16
+ authorization|cookie|password|passwd|passphrase|secret|token|
17
+ api[_-]?key|credential|jwt|session|csrf|query_string|form_vars|
18
+ request_uri|original_fullpath|fullpath
19
+ /ix
20
+ SENSITIVE_STRING_PATTERN = /
21
+ (
22
+ (?:authorization|cookie|password|passwd|passphrase|secret|token|
23
+ api[_-]?key|credential|jwt|session|csrf)
24
+ [^=:\s&;<>]{0,64}
25
+ \s*(?:=|:|=>)\s*["']?
26
+ )
27
+ [^&;\s<"'}]+
28
+ /ix
14
29
 
15
30
  # @param opts [Hash] options
16
31
  # @option opts to [String] recipient address
@@ -24,21 +39,33 @@ module HaveAPI::Extensions
24
39
 
25
40
  def enabled(server)
26
41
  HaveAPI::Action.connect_hook(:exec_exception) do |ret, context, e|
27
- log(context, e)
42
+ safe_log(context, e)
28
43
  ret
29
44
  end
30
45
 
31
46
  server.connect_hook(:description_exception) do |ret, context, e|
32
- log(context, e)
47
+ safe_log(context, e)
33
48
  ret
34
49
  end
50
+
51
+ server.connect_hook(:request_exception) do |ret, context, e|
52
+ safe_log(context, e)
53
+ ret
54
+ end
55
+ end
56
+
57
+ def safe_log(context, exception)
58
+ log(context, exception)
59
+ rescue StandardError => e
60
+ warn "HaveAPI::Extensions::ExceptionMailer failed: #{e.class}: #{e.message}"
35
61
  end
36
62
 
37
63
  def log(context, exception)
38
- req = context.request.request
39
- path = (req.script_name + req.path_info).squeeze('/')
64
+ request_context = context&.request
65
+ req = request_context.respond_to?(:request) ? request_context.request : request_context
66
+ path = request_path(context, req)
40
67
 
41
- frames = exception.backtrace.map do |line|
68
+ frames = Array(exception.backtrace).map do |line|
42
69
  frame = Frame.new
43
70
 
44
71
  next unless line =~ /(.*?):(\d+)(:in `(.*)')?/
@@ -57,14 +84,21 @@ module HaveAPI::Extensions
57
84
 
58
85
  frame
59
86
  end.compact
87
+ frames = [Frame.new('(unknown)', 0, nil, nil)] if frames.empty?
60
88
 
61
- env = context.request.env
89
+ args = redact(context&.args)
90
+ path_params = redact(context&.path_params)
91
+ input = redact(context&.input)
92
+ get = request_params(req, :GET)
93
+ post = request_params(req, :POST)
94
+ cookies = request_cookies(req)
95
+ env = redact(request_env(request_context, req))
62
96
 
63
97
  user =
64
- if context.current_user.respond_to?(:id)
98
+ if context&.current_user.respond_to?(:id)
65
99
  context.current_user.id
66
100
  else
67
- context.current_user
101
+ context&.current_user
68
102
  end
69
103
 
70
104
  mail(context, exception, TEMPLATE.result(binding))
@@ -114,6 +148,91 @@ module HaveAPI::Extensions
114
148
  end
115
149
  end
116
150
 
151
+ def request_path(context, req)
152
+ if req.respond_to?(:script_name) && req.respond_to?(:path_info)
153
+ return filter_query_string((req.script_name + req.path_info).squeeze('/'))
154
+ end
155
+
156
+ filter_query_string(
157
+ if context.respond_to?(:resolved_path)
158
+ context.resolved_path
159
+ elsif context.respond_to?(:path)
160
+ context.path
161
+ else
162
+ '(unknown)'
163
+ end
164
+ )
165
+ end
166
+
167
+ def request_env(request_context, req)
168
+ if request_context.respond_to?(:env)
169
+ request_context.env
170
+ elsif req.respond_to?(:env)
171
+ req.env
172
+ else
173
+ {}
174
+ end
175
+ end
176
+
177
+ def request_params(req, method)
178
+ return {} unless req.respond_to?(method)
179
+
180
+ redact(req.public_send(method) || {})
181
+ rescue StandardError
182
+ {}
183
+ end
184
+
185
+ def request_cookies(req)
186
+ return {} unless req.respond_to?(:cookies)
187
+
188
+ cookies = req.cookies || {}
189
+
190
+ cookies.each_with_object({}) do |(key, _value), ret|
191
+ ret[key] = FILTERED_VALUE
192
+ end
193
+ rescue StandardError
194
+ {}
195
+ end
196
+
197
+ def filter_query_string(path)
198
+ return path unless path.respond_to?(:sub)
199
+
200
+ path.sub(/\?.*/, "?#{FILTERED_VALUE}")
201
+ end
202
+
203
+ def redact(value, key = nil, seen = nil)
204
+ return FILTERED_VALUE if sensitive_key?(key)
205
+
206
+ seen ||= {}.compare_by_identity
207
+
208
+ case value
209
+ when Hash
210
+ return value if seen.has_key?(value)
211
+
212
+ seen[value] = true
213
+ value.each_with_object({}) do |(inner_key, inner_value), ret|
214
+ ret[inner_key] = redact(inner_value, inner_key, seen)
215
+ end
216
+ when Array
217
+ return value if seen.has_key?(value)
218
+
219
+ seen[value] = true
220
+ value.map { |inner_value| redact(inner_value, nil, seen) }
221
+ when String
222
+ redact_string(value)
223
+ else
224
+ value
225
+ end
226
+ end
227
+
228
+ def sensitive_key?(key)
229
+ key && key.to_s.match?(SENSITIVE_KEY_PATTERN)
230
+ end
231
+
232
+ def redact_string(value)
233
+ value.gsub(SENSITIVE_STRING_PATTERN, "\\1#{FILTERED_VALUE}")
234
+ end
235
+
117
236
  TEMPLATE = ERB.new(<<~END
118
237
  <!DOCTYPE html>
119
238
  <html>
@@ -224,15 +343,15 @@ module HaveAPI::Extensions
224
343
  </tr>
225
344
  <tr>
226
345
  <th>Arguments</th>
227
- <td><%=h context.args %></td>
346
+ <td><%=h args %></td>
228
347
  </tr>
229
348
  <tr>
230
349
  <th>Path parameters</th>
231
- <td><%=h context.path_params %></td>
350
+ <td><%=h path_params %></td>
232
351
  </tr>
233
352
  <tr>
234
353
  <th>Input</th>
235
- <td><%=h context.input %></td>
354
+ <td><%=h input %></td>
236
355
  </tr>
237
356
  <tr>
238
357
  <th>User</th>
@@ -269,13 +388,13 @@ module HaveAPI::Extensions
269
388
  </div> <!-- /BACKTRACE -->
270
389
  <div id="get">
271
390
  <h3 id="get-info">GET</h3>
272
- <% if req.GET and not req.GET.empty? %>
391
+ <% if !get.empty? %>
273
392
  <table class="req">
274
393
  <tr>
275
394
  <th>Variable</th>
276
395
  <th>Value</th>
277
396
  </tr>
278
- <% req.GET.sort_by { |k, v| k.to_s }.each { |key, val| %>
397
+ <% get.sort_by { |k, v| k.to_s }.each { |key, val| %>
279
398
  <tr>
280
399
  <td><%=h key %></td>
281
400
  <td class="code"><div><%=h val.inspect %></div></td>
@@ -289,13 +408,13 @@ module HaveAPI::Extensions
289
408
  </div> <!-- /GET -->
290
409
  <div id="post">
291
410
  <h3 id="post-info">POST</h3>
292
- <% if req.POST and not req.POST.empty? %>
411
+ <% if !post.empty? %>
293
412
  <table class="req">
294
413
  <tr>
295
414
  <th>Variable</th>
296
415
  <th>Value</th>
297
416
  </tr>
298
- <% req.POST.sort_by { |k, v| k.to_s }.each { |key, val| %>
417
+ <% post.sort_by { |k, v| k.to_s }.each { |key, val| %>
299
418
  <tr>
300
419
  <td><%=h key %></td>
301
420
  <td class="code"><div><%=h val.inspect %></div></td>
@@ -309,13 +428,13 @@ module HaveAPI::Extensions
309
428
  </div> <!-- /POST -->
310
429
  <div id="cookies">
311
430
  <h3 id="cookie-info">COOKIES</h3>
312
- <% unless req.cookies.empty? %>
431
+ <% unless cookies.empty? %>
313
432
  <table class="req">
314
433
  <tr>
315
434
  <th>Variable</th>
316
435
  <th>Value</th>
317
436
  </tr>
318
- <% req.cookies.each { |key, val| %>
437
+ <% cookies.each { |key, val| %>
319
438
  <tr>
320
439
  <td><%=h key %></td>
321
440
  <td class="code"><div><%=h val.inspect %></div></td>
@@ -45,6 +45,17 @@ module HaveAPI
45
45
  message: 'error message sent to the client'
46
46
  }
47
47
 
48
+ has_hook :request_exception,
49
+ desc: 'Called when an exception occurs outside action execution',
50
+ args: {
51
+ context: 'HaveAPI::Context',
52
+ exception: 'exception instance'
53
+ },
54
+ ret: {
55
+ http_status: 'HTTP status code to send to client',
56
+ message: 'error message sent to the client'
57
+ }
58
+
48
59
  module ServerHelpers
49
60
  def setup_formatter
50
61
  return if @formatter
@@ -137,6 +148,32 @@ module HaveAPI
137
148
  halt code, headers, @formatter.format(false, nil, msg, version: false)
138
149
  end
139
150
 
151
+ def report_exception(exception, context = nil)
152
+ context ||= Context.new(
153
+ settings.api_server,
154
+ request: self,
155
+ params:,
156
+ endpoint: true
157
+ )
158
+
159
+ tmp =
160
+ begin
161
+ settings.api_server.call_hooks_for(
162
+ :request_exception,
163
+ args: [context, exception]
164
+ )
165
+ rescue StandardError => e
166
+ warn "HaveAPI request exception hook failed: #{e.class}: #{e.message}"
167
+ {}
168
+ end
169
+
170
+ report_error(
171
+ tmp[:http_status] || 500,
172
+ {},
173
+ tmp[:message] || 'Server error occurred'
174
+ )
175
+ end
176
+
140
177
  def root
141
178
  settings.api_server.root
142
179
  end
@@ -295,6 +332,10 @@ module HaveAPI
295
332
  report_error(404, {}, 'Action not found') unless @halted
296
333
  end
297
334
 
335
+ error do
336
+ report_exception(env['sinatra.error'])
337
+ end
338
+
298
339
  after do
299
340
  if Object.const_defined?(:ActiveRecord)
300
341
  ActiveRecord::Base.connection_handler.clear_active_connections!
@@ -528,113 +569,125 @@ module HaveAPI
528
569
 
529
570
  def mount_action(v, route)
530
571
  @sinatra.method(route.http_method).call(route.sinatra_path) do
531
- setup_formatter
572
+ context = nil
532
573
 
533
- if route.action.auth || settings.api_server.action_state_auth_required?(route)
534
- authenticate!(v)
535
- else
536
- authenticated?(v)
537
- end
574
+ begin
575
+ setup_formatter
538
576
 
539
- raw_body = request.body.read
540
- body_method = !%i[get head options].include?(route.http_method.to_sym)
577
+ if route.action.auth || settings.api_server.action_state_auth_required?(route)
578
+ authenticate!(v)
579
+ else
580
+ authenticated?(v)
581
+ end
541
582
 
542
- if body_method && !raw_body.empty? && !settings.api_server.send(:json_content_type?, request)
543
- report_error(415, {}, 'Unsupported Content-Type')
544
- end
583
+ raw_body = request.body ? request.body.read : ''
584
+ body_method = !%i[get head options].include?(route.http_method.to_sym)
545
585
 
546
- begin
547
- body = raw_body.empty? ? nil : JSON.parse(raw_body)
548
- rescue JSON::ParserError
549
- report_error(400, {}, 'Bad JSON syntax')
550
- end
586
+ if body_method && !raw_body.empty? && !settings.api_server.send(:json_content_type?, request)
587
+ report_error(415, {}, 'Unsupported Content-Type')
588
+ end
551
589
 
552
- if !raw_body.empty? && !body.is_a?(Hash)
553
- report_error(400, {}, 'JSON body must be an object')
554
- end
590
+ begin
591
+ body = raw_body.empty? ? nil : JSON.parse(raw_body)
592
+ rescue JSON::ParserError
593
+ report_error(400, {}, 'Bad JSON syntax')
594
+ end
555
595
 
556
- action_params = settings.api_server.send(:path_params, route, params)
557
- action_input = body_method ? (body || {}) : request.GET
596
+ if !raw_body.empty? && !body.is_a?(Hash)
597
+ report_error(400, {}, 'JSON body must be an object')
598
+ end
558
599
 
559
- context = Context.new(
560
- settings.api_server,
561
- version: v,
562
- request: self,
563
- action: route.action,
564
- path: route.path,
565
- path_params: action_params,
566
- input: action_input,
567
- user: current_user,
568
- endpoint: true,
569
- resource_path: route.resource_path
570
- )
600
+ action_params = settings.api_server.send(:path_params, route, params)
601
+ action_input = body_method ? (body || {}) : request.GET
602
+
603
+ context = Context.new(
604
+ settings.api_server,
605
+ version: v,
606
+ request: self,
607
+ action: route.action,
608
+ path: route.path,
609
+ path_params: action_params,
610
+ input: action_input,
611
+ user: current_user,
612
+ endpoint: true,
613
+ resource_path: route.resource_path
614
+ )
571
615
 
572
- action = route.action.new(request, v, action_params, action_input, context)
616
+ action = route.action.new(request, v, action_params, action_input, context)
573
617
 
574
- unless action.authorized?(current_user)
575
- report_error(403, {}, 'Access denied. Insufficient permissions.')
576
- end
618
+ unless action.authorized?(current_user)
619
+ report_error(403, {}, 'Access denied. Insufficient permissions.')
620
+ end
577
621
 
578
- status, reply, errors, http_status = action.safe_exec
579
- @halted = true
622
+ status, reply, errors, http_status = action.safe_exec
623
+ @halted = true
580
624
 
581
- [
582
- http_status || 200,
583
- @formatter.format(
584
- status,
585
- status ? reply : nil,
586
- status ? nil : reply,
587
- errors,
588
- version: false
589
- )
590
- ]
625
+ [
626
+ http_status || 200,
627
+ @formatter.format(
628
+ status,
629
+ status ? reply : nil,
630
+ status ? nil : reply,
631
+ errors,
632
+ version: false
633
+ )
634
+ ]
635
+ rescue Exception => e # rubocop:disable Lint/RescueException
636
+ report_exception(e, context)
637
+ end
591
638
  end
592
639
 
593
640
  @sinatra.options route.sinatra_path do |*args|
594
- setup_formatter
595
- access_control
596
- route_method = route.http_method.to_s.upcase
641
+ ctx = nil
597
642
 
598
- pass if params[:method] && params[:method] != route_method
643
+ begin
644
+ setup_formatter
645
+ access_control
646
+ route_method = route.http_method.to_s.upcase
599
647
 
600
- if route.action.auth || settings.api_server.action_state_auth_required?(route)
601
- authenticate!(v)
602
- else
603
- authenticated?(v)
604
- end
648
+ pass if params[:method] && params[:method] != route_method
605
649
 
606
- ctx = Context.new(
607
- settings.api_server,
608
- version: v,
609
- request: self,
610
- action: route.action,
611
- path: route.path,
612
- args:,
613
- params:,
614
- user: current_user,
615
- endpoint: true,
616
- resource_path: route.resource_path,
617
- doc: true
618
- )
650
+ if route.action.auth || settings.api_server.action_state_auth_required?(route)
651
+ authenticate!(v)
652
+ else
653
+ authenticated?(v)
654
+ end
619
655
 
620
- begin
621
- desc = route.action.describe(ctx)
656
+ ctx = Context.new(
657
+ settings.api_server,
658
+ version: v,
659
+ request: self,
660
+ action: route.action,
661
+ path: route.path,
662
+ args:,
663
+ params:,
664
+ user: current_user,
665
+ endpoint: true,
666
+ resource_path: route.resource_path,
667
+ doc: true
668
+ )
622
669
 
623
- unless desc
624
- report_error(403, {}, 'Access denied. Insufficient permissions.')
670
+ begin
671
+ desc = route.action.describe(ctx)
672
+
673
+ unless desc
674
+ report_error(403, {}, 'Access denied. Insufficient permissions.')
675
+ end
676
+ rescue ValidationError => e
677
+ report_error(400, e.to_hash, e.message)
678
+ rescue StandardError => e
679
+ tmp = settings.api_server.call_hooks_for(:description_exception, args: [ctx, e])
680
+ report_error(
681
+ tmp[:http_status] || 500,
682
+ {},
683
+ tmp[:message] || 'Server error occured'
684
+ )
625
685
  end
626
- rescue ValidationError => e
627
- report_error(400, e.to_hash, e.message)
628
- rescue StandardError => e
629
- tmp = settings.api_server.call_hooks_for(:description_exception, args: [ctx, e])
630
- report_error(
631
- tmp[:http_status] || 500,
632
- {},
633
- tmp[:message] || 'Server error occured'
634
- )
635
- end
636
686
 
637
- @formatter.format(true, desc)
687
+ @formatter.format(true, desc)
688
+ rescue Exception => e # rubocop:disable Lint/RescueException
689
+ report_exception(e, ctx)
690
+ end
638
691
  end
639
692
  end
640
693
 
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.28.3'.freeze
3
+ VERSION = '0.28.4'.freeze
4
4
  end