haveapi 0.32.1 → 0.32.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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -8
  3. data/README.md +3 -422
  4. data/Rakefile +17 -7
  5. data/benchmarks/performance_compare.rb +27 -6
  6. data/doc/cs/client-examples.md +535 -0
  7. data/doc/cs/create-client.md +118 -0
  8. data/doc/cs/json-schema.html +1182 -0
  9. data/doc/cs/protocol.md +595 -0
  10. data/doc/cs/readme.md +432 -0
  11. data/doc/cs/typed-input-validation.md +122 -0
  12. data/doc/{client-examples.md → en/client-examples.md} +20 -10
  13. data/doc/{documentation-ui.md → en/documentation-ui.md} +42 -6
  14. data/doc/{performance.md → en/performance.md} +11 -0
  15. data/doc/{protocol.md → en/protocol.md} +2 -2
  16. data/doc/en/readme.md +423 -0
  17. data/haveapi.gemspec +2 -2
  18. data/lib/haveapi/client_example.rb +18 -5
  19. data/lib/haveapi/client_examples/curl.rb +5 -5
  20. data/lib/haveapi/client_examples/dynamic_helpers.rb +10 -10
  21. data/lib/haveapi/client_examples/go_client.rb +1 -1
  22. data/lib/haveapi/client_examples/http.rb +4 -4
  23. data/lib/haveapi/client_examples/js_client.rb +10 -10
  24. data/lib/haveapi/client_examples/php_client.rb +24 -25
  25. data/lib/haveapi/client_examples/python_client.rb +11 -11
  26. data/lib/haveapi/client_examples/ruby_cli.rb +6 -9
  27. data/lib/haveapi/client_examples/ruby_client.rb +24 -16
  28. data/lib/haveapi/client_examples/rust_client.rb +1 -1
  29. data/lib/haveapi/client_examples/ts_generated_client.rb +1 -1
  30. data/lib/haveapi/documentation_context.rb +14 -2
  31. data/lib/haveapi/documentation_helpers.rb +92 -15
  32. data/lib/haveapi/documentation_locale.rb +65 -0
  33. data/lib/haveapi/documentation_oauth.rb +12 -12
  34. data/lib/haveapi/documentation_search.rb +5 -5
  35. data/lib/haveapi/documentation_validators.rb +43 -33
  36. data/lib/haveapi/locales/cs.yml +468 -0
  37. data/lib/haveapi/locales/en.yml +467 -0
  38. data/lib/haveapi/public/css/documentation.css +8 -4
  39. data/lib/haveapi/public/js/documentation.js +73 -20
  40. data/lib/haveapi/server.rb +32 -47
  41. data/lib/haveapi/shared_documentation_helpers.rb +53 -5
  42. data/lib/haveapi/version.rb +1 -1
  43. data/lib/haveapi/views/doc_layout.erb +3 -2
  44. data/lib/haveapi/views/doc_sidebars/create-client.erb +10 -10
  45. data/lib/haveapi/views/doc_sidebars/json-schema.erb +2 -2
  46. data/lib/haveapi/views/doc_sidebars/protocol.erb +24 -24
  47. data/lib/haveapi/views/documentation/search_form.erb +5 -4
  48. data/lib/haveapi/views/documentation/search_results.erb +6 -6
  49. data/lib/haveapi/views/index.erb +3 -3
  50. data/lib/haveapi/views/main_layout.erb +4 -4
  51. data/lib/haveapi/views/shared_documentation.erb +8 -5
  52. data/lib/haveapi/views/shared_sidebar.erb +11 -11
  53. data/lib/haveapi/views/version_page/auth_body.erb +7 -7
  54. data/lib/haveapi/views/version_page/client_auth.erb +1 -1
  55. data/lib/haveapi/views/version_page/client_example.erb +1 -1
  56. data/lib/haveapi/views/version_page/client_init.erb +1 -1
  57. data/lib/haveapi/views/version_page/client_overview.erb +7 -5
  58. data/lib/haveapi/views/version_page/example_panels.erb +5 -2
  59. data/lib/haveapi/views/version_page/resource_body.erb +24 -24
  60. data/lib/haveapi/views/version_page/resource_directory.erb +2 -2
  61. data/lib/haveapi/views/version_page/usage.erb +5 -5
  62. data/lib/haveapi/views/version_page.erb +11 -11
  63. data/lib/haveapi/views/version_sidebar/auth_nav.erb +2 -2
  64. data/lib/haveapi/views/version_sidebar/authentication_control.erb +8 -8
  65. data/lib/haveapi/views/version_sidebar/authentication_status.erb +1 -1
  66. data/lib/haveapi/views/version_sidebar/brand.erb +16 -5
  67. data/lib/haveapi/views/version_sidebar.erb +33 -18
  68. data/spec/client_examples/assets/documentation_test.cjs +107 -9
  69. data/spec/client_examples/comment_capture.rb +72 -0
  70. data/spec/client_examples/comments_spec.rb +162 -0
  71. data/spec/client_examples/context_spec.rb +18 -2
  72. data/spec/client_examples/detail_spec.rb +68 -0
  73. data/spec/documentation/client_overview_spec.rb +60 -0
  74. data/spec/documentation/example_selection_spec.rb +192 -0
  75. data/spec/documentation/language_settings_spec.rb +132 -0
  76. data/spec/documentation/locale_spec.rb +367 -0
  77. data/spec/documentation/navigation_spec.rb +61 -3
  78. data/spec/documentation/oauth_spec.rb +42 -0
  79. data/spec/documentation/search_index_routes_spec.rb +8 -4
  80. data/spec/documentation/search_spec.rb +1 -1
  81. data/spec/documentation/shared_pages_spec.rb +63 -2
  82. data/spec/documentation/validators_spec.rb +25 -0
  83. data/spec/extensions/exception_mailer_spec.rb +2 -2
  84. data/spec/packaging_spec.rb +68 -9
  85. data/spec/performance/event_baseline_spec.rb +1 -1
  86. data/spec/performance/source_versions_spec.rb +111 -2
  87. metadata +24 -11
  88. /data/doc/{create-client.md → en/create-client.md} +0 -0
  89. /data/doc/{documentation-oauth.md → en/documentation-oauth.md} +0 -0
  90. /data/doc/{json-schema.html → en/json-schema.html} +0 -0
  91. /data/doc/{typed-input-validation.md → en/typed-input-validation.md} +0 -0
