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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +139 -0
  3. data/Rakefile +1 -0
  4. data/haveapi.gemspec +2 -1
  5. data/lib/haveapi/action.rb +26 -9
  6. data/lib/haveapi/actions/default.rb +5 -2
  7. data/lib/haveapi/actions/paginable.rb +8 -4
  8. data/lib/haveapi/authentication/oauth2/provider.rb +1 -1
  9. data/lib/haveapi/authentication/token/config.rb +10 -3
  10. data/lib/haveapi/authentication/token/provider.rb +22 -21
  11. data/lib/haveapi/context.rb +4 -1
  12. data/lib/haveapi/extensions/exception_mailer.rb +136 -17
  13. data/lib/haveapi/i18n.rb +125 -0
  14. data/lib/haveapi/locales/cs.yml +167 -0
  15. data/lib/haveapi/locales/en.yml +168 -0
  16. data/lib/haveapi/metadata.rb +25 -3
  17. data/lib/haveapi/model_adapters/active_record.rb +40 -26
  18. data/lib/haveapi/output_formatter.rb +2 -2
  19. data/lib/haveapi/parameters/metadata_i18n.rb +179 -0
  20. data/lib/haveapi/parameters/resource.rb +18 -7
  21. data/lib/haveapi/parameters/typed.rb +27 -20
  22. data/lib/haveapi/params.rb +76 -7
  23. data/lib/haveapi/resource.rb +1 -1
  24. data/lib/haveapi/resources/action_state.rb +47 -27
  25. data/lib/haveapi/server.rb +287 -94
  26. data/lib/haveapi/spec/api_builder.rb +25 -0
  27. data/lib/haveapi/spec/spec_methods.rb +10 -0
  28. data/lib/haveapi/tasks/i18n.rb +198 -0
  29. data/lib/haveapi/validator_chain.rb +1 -1
  30. data/lib/haveapi/validators/acceptance.rb +5 -2
  31. data/lib/haveapi/validators/confirmation.rb +5 -2
  32. data/lib/haveapi/validators/exclusion.rb +2 -2
  33. data/lib/haveapi/validators/format.rb +5 -2
  34. data/lib/haveapi/validators/inclusion.rb +2 -2
  35. data/lib/haveapi/validators/length.rb +5 -5
  36. data/lib/haveapi/validators/numericality.rb +20 -22
  37. data/lib/haveapi/validators/presence.rb +4 -2
  38. data/lib/haveapi/version.rb +1 -1
  39. data/lib/haveapi.rb +1 -0
  40. data/spec/authentication/oauth2_spec.rb +10 -0
  41. data/spec/extensions/exception_mailer_spec.rb +195 -0
  42. data/spec/i18n_spec.rb +520 -0
  43. data/spec/params_spec.rb +183 -0
  44. data/spec/server/integration_spec.rb +34 -0
  45. metadata +30 -7
  46. data/doc/create-client.md +0 -107
  47. data/doc/json-schema.html +0 -1182
  48. data/doc/protocol.md +0 -535
  49. data/doc/protocol.png +0 -0
@@ -6,9 +6,10 @@ require 'haveapi/hooks'
6
6
 
7
7
  module HaveAPI
8
8
  class Server
9
- attr_accessor :default_version, :action_state, :validation_error_http_status
9
+ attr_accessor :default_version, :action_state, :validation_error_http_status,
10
+ :default_locale, :parameter_i18n_scope
10
11
  attr_reader :root, :routes, :module_name, :auth_chain, :versions, :extensions,
11
- :action_state_auth
12
+ :action_state_auth, :locale_header, :available_locales
12
13
 
13
14
  include Hookable
14
15
 
@@ -45,7 +46,62 @@ module HaveAPI
45
46
  message: 'error message sent to the client'
46
47
  }
47
48
 
49
+ has_hook :request_exception,
50
+ desc: 'Called when an exception occurs outside action execution',
51
+ args: {
52
+ context: 'HaveAPI::Context',
53
+ exception: 'exception instance'
54
+ },
55
+ ret: {
56
+ http_status: 'HTTP status code to send to client',
57
+ message: 'error message sent to the client'
58
+ }
59
+
48
60
  module ServerHelpers