@@ -1,9 +1,14 @@
1
1
  require 'json'
2
2
  require 'uri'
3
+ require 'digest/sha2'
3
4
 
4
5
  module HaveAPI
5
6
  # HTML navigation is derived only from this request's authorized description.
6
7
  module DocumentationHelpers
8
+ ASSET_DIGESTS = %w[js/documentation.js css/documentation.css].to_h do |asset|
9
+ [asset, Digest::SHA256.file(File.join(__dir__, 'public', asset)).hexdigest.freeze]
10
+ end.freeze
11
+
7
12
  def documentation_identity_name
8
13
  settings.api_server.documentation_name || request.host_with_port
9
14
  end
@@ -11,13 +16,13 @@ module HaveAPI
11
16
  def documentation_document_title
12
17
  page = @documentation_title || if @v
13
18
  case @documentation_page
14
- when 'authentication' then 'Authentication'
15
- when 'resources' then 'Resources'
19
+ when 'authentication' then documentation_t('labels.authentication')
20
+ when 'resources' then documentation_t('labels.resources')
16
21
  when 'resource' then @documentation_resource[:key]
17
- else 'Overview'
22
+ else documentation_t('labels.overview')
18
23
  end
19
24
  else
20
- 'API versions'
25
+ documentation_t('labels.api_versions')
21
26
  end
22
27
  version = @v && !@documentation_title ? "API v#{@v}" : nil
23
28
  [page, version, documentation_identity_name].compact.join(' · ')
@@ -59,6 +64,12 @@ module HaveAPI
59
64
  documentation_search_query
60
65
  @v = version
61
66
  documentation_authenticated?(version)
67
+ with_documentation_locale do
68
+ render_localized_version_documentation(version)
69
+ end
70
+ end
71
+
72
+ def render_localized_version_documentation(version)
62
73
  @help = settings.api_server.describe_version(Context.new(
63
74
  settings.api_server,
64
75
  version:, user: current_user, params:
@@ -66,6 +77,7 @@ module HaveAPI
66
77
  return render_documentation_search_index if documentation_search_index_request?
67
78
 
68
79
  select_documentation_page
80
+ documentation_example_selection
69
81
  @documentation_ui = true
70
82
  content_type 'text/html'
71
83
  erb :doc_layout, layout: :main_layout do
@@ -81,36 +93,36 @@ module HaveAPI
81
93
  def documentation_selection
82
94
  @documentation_selection ||= begin
83
95
  query = request.query_string
84
- halt 400, 'Invalid documentation selection' if query.match?(/%(?![0-9a-f]{2})/i)
96
+ halt 400, documentation_t('errors.selection') if query.match?(/%(?![0-9a-f]{2})/i)
85
97
  pairs = URI.decode_www_form(query)
86
98
  selected = pairs.select { |key, _| key.match?(/\A(?:page|resource)(?:\[|\z)/) }
87
99
  unless selected.all? { |key, value| %w[page resource].include?(key) && value.valid_encoding? } &&
88
100
  selected.map(&:first).uniq.length == selected.length && selected.length <= 1
89
- halt 400, 'Invalid documentation selection'
101
+ halt 400, documentation_t('errors.selection')
90
102
  end
91
103
  selected.to_h
92
104
  end
93
105
  rescue ArgumentError
94
- halt 400, 'Invalid documentation selection'
106
+ halt 400, documentation_t('errors.selection')
95
107
  end
96
108
 
97
109
  def select_documentation_page
98
110
  selection = documentation_selection
99
111
  @documentation_page = selection.fetch('page', 'overview')
100
112
  unless %w[overview authentication resources].include?(@documentation_page)
101
- halt 400, 'Invalid documentation selection'
113
+ halt 400, documentation_t('errors.selection')
102
114
  end
103
115
  if @documentation_page == 'resources' && !documentation_search_query.empty?
104
- @documentation_title = "Search API v#{@v}: #{documentation_search_query}"
116
+ @documentation_title = documentation_t('titles.search', version: @v, query: documentation_search_query)
105
117
  end
106
118
  return unless selection.has_key?('resource')
107
119
 
108
120
  key = selection['resource']
109
121
  unless key.match?(/\A[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*\z/)
110
- halt 400, 'Invalid documentation selection'
122
+ halt 400, documentation_t('errors.selection')
111
123
  end
112
124
  @documentation_resource = documentation_resources.find { |entry| entry[:key] == key }
113
- halt 404, 'Documentation resource not found' unless @documentation_resource
125
+ halt 404, documentation_t('errors.resource_not_found') unless @documentation_resource
114
126
  @documentation_page = 'resource'
115
127
  end
116
128
 
@@ -153,8 +165,8 @@ module HaveAPI
153
165
  end
154
166
 
155
167
  def documentation_action_sections(info, resource_path:, action_name:, authentication: nil)
156
- sections = { input: 'Input parameters', output: 'Output parameters' }
157
- sections[:examples] = 'Examples' unless info[:examples].empty?
168
+ sections = { input: documentation_t('labels.input_parameters'), output: documentation_t('labels.output_parameters') }
169
+ sections[:examples] = documentation_t('labels.examples') unless info[:examples].empty?
158
170
  sections.map do |section, label|
159
171
  { id: documentation_reference_anchor(resource_path:, action_name:, authentication:, section:), label: }
160
172
  end
@@ -169,16 +181,81 @@ module HaveAPI
169
181
  end
170
182
 
171
183
  def documentation_permalink(id, label)
172
- "<a class=\"docs-permalink\" href=\"##{escape_html(id)}\" aria-label=\"#{escape_html("Link to #{label}")}\"><span aria-hidden=\"true\">#</span></a>"
184
+ "<a class=\"docs-permalink\" href=\"##{escape_html(id)}\" aria-label=\"#{escape_html(documentation_t('accessibility.link_to', label:))}\"><span aria-hidden=\"true\">#</span></a>"
185
+ end
186
+
187
+ def documentation_example_selection
188
+ return @documentation_example_selection if defined?(@documentation_example_selection)
189
+
190
+ keys = %w[example_language example_client example_detail]
191
+ pairs = URI.decode_www_form(request.query_string, Encoding::ASCII_8BIT).map do |key, value|
192
+ [key.force_encoding(Encoding::UTF_8), value.force_encoding(Encoding::UTF_8)]
193
+ end
194
+ selected = pairs.select { |key, _| key.match?(/\Aexample_(?:language|client|detail)(?:\[|\z)/) }
195
+ unless selected.all? { |key, value| keys.include?(key) && key.valid_encoding? && value.valid_encoding? } &&
196
+ selected.map(&:first).uniq.length == selected.length
197
+ halt 400, documentation_t('errors.selection')
198
+ end
199
+ requested = selected.to_h
200
+ clients = documentation_clients
201
+ client = clients.find { |entry| entry.id == requested['example_client'] }
202
+ language = if requested.has_key?('example_language')
203
+ requested['example_language']
204
+ else
205
+ client&.language
206
+ end
207
+ group = clients.select { |entry| entry.language == language }
208
+ if group.empty?
209
+ language = clients.first&.language
210
+ group = clients.select { |entry| entry.language == language }
211
+ end
212
+ client = group.find { |entry| entry.id == requested['example_client'] } || group.first
213
+ @documentation_example_selection = {
214
+ client:, language:, detail: requested['example_detail'] == 'thorough' ? 'thorough' : 'simple',
215
+ explicit: selected.any?
216
+ }
217
+ rescue ArgumentError
218
+ halt 400, documentation_t('errors.selection')
219
+ end
220
+
221
+ def documentation_example_detail(levels)
222
+ requested = documentation_example_selection[:detail]
223
+ levels.include?(requested) ? requested : 'simple'
224
+ end
225
+
226
+ def documentation_example_form_parameters
227
+ parameters = documentation_selection.to_a
228
+ parameters << ['q', documentation_search_query] unless documentation_search_query.empty?
229
+ parameters << ['doc_lang', documentation_locale_override] if documentation_locale_override
230
+ parameters
173
231
  end
174
232
 
175
233
  def documentation_client_metadata
176
234
  documentation_clients.map do |client|
177
- { id: client.id, language: client.language, language_label: client.language_label, variant: client.variant,
235
+ { id: client.id, language: client.language, language_label: client.language_label, variant: documentation_client_variant(client),
178
236
  details: (@documentation_page_details || {}).fetch(client.id, [:simple]).map(&:to_s) }
179
237
  end
180
238
  end
181
239
 
240
+ def documentation_client_label(client)
241
+ case client.id
242
+ when 'go' then documentation_t('generated.labels.go', default: client.label)
243
+ when 'rust-async' then documentation_t('generated.labels.rust_async', default: client.label)
244
+ when 'rust-blocking' then documentation_t('generated.labels.rust_blocking', default: client.label)
245
+ when 'typescript-generated' then documentation_t('generated.labels.typescript', default: client.label)
246
+ else client.label
247
+ end
248
+ end
249
+
250
+ def documentation_client_variant(client)
251
+ case client.id
252
+ when 'rust-async' then documentation_t('generated.variants.async', default: client.variant)
253
+ when 'rust-blocking' then documentation_t('generated.variants.blocking', default: client.variant)
254
+ when 'typescript-generated' then documentation_t('generated.variants.api_client', default: client.variant)
255
+ else client.variant
256
+ end
257
+ end
258
+
182
259
  def documentation_json(value)
183
260
  JSON.generate(value).gsub('<', '\\u003c').gsub('>', '\\u003e').gsub('&', '\\u0026')
184
261
  end
@@ -0,0 +1,65 @@
1
+ require 'uri'
2
+
3
+ module HaveAPI
4
+ # Documentation has its own preference; API request locale remains independent.
5
+ module DocumentationLocale
6
+ LOCALES = %w[en cs].freeze
7
+ COOKIE = 'haveapi.docs.locale'.freeze
8
+
9
+ def documentation_locale
10
+ @documentation_locale ||= begin
11
+ explicit = documentation_locale_override
12
+ saved = request.cookies[COOKIE]
13
+ explicit || (saved if LOCALES.include?(saved)) ||
14
+ HaveAPI::I18n.accept_language(request.env['HTTP_ACCEPT_LANGUAGE'], LOCALES)&.to_s || 'en'
15
+ end
16
+ end
17
+
18
+ def documentation_locale_override
19
+ pairs = URI.decode_www_form(request.query_string)
20
+ values = pairs.select { |pair| pair.first == 'doc_lang' }
21
+ values.first.last if values.length == 1 && LOCALES.include?(values.first.last)
22
+ rescue ArgumentError
23
+ nil
24
+ end
25
+
26
+ def with_documentation_locale(persist: true)
27
+ previous = ::I18n.locale
28
+ @documentation_static_messages = {}
29
+ settings.api_server.activate_locale(documentation_locale)
30
+ add_vary_header('Accept-Language')
31
+ add_vary_header('Cookie')
32
+ if persist && documentation_locale_override && !documentation_search_index_request?
33
+ response.set_cookie(COOKIE, value: documentation_locale, path: root,
34
+ max_age: 31_536_000, same_site: :lax, secure: base_url.start_with?('https:'))
35
+ end
36
+ yield
37
+ ensure
38
+ @documentation_static_messages = nil
39
+ ::I18n.locale = previous
40
+ end
41
+
42
+ def documentation_language_parameters
43
+ URI.decode_www_form(request.query_string).reject { |pair| pair.first == 'doc_lang' }
44
+ rescue ArgumentError
45
+ []
46
+ end
47
+
48
+ def documentation_t(key, default: nil, **values)
49
+ if @documentation_static_messages && key.is_a?(String) && default.nil? && values.empty?
50
+ memo = (@documentation_static_messages[::I18n.locale] ||= {})
51
+ cached = memo[key]
52
+ return cached.dup if cached
53
+ end
54
+
55
+ full_key = "haveapi.documentation.#{key}"
56
+ fallback = ::I18n.t(full_key, locale: :en, default:, **values)
57
+ result = ::I18n.locale == :en ? fallback : ::I18n.t(full_key, default: fallback, **values)
58
+
59
+ # Own the stored bytes; neither the first result nor later hit copies can
60
+ # mutate labels used elsewhere in this request. Dynamic lookups stay live.
61
+ memo[key] = result.dup.freeze if memo && result.is_a?(String)
62
+ result
63
+ end
64
+ end
65
+ end
@@ -135,9 +135,9 @@ module HaveAPI
135
135
  @purposes = %w[pending session].to_h { |kind| [kind, "haveapi.docs.#{binding}.#{kind}"] }.freeze
136
136
  @names = %w[pending session].to_h { |kind| [kind, "haveapi_docs_#{Digest::SHA256.hexdigest(@path)[0, 12]}_#{kind}"] }.freeze
137
137
  that = self
138
- sinatra.get(@routes.fetch('login')) { that.login(self) }
139
- sinatra.get(@routes.fetch('callback')) { that.callback(self) }
140
- sinatra.post(@routes.fetch('logout')) { that.logout(self) }
138
+ sinatra.get(@routes.fetch('login')) { with_documentation_locale(persist: false) { that.login(self) } }
139
+ sinatra.get(@routes.fetch('callback')) { with_documentation_locale(persist: false) { that.callback(self) } }
140
+ sinatra.post(@routes.fetch('logout')) { with_documentation_locale(persist: false) { that.logout(self) } }
141
141
  end
142
142
 
143
143
  def authenticate(handler, version)
@@ -153,7 +153,7 @@ module HaveAPI
153
153
  handler.instance_variable_set(:@documentation_oauth_csrf, session.fetch('csrf'))
154
154
  else
155
155
  clear_cookie(handler, 'session')
156
- handler.instance_variable_set(:@documentation_oauth_message, 'Your documentation session ended. Sign in again.')
156
+ handler.instance_variable_set(:@documentation_oauth_message, HaveAPI.message('haveapi.documentation.oauth.session_ended'))
157
157
  end
158
158
  user
159
159
  end
@@ -228,7 +228,7 @@ module HaveAPI
228
228
  session = read_cookie(handler, 'session')
229
229
  csrf = handler.params['csrf']
230
230
  unless handler.request.env['HTTP_ORIGIN'] == @origin && session && nonce?(csrf) && secure_equal?(csrf, session['csrf'])
231
- handler.halt(403, 'Invalid documentation sign-out request')
231
+ handler.halt(403, handler.documentation_t('oauth.sign_out_invalid'))
232
232
  end
233
233
  clear_cookie(handler, 'session')
234
234
  clear_cookie(handler, 'pending')
@@ -238,8 +238,8 @@ module HaveAPI
238
238
  @transport.post(@endpoints.fetch(:revoke), parameters)
239
239
  rescue Failure
240
240
  handler.halt(502, { 'content-type' => 'text/html; charset=utf-8' },
241
- '<p>Local sign-out succeeded, but the server session may remain until it expires.</p>' \
242
- "<p><a href=\"#{CGI.escapeHTML(destination_url('version' => @server.default_version.to_s))}\">Return to documentation</a></p>")
241
+ "<p>#{CGI.escapeHTML(handler.documentation_t('oauth.sign_out_incomplete'))}</p>" \
242
+ "<p><a href=\"#{CGI.escapeHTML(destination_url('version' => @server.default_version.to_s))}\">#{CGI.escapeHTML(handler.documentation_t('oauth.return'))}</a></p>")
243
243
  end
244
244
  if switch_user
245
245
  handler.redirect("#{@origin}#{@routes.fetch('login')}?#{URI.encode_www_form(version: @server.default_version.to_s)}", 303)
@@ -274,7 +274,7 @@ module HaveAPI
274
274
  end
275
275
 
276
276
  def require_origin(handler)
277
- handler.halt(400, 'Documentation sign-in requires its configured HTTPS origin') unless documentation_origin?(handler)
277
+ handler.halt(400, handler.documentation_t('oauth.origin_required')) unless documentation_origin?(handler)
278
278
  end
279
279
 
280
280
  def protect_response(handler, referrer_policy: 'no-referrer')
@@ -339,12 +339,12 @@ module HaveAPI
339
339
 
340
340
  unless @switch_user && selected == [%w[switch_user 1]] && parameters['switch_user'] == '1' &&
341
341
  [nil, 'application/x-www-form-urlencoded'].include?(request.media_type)
342
- handler.halt(403, 'Invalid documentation switch request')
342
+ handler.halt(403, handler.documentation_t('oauth.switch_invalid'))
343
343
  end
344
344
 
345
345
  true
346
346
  rescue ArgumentError
347
- handler.halt(403, 'Invalid documentation switch request')
347
+ handler.halt(403, handler.documentation_t('oauth.switch_invalid'))
348
348
  end
349
349
 
350
350
  def nonce?(value)
@@ -392,7 +392,7 @@ module HaveAPI
392
392
  payload
393
393
  rescue ActiveSupport::MessageEncryptor::InvalidMessage, Failure, ArgumentError, TypeError
394
394
  clear_cookie(handler, kind)
395
- handler.instance_variable_set(:@documentation_oauth_message, 'Your documentation session ended. Sign in again.') if kind == 'session'
395
+ handler.instance_variable_set(:@documentation_oauth_message, HaveAPI.message('haveapi.documentation.oauth.session_ended')) if kind == 'session'
396
396
  nil
397
397
  end
398
398
 
@@ -405,7 +405,7 @@ module HaveAPI
405
405
  protect_response(handler)
406
406
  link = "#{@origin}#{@routes.fetch('login')}"
407
407
  handler.halt(400, { 'content-type' => 'text/html; charset=utf-8' },
408
- "<p>Documentation sign-in failed. <a href=\"#{CGI.escapeHTML(link)}\">Try again</a>.</p>")
408
+ "<p>#{CGI.escapeHTML(handler.documentation_t('oauth.sign_in_failed'))} <a href=\"#{CGI.escapeHTML(link)}\">#{CGI.escapeHTML(handler.documentation_t('oauth.try_again'))}</a>.</p>")
409
409
  end
410
410
  end
411
411
  end
@@ -139,7 +139,7 @@ module HaveAPI
139
139
 
140
140
  def invalid_documentation_search_query
141
141
  headers 'Cache-Control' => 'private, no-store'
142
- halt 400, 'Invalid documentation search query'
142
+ halt 400, documentation_t('errors.search_query')
143
143
  end
144
144
 
145
145
  def documentation_search_form_options(id:)
@@ -159,7 +159,7 @@ module HaveAPI
159
159
  end
160
160
 
161
161
  def documentation_search_index_url(version:, key:)
162
- "#{url(settings.api_server.version_prefix(version), false)}?page=resources&search_index=#{key}"
162
+ "#{url(settings.api_server.version_prefix(version), false)}?page=resources&search_index=#{key}&doc_lang=#{documentation_locale}"
163
163
  end
164
164
 
165
165
  def documentation_search_index_request?
@@ -171,7 +171,7 @@ module HaveAPI
171
171
  index = documentation_search_index
172
172
  requested = documentation_search_parameters[:index]
173
173
  if requested != 'current' && requested != index.key
174
- halt 409, 'Search has changed. Reload this page or submit to open current results.'
174
+ halt 409, documentation_t('search.changed')
175
175
  end
176
176
 
177
177
  content_type 'text/html', charset: 'utf-8'
@@ -215,8 +215,8 @@ module HaveAPI
215
215
  else
216
216
  context << "#{result[:resource_path].join('.')} · #{result[:action_name]}"
217
217
  end
218
- context << "#{result[:direction].to_s.capitalize} · #{result[:type]}" if result[:kind] == :parameter
219
- context << "Authentication: #{result[:authentication]}" if result[:authentication]
218
+ context << "#{documentation_t("browser.#{result[:direction]}")} · #{result[:type]}" if result[:kind] == :parameter
219
+ context << documentation_t('browser.authentication', provider: result[:authentication]) if result[:authentication]
220
220
  context << result[:label] if result[:kind] != :resource && result[:label] && result[:label] != result[:name]
221
221
  context
222
222
  end
@@ -1,5 +1,6 @@
1
1
  require 'cgi'
2
2
  require 'json'
3
+ require 'haveapi/i18n'
3
4
 
4
5
  module HaveAPI
5
6
  # Presents already-authorized/localized metadata without changing validation.
@@ -10,31 +11,40 @@ module HaveAPI
10
11
 
11
12
  renderer = new(type)
12
13
  rules = validators.map { |name, options| renderer.rule(name, options) }.join
13
- %(<div class="docs-validators"><table class="docs-validator-table"><caption>Validation rules</caption><thead><tr><th scope="col">Rule</th><th scope="col">Details</th></tr></thead><tbody>#{rules}</tbody></table></div>)
14
+ %(<div class="docs-validators"><table class="docs-validator-table"><caption>#{renderer.text('caption')}</caption><thead><tr><th scope="col">#{renderer.text('rule')}</th><th scope="col">#{renderer.text('details')}</th></tr></thead><tbody>#{rules}</tbody></table></div>)
14
15
  end
15
16
 
16
17
  def initialize(type)
17
18
  @type = type
18
19
  end
19
20
 
21
+ # Escape catalog text before interpolating markup produced by this renderer.
22
+ # Validator metadata is always escaped by value/literal/html first.
23
+ def text(key, **values)
24
+ full_key = "haveapi.documentation.validators.#{key}"
25
+ fallback = ::I18n.t(full_key, locale: :en, skip_interpolation: true)
26
+ template = ::I18n.t(full_key, default: fallback, skip_interpolation: true)
27
+ format(html(template), **values)
28
+ end
29
+
20
30
  def rule(name, options)
21
31
  content, used = explanation(name.to_s, options)
22
32
  if content && options.is_a?(Hash)
23
33
  remaining = options.reject { |key, _| used.include?(key.to_s) || key.to_s == 'message' }
24
- content += "<p><strong>Validation error message:</strong> #{value(field(options, :message))}</p>" if option?(options, :message)
34
+ content += "<p><strong>#{text('message')}</strong> #{value(field(options, :message))}</p>" if option?(options, :message)
25
35
  unless remaining.empty?
26
- content += "<details><summary>Additional details</summary>#{data(remaining)}</details>"
36
+ content += "<details><summary>#{text('additional')}</summary>#{data(remaining)}</details>"
27
37
  end
28
38
  elsif !content
29
- content = "<p>Supplied validator metadata:</p>#{data(options)}"
39
+ content = "<p>#{text('metadata')}</p>#{data(options)}"
30
40
  end
31
41
  known = %w[present include exclude accept confirm number length format custom].include?(name.to_s)
32
- title = known ? name.to_s.capitalize : name.to_s
33
- %(<tr class="docs-validator"><th scope="row">#{html(title)}</th><td>#{content}</td></tr>)
42
+ title = known ? text("names.#{name}") : html(name)
43
+ %(<tr class="docs-validator"><th scope="row">#{title}</th><td>#{content}</td></tr>)
34
44
  end
35
45
 
36
46
  def explanation(name, options)
37
- return ["<p>Application-specific rule: #{html(options)}</p>", []] if name == 'custom' && options.is_a?(String)
47
+ return ["<p>#{text('custom', rule: html(options))}</p>", []] if name == 'custom' && options.is_a?(String)
38
48
  return unless options.is_a?(Hash)
39
49
  # Two differently typed keys must not silently collapse to one option.
40
50
  return if options.keys.map(&:to_s).uniq.length != options.length
@@ -45,20 +55,20 @@ module HaveAPI
45
55
  when 'accept'
46
56
  return unless option?(options, :value)
47
57
 
48
- ["<p>Must equal #{literal(field(options, :value))}.</p>", ['value']]
58
+ ["<p>#{text('accept', value: literal(field(options, :value)))}</p>", ['value']]
49
59
  when 'confirm'
50
60
  return unless field(options, :parameter).is_a?(String) || field(options, :parameter).is_a?(Symbol)
51
61
  return unless boolean?(field(options, :equal))
52
62
 
53
- relation = field(options, :equal) ? 'Must equal' : 'Must differ from'
54
- ["<p>#{relation} parameter <code>#{html(field(options, :parameter))}</code>.</p>", %w[parameter equal]]
63
+ relation = field(options, :equal) ? 'confirm_equal' : 'confirm_different'
64
+ ["<p>#{text(relation, parameter: "<code>#{html(field(options, :parameter))}</code>")}</p>", %w[parameter equal]]
55
65
  when 'number' then number(options)
56
66
  when 'length' then length(options)
57
67
  when 'format'
58
68
  return unless field(options, :rx).is_a?(String) && boolean?(field(options, :match))
59
69
 
60
- relation = field(options, :match) ? 'Must match' : 'Must not match'
61
- content = "<p>#{relation} this regular expression:</p><pre><code>#{html(field(options, :rx))}</code></pre>"
70
+ relation = field(options, :match) ? 'format_match' : 'format_no_match'
71
+ content = "<p>#{text(relation)}</p><pre><code>#{html(field(options, :rx))}</code></pre>"
62
72
  content += "<p>#{value(field(options, :description))}</p>" if option?(options, :description)
63
73
  [content, %w[rx match description]]
64
74
  end
@@ -67,22 +77,22 @@ module HaveAPI
67
77
  def presence(options)
68
78
  return unless boolean?(field(options, :empty))
69
79
 
70
- empty = field(options, :empty) ? 'Empty values are allowed.' : 'Must not be empty; strings must contain a non-whitespace character.'
71
- ["<p>Must be supplied and cannot be null. #{empty}</p>", ['empty']]
80
+ empty = field(options, :empty) ? 'empty_allowed' : 'empty_disallowed'
81
+ ["<p>#{text('present')} #{text(empty)}</p>", ['empty']]
72
82
  end
73
83
 
74
84
  def choices(name, options)
75
85
  values = field(options, :values)
76
86
  return unless values.is_a?(Array) || (name == 'include' && values.is_a?(Hash))
77
87
 
78
- label = name == 'include' ? 'Allowed values' : 'Disallowed values'
88
+ label = name == 'include' ? 'allowed' : 'disallowed'
79
89
  items = if values.is_a?(Hash)
80
90
  values.map { |machine, label_text| "<li>#{choice_literal(machine)}: #{value(label_text)}</li>" }
81
91
  else
82
92
  values.map { |machine| "<li>#{choice_literal(machine)}</li>" }
83
93
  end
84
- body = items.empty? ? '<p>The set is empty.</p>' : %(<ul class="docs-validator-values">#{items.join}</ul>)
85
- ["<p>#{label}:</p>#{body}", ['values']]
94
+ body = items.empty? ? "<p>#{text('empty_set')}</p>" : %(<ul class="docs-validator-values">#{items.join}</ul>)
95
+ ["<p>#{text(label)}:</p>#{body}", ['values']]
86
96
  end
87
97
 
88
98
  def number(options)
@@ -90,18 +100,18 @@ module HaveAPI
90
100
  return unless numeric.all? { |key| !option?(options, key) || finite_number?(field(options, key)) }
91
101
  return unless %w[odd even].all? { |key| !option?(options, key) || boolean?(field(options, key)) }
92
102
 
93
- lines = ['Must be a number after parameter type conversion.']
94
- lines << "Minimum: #{literal(field(options, :min))} (inclusive)." if option?(options, :min)
95
- lines << "Maximum: #{literal(field(options, :max))} (inclusive)." if option?(options, :max)
103
+ lines = [text('number')]
104
+ lines << text('minimum', value: literal(field(options, :min))) if option?(options, :min)
105
+ lines << text('maximum', value: literal(field(options, :max))) if option?(options, :max)
96
106
  if option?(options, :step)
97
107
  start = option?(options, :min) ? field(options, :min) : 0
98
- lines << "Step: #{literal(field(options, :step))}, measured from #{literal(start)}."
108
+ lines << text('step', value: literal(field(options, :step)), start: literal(start))
99
109
  end
100
- lines << "Must be a multiple of #{literal(field(options, :mod))}, measured from zero." if option?(options, :mod)
101
- lines << 'Must be odd.' if field(options, :odd) == true
102
- lines << 'Must be even.' if field(options, :even) == true
103
- lines << 'Odd-only restriction: disabled.' if option?(options, :odd) && field(options, :odd) == false
104
- lines << 'Even-only restriction: disabled.' if option?(options, :even) && field(options, :even) == false
110
+ lines << text('multiple', value: literal(field(options, :mod))) if option?(options, :mod)
111
+ lines << text('odd') if field(options, :odd) == true
112
+ lines << text('even') if field(options, :even) == true
113
+ lines << text('odd_disabled') if option?(options, :odd) && field(options, :odd) == false
114
+ lines << text('even_disabled') if option?(options, :even) && field(options, :even) == false
105
115
  ["<ul>#{lines.map { |line| "<li>#{line}</li>" }.join}</ul>", numeric + %w[odd even]]
106
116
  end
107
117
 
@@ -112,16 +122,16 @@ module HaveAPI
112
122
  return if option?(options, :equals) && (option?(options, :min) || option?(options, :max))
113
123
 
114
124
  unit = if %w[String Text].include?(@type)
115
- ' characters'
125
+ text('characters')
116
126
  elsif %w[List Array].include?(@type)
117
- ' items'
127
+ text('items')
118
128
  else
119
129
  ''
120
130
  end
121
131
  lines = []
122
- lines << "Length must equal #{literal(field(options, :equals))}#{unit}." if option?(options, :equals)
123
- lines << "Minimum length: #{literal(field(options, :min))}#{unit} (inclusive)." if option?(options, :min)
124
- lines << "Maximum length: #{literal(field(options, :max))}#{unit} (inclusive)." if option?(options, :max)
132
+ lines << text('length_equal', value: literal(field(options, :equals)), unit:) if option?(options, :equals)
133
+ lines << text('length_minimum', value: literal(field(options, :min)), unit:) if option?(options, :min)
134
+ lines << text('length_maximum', value: literal(field(options, :max)), unit:) if option?(options, :max)
125
135
  ["<ul>#{lines.map { |line| "<li>#{line}</li>" }.join}</ul>", keys]
126
136
  end
127
137
 
@@ -152,7 +162,7 @@ module HaveAPI
152
162
  end
153
163
 
154
164
  def literal(value)
155
- json_value?(value) ? "<code>#{html(JSON.generate(value))}</code>" : "<span>Ruby value: <code>#{html(value.inspect)}</code></span>"
165
+ json_value?(value) ? "<code>#{html(JSON.generate(value))}</code>" : "<span>#{text('ruby_value')}: <code>#{html(value.inspect)}</code></span>"
156
166
  end
157
167
 
158
168
  def choice_literal(value)
@@ -167,7 +177,7 @@ module HaveAPI
167
177
  label, text = if json_value?(value)
168
178
  ['JSON', JSON.pretty_generate(value)]
169
179
  else
170
- ['Ruby value', value.inspect]
180
+ [text('ruby_value'), value.inspect]
171
181
  end
172
182
  "<p>#{label}:</p><pre><code>#{html(text)}</code></pre>"
173
183
  end