61
+ def setup_request_locale
62
+ return if @haveapi_locale_setup
63
+
64
+ server = settings.api_server
65
+ @haveapi_previous_locale = ::I18n.locale
66
+ locale_header_value = server.locale_header_value(request)
67
+ @haveapi_locale_requested = !locale_header_value.nil?
68
+ @haveapi_explicit_locale = server.request_locale(request)
69
+ @haveapi_locale = if @haveapi_locale_requested
70
+ @haveapi_explicit_locale || server.default_locale
71
+ else
72
+ server.resolved_locale(
73
+ request:,
74
+ current_user: nil
75
+ )
76
+ end
77
+ server.activate_locale(@haveapi_locale)
78
+ add_vary_header(server.locale_header) if server.locale_header
79
+ @haveapi_locale_setup = true
80
+ end
81
+
82
+ def resolve_request_locale(user = current_user)
83
+ setup_request_locale
84
+ return @haveapi_locale if @haveapi_locale_requested
85
+
86
+ @haveapi_locale = settings.api_server.resolved_locale(
87
+ request:,
88
+ current_user: user
89
+ )
90
+ settings.api_server.activate_locale(@haveapi_locale)
91
+ end
92
+
93
+ def restore_request_locale
94
+ return unless @haveapi_locale_setup
95
+
96
+ settings.api_server.activate_locale(@haveapi_previous_locale)
97
+ end
98
+
99
+ def add_vary_header(name)
100
+ values = response['Vary'].to_s.split(/\s*,\s*/).reject(&:empty?)
101
+ values << name unless values.include?(name)
102
+ headers 'Vary' => values.join(', ')
103
+ end
104
+
49
105
  def setup_formatter
50
106
  return if @formatter
51
107
 
@@ -53,7 +109,7 @@ module HaveAPI
53
109
  accept = request.accept
54
110
  rescue ArgumentError, EncodingError
55
111
  @formatter.supports?([])
56
- report_error(400, {}, 'Bad Accept header')
112
+ report_error(400, {}, HaveAPI.message('haveapi.errors.bad_accept_header'))
57
113
  else
58
114
  unless @formatter.supports?(accept)
59
115
  @halted = true
@@ -68,7 +124,10 @@ module HaveAPI
68
124
  end
69
125
 
70
126
  def authenticated?(v)
71
- return @current_user if @current_user
127
+ if @current_user
128
+ resolve_request_locale
129
+ return @current_user
130
+ end
72
131
 
73
132
  begin
74
133
  @current_user = settings.api_server.send(:do_authenticate, v, request)
@@ -81,12 +140,13 @@ module HaveAPI
81
140
  report_error(400, {}, e.message)
82
141
  end
83
142
  settings.api_server.call_hooks_for(:post_authenticated, args: [@current_user])
143
+ resolve_request_locale
84
144
  @current_user
85
145
  end
86
146
 
87
147
  def authenticated_versions
88
- settings.api_server.versions.each_with_object({}) do |v, ret|
89
- ret[v] = settings.api_server.send(:do_authenticate, v, request)
148
+ ret = settings.api_server.versions.each_with_object({}) do |v, users|
149
+ users[v] = settings.api_server.send(:do_authenticate, v, request)
90
150
  rescue HaveAPI::Authentication::TokenConflict => e
91
151
  unless @formatter
92
152
  @formatter = OutputFormatter.new
@@ -95,6 +155,9 @@ module HaveAPI
95
155
 
96
156
  report_error(400, {}, e.message)
97
157
  end
158
+
159
+ resolve_request_locale(ret[settings.api_server.default_version] || ret.values.compact.first)
160
+ ret
98
161
  end
99
162
 
100
163
  def access_control
@@ -122,7 +185,7 @@ module HaveAPI
122
185
  report_error(
123
186
  401,
124
187
  { 'www-authenticate' => 'Basic realm="Restricted Area"' },
125
- 'Action requires user to authenticate'
188
+ HaveAPI.message('haveapi.authentication.required')
126
189
  )
127
190
  end
128
191
 
@@ -137,6 +200,32 @@ module HaveAPI
137
200
  halt code, headers, @formatter.format(false, nil, msg, version: false)
138
201
  end
139
202
 
203
+ def report_exception(exception, context = nil)
204
+ context ||= Context.new(
205
+ settings.api_server,
206
+ request: self,
207
+ params:,
208
+ endpoint: true
209
+ )
210
+
211
+ tmp =
212
+ begin
213
+ settings.api_server.call_hooks_for(
214
+ :request_exception,
215
+ args: [context, exception]
216
+ )
217
+ rescue StandardError => e
218
+ warn "HaveAPI request exception hook failed: #{e.class}: #{e.message}"
219
+ {}
220
+ end
221
+
222
+ report_error(
223
+ tmp[:http_status] || 500,
224
+ {},
225
+ tmp[:message] || HaveAPI.message('haveapi.errors.server_error')
226
+ )
227
+ end
228
+
140
229
  def root
141
230
  settings.api_server.root
142
231
  end
@@ -223,6 +312,62 @@ module HaveAPI
223
312
  @extensions = []
224
313
  @action_state_auth = :backend
225
314
  @validation_error_http_status = nil
315
+ @default_locale = :en
316
+ self.available_locales = HaveAPI::I18n.available_locales
317
+ self.locale_header = 'Accept-Language'
318
+ end
319
+
320
+ def available_locales=(locales)
321
+ @available_locales = Array(locales)
322
+ allow_i18n_locales(@available_locales)
323
+ end
324
+
325
+ def locale_header=(header)
326
+ @locale_header = header
327
+ allow_header(header) if header
328
+ end
329
+
330
+ def activate_locale(locale)
331
+ allow_i18n_locales([locale])
332
+ ::I18n.locale = locale
333
+ end
334
+
335
+ def allow_i18n_locales(locales)
336
+ requested = Array(locales).compact.map { |locale| locale.to_s.to_sym }
337
+ current = ::I18n.available_locales
338
+ missing = requested.reject do |locale|
339
+ current.any? { |available| available.to_s == locale.to_s }
340
+ end
341
+
342
+ ::I18n.available_locales = current + missing unless missing.empty?
343
+ end
344
+
345
+ def locale(&block)
346
+ @locale_resolver = block if block
347
+ @locale_resolver
348
+ end
349
+
350
+ def request_locale(request)
351
+ HaveAPI::I18n.accept_language(
352
+ locale_header_value(request),
353
+ available_locales
354
+ )
355
+ end
356
+
357
+ def locale_header_value(request)
358
+ request.env["HTTP_#{locale_header.to_s.upcase.tr('-', '_')}"]
359
+ end
360
+
361
+ def resolved_locale(request:, current_user:)
362
+ locale = if @locale_resolver
363
+ @locale_resolver.call(
364
+ request:,
365
+ current_user:,
366
+ default_locale:
367
+ )
368
+ end
369
+
370
+ HaveAPI::I18n.normalize_locale(locale, available_locales) || default_locale
226
371
  end
227
372
 
228
373
  def action_state_auth=(mode)
@@ -284,6 +429,8 @@ module HaveAPI
284
429
  helpers DocHelpers
285
430
 
286
431
  before do
432
+ setup_request_locale
433
+
287
434
  if request.env['HTTP_ORIGIN']
288
435
  headers 'access-control-allow-origin' => '*',
289
436
  'access-control-allow-credentials' => 'false'
@@ -292,10 +439,16 @@ module HaveAPI
292
439
 
293
440
  not_found do
294
441
  setup_formatter
295
- report_error(404, {}, 'Action not found') unless @halted
442
+ report_error(404, {}, HaveAPI.message('haveapi.errors.action_not_found')) unless @halted
443
+ end
444
+
445
+ error do
446
+ report_exception(env['sinatra.error'])
296
447
  end
297
448
 
298
449
  after do
450
+ restore_request_locale
451
+
299
452
  if Object.const_defined?(:ActiveRecord)
300
453
  ActiveRecord::Base.connection_handler.clear_active_connections!
301
454
  end
@@ -493,6 +646,27 @@ module HaveAPI
493
646
  end
494
647
  end
495
648
 
649
+ def collect_parameter_metadata_i18n_items(resources, context)
650
+ resources.flat_map do |resource, children|
651
+ original_resource_path = context.resource_path
652
+ context.resource_path = context.resource_path + [resource.resource_name.underscore]
653
+
654
+ action_items = children[:actions].flat_map do |action, path|
655
+ context.action = action
656
+ context.path = path
657
+
658
+ action.parameter_metadata_i18n_items(context)
659
+ end
660
+
661
+ child_items = collect_parameter_metadata_i18n_items(children[:resources], context)
662
+
663
+ action_items + child_items
664
+ ensure
665
+ context.resource_path = original_resource_path
666
+ end
667
+ end
668
+ private :collect_parameter_metadata_i18n_items
669
+
496
670
  def mount_resource(prefix, v, resource, hash)
497
671
  hash[resource] = { resources: {}, actions: {} }
498
672
 
@@ -528,113 +702,125 @@ module HaveAPI
528
702
 
529
703
  def mount_action(v, route)
530
704
  @sinatra.method(route.http_method).call(route.sinatra_path) do
531
- setup_formatter
705
+ context = nil
532
706
 
533
- if route.action.auth || settings.api_server.action_state_auth_required?(route)
534
- authenticate!(v)
535
- else
536
- authenticated?(v)
537
- end
707
+ begin
708
+ setup_formatter
538
709
 
539
- raw_body = request.body.read
540
- body_method = !%i[get head options].include?(route.http_method.to_sym)
710
+ if route.action.auth || settings.api_server.action_state_auth_required?(route)
711
+ authenticate!(v)
712
+ else
713
+ authenticated?(v)
714
+ end
541
715
 
542
- if body_method && !raw_body.empty? && !settings.api_server.send(:json_content_type?, request)
543
- report_error(415, {}, 'Unsupported Content-Type')
544
- end
716
+ raw_body = request.body ? request.body.read : ''
717
+ body_method = !%i[get head options].include?(route.http_method.to_sym)
545
718
 
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
719
+ if body_method && !raw_body.empty? && !settings.api_server.send(:json_content_type?, request)
720
+ report_error(415, {}, HaveAPI.message('haveapi.errors.unsupported_content_type'))
721
+ end
551
722
 
552
- if !raw_body.empty? && !body.is_a?(Hash)
553
- report_error(400, {}, 'JSON body must be an object')
554
- end
723
+ begin
724
+ body = raw_body.empty? ? nil : JSON.parse(raw_body)
725
+ rescue JSON::ParserError
726
+ report_error(400, {}, HaveAPI.message('haveapi.errors.bad_json_syntax'))
727
+ end
555
728
 
556
- action_params = settings.api_server.send(:path_params, route, params)
557
- action_input = body_method ? (body || {}) : request.GET
729
+ if !raw_body.empty? && !body.is_a?(Hash)
730
+ report_error(400, {}, HaveAPI.message('haveapi.errors.json_body_object'))
731
+ end
558
732
 
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
- )
733
+ action_params = settings.api_server.send(:path_params, route, params)
734
+ action_input = body_method ? (body || {}) : request.GET
735
+
736
+ context = Context.new(
737
+ settings.api_server,
738
+ version: v,
739
+ request: self,
740
+ action: route.action,
741
+ path: route.path,
742
+ path_params: action_params,
743
+ input: action_input,
744
+ user: current_user,
745
+ endpoint: true,
746
+ resource_path: route.resource_path
747
+ )
571
748
 
572
- action = route.action.new(request, v, action_params, action_input, context)
749
+ action = route.action.new(request, v, action_params, action_input, context)
573
750
 
574
- unless action.authorized?(current_user)
575
- report_error(403, {}, 'Access denied. Insufficient permissions.')
576
- end
751
+ unless action.authorized?(current_user)
752
+ report_error(403, {}, HaveAPI.message('haveapi.authorization.insufficient_permissions'))
753
+ end
577
754
 
578
- status, reply, errors, http_status = action.safe_exec
579
- @halted = true
755
+ status, reply, errors, http_status = action.safe_exec
756
+ @halted = true
580
757
 
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
- ]
758
+ [
759
+ http_status || 200,
760
+ @formatter.format(
761
+ status,
762
+ status ? reply : nil,
763
+ status ? nil : reply,
764
+ errors,
765
+ version: false
766
+ )
767
+ ]
768
+ rescue Exception => e # rubocop:disable Lint/RescueException
769
+ report_exception(e, context)
770
+ end
591
771
  end
592
772
 
593
773
  @sinatra.options route.sinatra_path do |*args|
594
- setup_formatter
595
- access_control
596
- route_method = route.http_method.to_s.upcase
774
+ ctx = nil
597
775
 
598
- pass if params[:method] && params[:method] != route_method
776
+ begin
777
+ setup_formatter
778
+ access_control
779
+ route_method = route.http_method.to_s.upcase
599
780
 
600
- if route.action.auth || settings.api_server.action_state_auth_required?(route)
601
- authenticate!(v)
602
- else
603
- authenticated?(v)
604
- end
781
+ pass if params[:method] && params[:method] != route_method
605
782
 
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
- )
783
+ if route.action.auth || settings.api_server.action_state_auth_required?(route)
784
+ authenticate!(v)
785
+ else
786
+ authenticated?(v)
787
+ end
619
788
 
620
- begin
621
- desc = route.action.describe(ctx)
789
+ ctx = Context.new(
790
+ settings.api_server,
791
+ version: v,
792
+ request: self,
793
+ action: route.action,
794
+ path: route.path,
795
+ args:,
796
+ params:,
797
+ user: current_user,
798
+ endpoint: true,
799
+ resource_path: route.resource_path,
800
+ doc: true
801
+ )
622
802
 
623
- unless desc
624
- report_error(403, {}, 'Access denied. Insufficient permissions.')
803
+ begin
804
+ desc = route.action.describe(ctx)
805
+
806
+ unless desc
807
+ report_error(403, {}, HaveAPI.message('haveapi.authorization.insufficient_permissions'))
808
+ end
809
+ rescue ValidationError => e
810
+ report_error(400, e.to_hash, e.message_value)
811
+ rescue StandardError => e
812
+ tmp = settings.api_server.call_hooks_for(:description_exception, args: [ctx, e])
813
+ report_error(
814
+ tmp[:http_status] || 500,
815
+ {},
816
+ tmp[:message] || HaveAPI.message('haveapi.errors.server_error')
817
+ )
625
818
  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
819
 
637
- @formatter.format(true, desc)
820
+ @formatter.format(true, desc)
821
+ rescue Exception => e # rubocop:disable Lint/RescueException
822
+ report_exception(e, ctx)
823
+ end
638
824
  end
639
825
  end
640
826
 
@@ -689,6 +875,13 @@ module HaveAPI
689
875
  r.describe(hash, context)
690
876
  end
691
877
 
878
+ def parameter_metadata_i18n_items(version: @default_version)
879
+ routes = @routes.fetch(version)
880
+ context = Context.new(self, version:, doc: true)
881
+
882
+ collect_parameter_metadata_i18n_items(routes[:resources], context)
883
+ end
884
+
692
885
  def path_for_action(version, action)
693
886
  routes = @routes && @routes[version]
694
887
  return unless routes
@@ -64,6 +64,31 @@ module HaveAPI::Spec
64
64
  opt(:validation_error_http_status, status)
65
65
  end
66
66
 
67
+ # Set default response locale.
68
+ def default_locale(locale)
69
+ opt(:default_locale, locale)
70
+ end
71
+
72
+ # Set locales available to the API server.
73
+ def available_locales(locales)
74
+ opt(:available_locales, locales)
75
+ end
76
+
77
+ # Set request header used to negotiate the response locale.
78
+ def locale_header(header)
79
+ opt(:locale_header, header)
80
+ end
81
+
82
+ # Set custom locale resolver.
83
+ def locale(&block)
84
+ opt(:locale, block)
85
+ end
86
+
87
+ # Set the translation scope for action parameter labels/descriptions.
88
+ def parameter_i18n_scope(scope)
89
+ opt(:parameter_i18n_scope, scope)
90
+ end
91
+
67
92
  # Set a custom mount path.
68
93
  def mount_to(path)
69
94
  opt(:mount, path)
@@ -19,6 +19,16 @@ module HaveAPI::Spec
19
19
  @api.action_state_auth = asa if asa
20
20
  ves = get_opt(:validation_error_http_status)
21
21
  @api.validation_error_http_status = ves if ves
22
+ dl = get_opt(:default_locale)
23
+ @api.default_locale = dl if dl
24
+ al = get_opt(:available_locales)
25
+ @api.available_locales = al if al
26
+ lh = get_opt(:locale_header)
27
+ @api.locale_header = lh if lh
28
+ locale = get_opt(:locale)
29
+ @api.locale(&locale) if locale
30
+ parameter_i18n_scope = get_opt(:parameter_i18n_scope)
31
+ @api.parameter_i18n_scope = parameter_i18n_scope if parameter_i18n_scope
22
32
  @api.mount(get_opt(:mount) || '/')
23
33
  @api.app
24
34
  end