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
@@ -0,0 +1,367 @@
1
+ require 'spec_helper'
2
+ require 'cgi'
3
+ require 'ripper'
4
+ require 'open3'
5
+
6
+ RSpec.describe HaveAPI::DocumentationLocale do
7
+ api do
8
+ define_resource(:Thing) do
9
+ version 1
10
+ auth false
11
+ desc HaveAPI.message('documentation_locale_spec.resource')
12
+ define_action(:Show) do
13
+ route ''
14
+ http_method :get
15
+ input { string :query }
16
+ output { string :language }
17
+ authorize { allow }
18
+ def exec
19
+ { language: ::I18n.locale.to_s }
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ let(:server) do
26
+ HaveAPI::Server.new(get_opt(:api_module)).tap do |api|
27
+ api.use_version(1)
28
+ api.default_locale = :cs
29
+ api.locale_header = 'X-API-Language'
30
+ api.parameter_i18n_scope = 'documentation_locale_spec'
31
+ api.locale { :cs }
32
+ api.mount('/api/')
33
+ end
34
+ end
35
+ let(:app) { server.app }
36
+
37
+ before do
38
+ ::I18n.backend.store_translations(:en, documentation_locale_spec: {
39
+ resource: 'English resource',
40
+ attributes: { query: { label: 'English query' } }
41
+ })
42
+ ::I18n.backend.store_translations(:cs, documentation_locale_spec: {
43
+ resource: 'Český prostředek',
44
+ attributes: { query: { label: 'Český dotaz' } }
45
+ })
46
+ end
47
+
48
+ describe '#documentation_t' do
49
+ let(:helper) { Object.new.extend(described_class) }
50
+
51
+ it 'preserves translations, interpolation and defaults across repeated locale changes' do
52
+ ::I18n.backend.store_translations(:en, haveapi: { documentation: {
53
+ locale_spec_present: 'English %{name}', locale_spec_fallback: 'Fallback %{name}'
54
+ } })
55
+ ::I18n.backend.store_translations(:cs, haveapi: { documentation: { locale_spec_present: 'Česky %{name}' } })
56
+ %i[en cs en].each do |locale|
57
+ ::I18n.with_locale(locale) do
58
+ expected = locale == :en ? 'English <&>' : 'Česky <&>'
59
+ expect(helper.documentation_t('locale_spec_present', name: '<&>')).to eq(expected)
60
+ expect(helper.documentation_t('locale_spec_present', default: 'Ignored', name: '<&>')).to eq(expected)
61
+ expect(helper.documentation_t('locale_spec_fallback', name: 'input')).to eq('Fallback input')
62
+ expect(helper.documentation_t('locale_spec_absent', default: 'Default %{name}', name: 'input')).to eq('Default input')
63
+ expect(helper.documentation_t('locale_spec_absent')).to be_nil
64
+ expect(helper.documentation_t('browser')).to eq(::I18n.t('haveapi.documentation.browser'))
65
+ end
66
+ end
67
+ end
68
+
69
+ it 'reuses the resolved English fallback without translating the same key twice' do
70
+ allow(::I18n).to receive(:t).and_call_original
71
+ ::I18n.with_locale(:en) do
72
+ expect(helper.documentation_t('labels.language')).to eq('Language')
73
+ end
74
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.labels.language', locale: :en, default: nil).once
75
+ expect(::I18n).to have_received(:t).once
76
+ end
77
+
78
+ context 'with a documentation render scope' do
79
+ let(:helper) do
80
+ Class.new do
81
+ attr_accessor :scope_locale
82
+
83
+ def settings = self
84
+ def api_server = self
85
+ def documentation_locale = scope_locale
86
+ def activate_locale(locale) = (::I18n.locale = locale)
87
+ def add_vary_header(_header); end
88
+ end.include(described_class).new.tap { |instance| instance.scope_locale = :en }
89
+ end
90
+
91
+ before do
92
+ ::I18n.backend.store_translations(:en, haveapi: { documentation: {
93
+ memo_static: 'English label', memo_interpolated: 'Hello %{name}',
94
+ memo_fallback: 'English fallback', memo_hash: { leaf: 'Leaf' }, memo_number: 7
95
+ } })
96
+ ::I18n.backend.store_translations(:cs, haveapi: { documentation: { memo_static: 'Český štítek' } })
97
+ allow(::I18n).to receive(:t).and_call_original
98
+ end
99
+
100
+ it 'memoizes static strings by active locale, including explicit nil defaults and English fallback' do
101
+ helper.with_documentation_locale(persist: false) do
102
+ %i[en cs en].each do |locale|
103
+ ::I18n.with_locale(locale) do
104
+ expected = locale == :en ? 'English label' : 'Český štítek'
105
+ expect(helper.documentation_t('memo_static', default: nil)).to eq(expected)
106
+ expect(helper.documentation_t('memo_static')).to eq(expected)
107
+ end
108
+ end
109
+ ::I18n.with_locale(:cs) do
110
+ 2.times { expect(helper.documentation_t('memo_fallback')).to eq('English fallback') }
111
+ end
112
+ end
113
+
114
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_static', locale: :en, default: nil).twice
115
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_static', default: 'English label').once
116
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_fallback', locale: :en, default: nil).once
117
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_fallback', default: 'English fallback').once
118
+ end
119
+
120
+ it 'isolates caller mutation of both the initial result and later hit copies' do
121
+ helper.with_documentation_locale(persist: false) do
122
+ first = helper.documentation_t('memo_static')
123
+ first.replace('Changed first result')
124
+ hit = helper.documentation_t('memo_static')
125
+ expect(hit).to eq('English label')
126
+ expect(hit).not_to be_frozen
127
+ hit.replace('Changed hit')
128
+ expect(helper.documentation_t('memo_static')).to eq('English label')
129
+ end
130
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_static', locale: :en, default: nil).once
131
+ end
132
+
133
+ it 'keeps explicit defaults, interpolation and non-String keys on the original lookup path' do
134
+ helper.with_documentation_locale(persist: false) do
135
+ 2.times do
136
+ expect(helper.documentation_t('memo_default_only', default: 'Explicit default')).to eq('Explicit default')
137
+ expect(helper.documentation_t(:memo_static)).to eq('English label')
138
+ end
139
+ %w[first second].each do |name|
140
+ expect(helper.documentation_t('memo_interpolated', name:)).to eq("Hello #{name}")
141
+ end
142
+ end
143
+
144
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_default_only', locale: :en, default: 'Explicit default').twice
145
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_static', locale: :en, default: nil).twice
146
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_interpolated', locale: :en, default: nil, name: 'first').once
147
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_interpolated', locale: :en, default: nil, name: 'second').once
148
+ end
149
+
150
+ it 'does not retain missing, hash or other non-String results' do
151
+ helper.with_documentation_locale(persist: false) do
152
+ 2.times do
153
+ expect(helper.documentation_t('memo_late')).to be_nil
154
+ expect(helper.documentation_t('memo_hash')).to eq(leaf: 'Leaf')
155
+ expect(helper.documentation_t('memo_number')).to eq(7)
156
+ end
157
+ ::I18n.backend.store_translations(:en, haveapi: { documentation: { memo_late: 'Now available' } })
158
+ expect(helper.documentation_t('memo_late')).to eq('Now available')
159
+ end
160
+
161
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_late', locale: :en, default: nil).exactly(3).times
162
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_hash', locale: :en, default: nil).twice
163
+ expect(::I18n).to have_received(:t).with('haveapi.documentation.memo_number', locale: :en, default: nil).twice
164
+ end
165
+
166
+ it 'starts fresh on each render and leaves no memo outside that scope' do
167
+ helper.with_documentation_locale(persist: false) { expect(helper.documentation_t('memo_static')).to eq('English label') }
168
+ ::I18n.backend.store_translations(:en, haveapi: { documentation: { memo_static: 'Next render' } })
169
+ helper.with_documentation_locale(persist: false) { expect(helper.documentation_t('memo_static')).to eq('Next render') }
170
+ ::I18n.backend.store_translations(:en, haveapi: { documentation: { memo_static: 'Outside render' } })
171
+ ::I18n.with_locale(:en) { expect(helper.documentation_t('memo_static')).to eq('Outside render') }
172
+ end
173
+
174
+ it 'clears nested scopes after errors and restores the active locale' do
175
+ previous = ::I18n.locale
176
+ helper.scope_locale = :cs
177
+ helper.with_documentation_locale(persist: false) do
178
+ expect(helper.documentation_t('memo_static')).to eq('Český štítek')
179
+ helper.scope_locale = :en
180
+ expect do
181
+ helper.with_documentation_locale(persist: false) do
182
+ expect(helper.documentation_t('memo_static')).to eq('English label')
183
+ raise 'nested rendering failed'
184
+ end
185
+ end.to raise_error(RuntimeError, 'nested rendering failed')
186
+ expect(::I18n.locale).to eq(:cs)
187
+ ::I18n.backend.store_translations(:cs, haveapi: { documentation: { memo_static: 'After inner scope' } })
188
+ expect(helper.documentation_t('memo_static')).to eq('After inner scope')
189
+ end
190
+ expect(::I18n.locale).to eq(previous)
191
+
192
+ helper.scope_locale = :en
193
+ expect do
194
+ helper.with_documentation_locale(persist: false) do
195
+ expect(helper.documentation_t('memo_static')).to eq('English label')
196
+ raise 'outer rendering failed'
197
+ end
198
+ end.to raise_error(RuntimeError, 'outer rendering failed')
199
+ ::I18n.backend.store_translations(:en, haveapi: { documentation: { memo_static: 'After aborted render' } })
200
+ ::I18n.with_locale(:en) { expect(helper.documentation_t('memo_static')).to eq('After aborted render') }
201
+ expect(::I18n.locale).to eq(previous)
202
+ end
203
+
204
+ it 'does not memoize a failed lookup' do
205
+ calls = 0
206
+ allow(::I18n).to receive(:t).with('haveapi.documentation.memo_failure', locale: :en, default: nil) do
207
+ calls += 1
208
+ raise 'lookup failed' if calls == 1
209
+
210
+ 'Recovered label'
211
+ end
212
+ helper.with_documentation_locale(persist: false) do
213
+ expect { helper.documentation_t('memo_failure') }.to raise_error(RuntimeError, 'lookup failed')
214
+ 2.times { expect(helper.documentation_t('memo_failure')).to eq('Recovered label') }
215
+ end
216
+ expect(calls).to eq(2)
217
+ end
218
+ end
219
+ end
220
+
221
+ def document_language
222
+ last_response.body[/<html lang="([^"]+)"/, 1]
223
+ end
224
+
225
+ def index_url
226
+ CGI.unescapeHTML(last_response.body[/data-search-index-url="([^"]+)"/, 1])
227
+ end
228
+
229
+ it 'negotiates browser preferences independently of API configuration without saving detection' do
230
+ { 'CS-cz, en;q=0.8' => 'cs', 'de, en-GB;q=0.8,cs;q=0.5' => 'en',
231
+ 'en;q=0,cs-CZ;q=0.7' => 'cs', 'en-US,cs;q=0.9' => 'en',
232
+ 'de,*;q=0.5' => 'en', '*' => 'en', '' => 'en' }.each do |browser, expected|
233
+ get '/api/v1/', {}, 'HTTP_ACCEPT_LANGUAGE' => browser, 'HTTP_X_API_LANGUAGE' => 'cs'
234
+ expect(last_response).to be_ok
235
+ expect(document_language).to eq(expected), browser
236
+ expect(last_response.headers['Set-Cookie'].to_s).not_to include('haveapi.docs.locale')
237
+ end
238
+ end
239
+
240
+ it 'saves only explicit choices and gives a valid query priority over the saved language' do
241
+ get '/api/v1/?resource=thing&doc_lang=cs', {}, 'HTTP_ACCEPT_LANGUAGE' => 'en'
242
+ expect(document_language).to eq('cs')
243
+ cookie = last_response.headers['Set-Cookie'].to_s
244
+ expect(cookie).to include('haveapi.docs.locale=cs', 'path=/api/', 'samesite=lax')
245
+ expect(cookie.downcase).not_to include('domain=', 'secure')
246
+ get '/api/v1/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'en'
247
+ expect(document_language).to eq('cs')
248
+ get '/api/v1/?doc_lang=en', {}, 'HTTP_ACCEPT_LANGUAGE' => 'cs'
249
+ expect(document_language).to eq('en')
250
+ expect(last_response.headers['Set-Cookie'].to_s).to include('haveapi.docs.locale=en')
251
+ end
252
+
253
+ it 'uses a secure host-only cookie over HTTPS and works without returning that cookie' do
254
+ get 'https://example.org/api/v1/?doc_lang=cs'
255
+ expect(document_language).to eq('cs')
256
+ expect(last_response.headers['Set-Cookie'].to_s).to include('secure')
257
+ clear_cookies
258
+ get '/api/v1/?doc_lang=cs'
259
+ expect(document_language).to eq('cs')
260
+ clear_cookies
261
+ get '/api/v1/'
262
+ expect(document_language).to eq('en')
263
+ end
264
+
265
+ it 'ignores unsupported, nested and duplicate preferences' do
266
+ set_cookie 'haveapi.docs.locale=unknown; path=/api/'
267
+ %w[doc_lang=unknown doc_lang=auto doc_lang[]=cs doc_lang=cs&doc_lang=en].each do |query|
268
+ get "/api/v1/?#{query}", {}, 'HTTP_ACCEPT_LANGUAGE' => 'en'
269
+ expect(document_language).to eq('en')
270
+ expect(last_response.headers['Set-Cookie'].to_s).not_to include('haveapi.docs.locale')
271
+ end
272
+ end
273
+
274
+ it 'localizes metadata and controls, retaining the exact language choices above Theme' do
275
+ get '/api/v1/?resource=thing&doc_lang=cs&q=dotaz'
276
+ body = last_response.body
277
+ expect(body).to include('Český prostředek', 'Český dotaz', 'data-documentation-language', '>English</option>', '>Česky</option>')
278
+ expect(body.index('data-documentation-language')).to be < body.index('docs-theme-label')
279
+ expect(body).to include('name="resource" value="thing"', 'name="q" value="dotaz"')
280
+ expect(body).not_to include('value="auto"')
281
+ end
282
+
283
+ it 'pins search to the displayed locale without persisting a read-only selector' do
284
+ get '/api/v1/?resource=thing&doc_lang=cs'
285
+ endpoint = index_url
286
+ expect(endpoint).to include('doc_lang=cs')
287
+ clear_cookies
288
+ get endpoint, {}, 'HTTP_ACCEPT_LANGUAGE' => 'en'
289
+ expect(last_response).to be_ok
290
+ expect(last_response.body).to include('"locale":"cs"', 'Český dotaz')
291
+ expect(last_response.headers['Set-Cookie'].to_s).not_to include('haveapi.docs.locale')
292
+ expect(last_response.headers['Cache-Control']).to eq('private, max-age=86400, immutable')
293
+ expect(last_response.headers['Vary']).to include('Cookie', 'Accept-Language', 'Authorization')
294
+ get endpoint.sub('doc_lang=cs', 'doc_lang=en')
295
+ expect(last_response.status).to eq(409)
296
+ end
297
+
298
+ it 'preserves native Ruby and JavaScript tokens while translating authored comments' do
299
+ scanner = <<~JS
300
+ const ts = require(process.argv[1]);
301
+ let source = "";
302
+ process.stdin.on("data", chunk => source += chunk);
303
+ process.stdin.on("end", () => {
304
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, source);
305
+ const tokens = [];
306
+ let kind;
307
+ while ((kind = scanner.scan()) !== ts.SyntaxKind.EndOfFileToken) {
308
+ if (kind !== ts.SyntaxKind.SingleLineCommentTrivia && kind !== ts.SyntaxKind.MultiLineCommentTrivia) {
309
+ tokens.push([kind, scanner.getTokenText()]);
310
+ }
311
+ }
312
+ process.stdout.write(JSON.stringify(tokens));
313
+ });
314
+ JS
315
+ compiler = File.expand_path('../../../../clients/js/node_modules/typescript', __dir__)
316
+ rendered = %w[en cs].map do |locale|
317
+ get "/api/v1/?doc_lang=#{locale}"
318
+ last_response.body.scan(%r{<pre><code class="(ruby|javascript)"[^>]*>(.*?)</code></pre>}m).map do |language, html|
319
+ source = CGI.unescapeHTML(html)
320
+ if language == 'ruby'
321
+ Ripper.lex(source).reject { |(_, type, _, _)| type == :on_comment }.map { |(_, type, token, _)| [type, token] }
322
+ else
323
+ output, error, status = Open3.capture3('node', '-e', scanner, compiler, stdin_data: source)
324
+ expect(status).to be_success, error
325
+ JSON.parse(output)
326
+ end
327
+ end
328
+ end
329
+ expect(rendered.first).not_to be_empty
330
+ expect(rendered.last).to eq(rendered.first)
331
+ end
332
+
333
+ it 'leaves ordinary actions and OPTIONS in the configured API locale' do
334
+ set_cookie 'haveapi.docs.locale=cs; path=/api/'
335
+ get '/api/v1/things?doc_lang=cs', {}, 'HTTP_X_API_LANGUAGE' => 'en'
336
+ expect(JSON.parse(last_response.body).dig('response', 'thing', 'language')).to eq('en')
337
+ options '/api/v1/?doc_lang=cs', {}, 'HTTP_X_API_LANGUAGE' => 'en'
338
+ expect(last_response.body).to include('English resource', 'English query')
339
+ expect(last_response.body).not_to include('Český prostředek')
340
+ end
341
+
342
+ it 'restores the thread locale after successful and failed documentation rendering' do
343
+ previous = ::I18n.locale
344
+ get '/api/v1/?doc_lang=cs'
345
+ expect(::I18n.locale).to eq(previous)
346
+ allow(server).to receive(:describe_version) do
347
+ expect(::I18n.locale).to eq(:cs)
348
+ raise 'description failure'
349
+ end
350
+ app.set :raise_errors, true
351
+ expect { get '/api/v1/?doc_lang=cs' }.to raise_error(RuntimeError, 'description failure')
352
+ expect(::I18n.locale).to eq(previous)
353
+ end
354
+
355
+ it 'uses localized articles and stable mounted links with explicit English fallback' do
356
+ get '/api/doc/protocol?doc_lang=cs'
357
+ expect(document_language).to eq('cs')
358
+ expect(last_response.body).to include('id="resources"', 'src="/api/doc/protocol.png"', 'href="/api/doc/typed-input-validation')
359
+ get '/api/doc/documentation-ui'
360
+ expect(document_language).to eq('cs')
361
+ expect(last_response.body).to include('lang="en"', 'Browsing API documentation')
362
+ get '/api/doc/protocol.md'
363
+ expect(last_response).to be_ok
364
+ get '/api/doc/../../Gemfile'
365
+ expect(last_response.status).to eq(404)
366
+ end
367
+ end
@@ -1,4 +1,5 @@
1
1
  require 'spec_helper'
2
+ require 'open3'
2
3
 
3
4
  module DocumentationNavigationSpec
4
5
  User = Struct.new(:login)
@@ -232,7 +233,7 @@ describe DocumentationNavigationSpec do
232
233
 
233
234
  it 'links nested resource breadcrumbs to the directory and each real parent' do
234
235
  get '/v1/?resource=widget.child'
235
- breadcrumb = last_response.body.match(%r{<nav aria-label="Breadcrumb" data-resource-breadcrumbs>(.*?)</nav>}m)[1]
236
+ breadcrumb = last_response.body.match(%r{<nav class="docs-breadcrumb"[^>]*data-resource-breadcrumbs>(.*?)</nav>}m)[1]
236
237
  expect(breadcrumb).to include('href="/v1/?page=resources"', 'href="/v1/?resource=widget"',
237
238
  '<li aria-current="page">Child</li>')
238
239
  expect(last_response.body.index('data-action-nav')).to be < last_response.body.index('docs-preferences')
@@ -273,12 +274,69 @@ describe DocumentationNavigationSpec do
273
274
  response = Rack::MockRequest.new(app).get('/v1/?resource=widget',
274
275
  'HTTP_HOST' => 'example.test', 'SCRIPT_NAME' => '/rack')
275
276
  expect(response.status).to eq(200)
276
- expect(response.body).to include('src="/rack/js/documentation.js"',
277
+ digests = HaveAPI::DocumentationHelpers::ASSET_DIGESTS
278
+ expect(response.body).to include("src=\"/rack/js/documentation.js?v=#{digests.fetch('js/documentation.js')}\"",
277
279
  'data-highlighter-url="/rack/js/highlighter.js"',
278
- 'href="/rack/css/documentation.css"',
280
+ "href=\"/rack/css/documentation.css?v=#{digests.fetch('css/documentation.css')}\"",
279
281
  'href="/rack/v1/?page=resources"')
280
282
  end
281
283
 
284
+ it 'versions the entry assets by installed content without reading files during requests' do
285
+ public_root = File.expand_path('../../lib/haveapi/public', __dir__)
286
+ digests = HaveAPI::DocumentationHelpers::ASSET_DIGESTS
287
+ expect(digests).to eq(%w[js/documentation.js css/documentation.css].to_h do |asset|
288
+ [asset, Digest::SHA256.file(File.join(public_root, asset)).hexdigest]
289
+ end)
290
+ expect(digests).to be_frozen
291
+ expect(digests.values).to all(be_frozen)
292
+ allow(Digest::SHA256).to receive(:file).and_call_original
293
+ %w[en cs].each do |locale|
294
+ get "/v1/?doc_lang=#{locale}"
295
+ digests.each do |asset, digest|
296
+ expect(last_response.body).to include("/#{asset}?v=#{digest}\"")
297
+ end
298
+ end
299
+ expect(Digest::SHA256).not_to have_received(:file)
300
+ digests.each do |asset, digest|
301
+ get "/#{asset}?v=#{digest}"
302
+ expect(last_response).to be_ok
303
+ expect(last_response.body).to eq(File.binread(File.join(public_root, asset)))
304
+ end
305
+ end
306
+
307
+ it 'retains content versions across loads and changes only the modified asset version' do
308
+ helper_path = File.expand_path('../../lib/haveapi/documentation_helpers.rb', __dir__)
309
+ script = <<~RUBY
310
+ require 'tmpdir'
311
+ require 'fileutils'
312
+ Dir.mktmpdir('haveapi-assets') do |root|
313
+ path = File.join(root, 'documentation_helpers.rb')
314
+ FileUtils.cp(ARGV.fetch(0), path)
315
+ %w[js/documentation.js css/documentation.css].each do |asset|
316
+ target = File.join(root, 'public', asset)
317
+ FileUtils.mkdir_p(File.dirname(target))
318
+ File.write(target, asset)
319
+ end
320
+ load path
321
+ first = HaveAPI::DocumentationHelpers::ASSET_DIGESTS
322
+ HaveAPI::DocumentationHelpers.send(:remove_const, :ASSET_DIGESTS)
323
+ load path
324
+ unchanged = HaveAPI::DocumentationHelpers::ASSET_DIGESTS
325
+ File.write(File.join(root, 'public/js/documentation.js'), 'changed JavaScript')
326
+ HaveAPI::DocumentationHelpers.send(:remove_const, :ASSET_DIGESTS)
327
+ load path
328
+ puts JSON.generate([first, unchanged, HaveAPI::DocumentationHelpers::ASSET_DIGESTS])
329
+ end
330
+ RUBY
331
+ output, status = Open3.capture2e(RbConfig.ruby, '-e', script, helper_path)
332
+ expect(status).to be_success, output
333
+ first, unchanged, changed = JSON.parse(output)
334
+ expect(unchanged).to eq(first)
335
+ expect(changed.fetch('css/documentation.css')).to eq(first.fetch('css/documentation.css'))
336
+ expect(changed.fetch('js/documentation.js')).to eq(Digest::SHA256.hexdigest('changed JavaScript'))
337
+ expect(changed.fetch('js/documentation.js')).not_to eq(first.fetch('js/documentation.js'))
338
+ end
339
+
282
340
  it 'uses mounted canonical navigation and keeps shared documentation on its layout' do
283
341
  server = HaveAPI::Server.new(get_opt(:api_module))
284
342
  server.use_version(1)
@@ -137,6 +137,25 @@ describe HaveAPI::DocumentationOAuth do
137
137
  authorization
138
138
  end
139
139
 
140
+ it 'localizes failed browser sign-in without saving a detected language or leaking the thread locale' do
141
+ previous = ::I18n.locale
142
+ get 'https://docs.example/api/_documentation/oauth/callback', {}, 'HTTP_ACCEPT_LANGUAGE' => 'cs-CZ'
143
+ expect(last_response.status).to eq(400)
144
+ expect(last_response.body).to include(::I18n.t('haveapi.documentation.oauth.sign_in_failed', locale: :cs))
145
+ expect(cookie_headers).not_to include('haveapi.docs.locale')
146
+ expect(::I18n.locale).to eq(previous)
147
+ end
148
+
149
+ it 'localizes expired-session notices after authentication resolves the API locale' do
150
+ set_cookie 'haveapi.docs.locale=cs; path=/api/', URI('https://docs.example/api/')
151
+ sign_in
152
+ DocumentationOAuthSpec::Config.users = {}
153
+ get 'https://docs.example/api/v1/'
154
+ expect(last_response.status).to eq(200)
155
+ expect(last_response.body).to include(::I18n.t('haveapi.documentation.oauth.session_ended', locale: :cs))
156
+ expect(last_response.body).not_to include('PRIVATE_ACCESS_TOKEN')
157
+ end
158
+
140
159
  def cookie_headers
141
160
  Array(last_response.headers['set-cookie']).join("\n")
142
161
  end
@@ -263,9 +282,32 @@ describe HaveAPI::DocumentationOAuth do
263
282
  end
264
283
  end
265
284
 
285
+ it 'keeps only the protected sign-out form when switching is unavailable' do
286
+ sign_in
287
+ get 'https://docs.example/api/v1/'
288
+ forms = last_response.body.scan(%r{<form method="post" action="https://docs.example/api/_documentation/oauth/logout">(.*?)</form>}m).flatten
289
+ expect(forms.length).to eq(1)
290
+ expect(forms.first).to include('>Sign out</button>')
291
+ expect(forms.first).to match(/name="csrf" value="[\w-]{43}"/)
292
+ expect(forms.first).not_to include('name="switch_user"', '>Switch user</button>')
293
+ end
294
+
266
295
  context 'with valid session CSRF and a switch-capable client' do
267
296
  let(:oauth_options) { super().merge(switch_user: true, switch_user_revoke_params: { 'end_shared_session' => '1' }) }
268
297
 
298
+ it 'places the native switch-user form before sign-out without changing their protected fields' do
299
+ sign_in
300
+ get 'https://docs.example/api/v1/'
301
+ forms = last_response.body.scan(%r{<form method="post" action="https://docs.example/api/_documentation/oauth/logout">(.*?)</form>}m).flatten
302
+ expect(forms.length).to eq(2)
303
+ expect(forms.first).to include('name="switch_user" value="1"', '>Switch user</button>')
304
+ expect(forms.last).to include('>Sign out</button>')
305
+ expect(forms.last).not_to include('name="switch_user"')
306
+ csrf_fields = forms.map { |form| form[/name="csrf" value="([^"]+)"/, 1] }
307
+ expect(csrf_fields.first).to match(/\A[\w-]{43}\z/)
308
+ expect(csrf_fields.last).to eq(csrf_fields.first)
309
+ end
310
+
269
311
  [nil, 'null', 'https://foreign.example', 'http://docs.example', 'https://docs.example:444'].each do |origin|
270
312
  [false, true].each do |switch_user|
271
313
  it "rejects #{switch_user ? 'switching' : 'sign-out'} from #{origin || 'an absent Origin'} without sign-out effects" do
@@ -86,7 +86,7 @@ RSpec.describe HaveAPI::DocumentationSearch::Helpers do
86
86
  expect(response.content_type).to match(%r{\Atext/html;\s*charset=utf-8\z})
87
87
  expect(response['cache-control']).to eq('private, max-age=86400, immutable')
88
88
  expect(response['etag']).to eq(%("#{key}"))
89
- expect(response['vary'].split(/,\s*/)).to contain_exactly('X-Language', 'Cookie', 'Authorization')
89
+ expect(response['vary'].split(/,\s*/)).to contain_exactly('X-Language', 'Accept-Language', 'Cookie', 'Authorization')
90
90
  expect(envelope(response)).to include('key' => key)
91
91
  expect(response.body).to include('\\u003c/script\\u003e', '\\u0026')
92
92
  expect(response.body).not_to include('<!DOCTYPE', 'docs-sidebar', '<script src=', 'fixture')
@@ -139,10 +139,14 @@ RSpec.describe HaveAPI::DocumentationSearch::Helpers do
139
139
  expect(envelope(current)['index']).to include('version' => '1', 'base_path' => '/api/v1/')
140
140
  end
141
141
 
142
- it 'binds the projection to resolved locale and canonical Rack/API mounts' do
142
+ it 'binds the projection to the documentation browser locale independently of the API header and Rack/API mounts' do
143
143
  server.available_locales = %i[en cs]
144
- english = envelope(request('?page=resources&search_index=current', 'HTTP_X_LANGUAGE' => 'en'))
145
- czech = envelope(request('?page=resources&search_index=current', 'HTTP_X_LANGUAGE' => 'cs'))
144
+ english = envelope(request('?page=resources&search_index=current', 'HTTP_ACCEPT_LANGUAGE' => 'en', 'HTTP_X_LANGUAGE' => 'cs'))
145
+ czech = envelope(request('?page=resources&search_index=current', 'HTTP_ACCEPT_LANGUAGE' => 'cs', 'HTTP_X_LANGUAGE' => 'en'))
146
+ custom_header = envelope(request('?page=resources&search_index=current', 'HTTP_X_LANGUAGE' => 'cs'))
147
+ expect(english['index']['locale']).to eq('en')
148
+ expect(custom_header['index']['locale']).to eq('en')
149
+ expect(custom_header['key']).to eq(english['key'])
146
150
  expect(english['key']).not_to eq(czech['key'])
147
151
  expect(czech['index']['locale']).to eq('cs')
148
152
  mounted = envelope(request('?page=resources&search_index=current', 'SCRIPT_NAME' => '/rack'))
@@ -12,7 +12,7 @@ RSpec.describe HaveAPI::DocumentationSearch do
12
12
  { resources: { widget: { actions: { renew: action }, resources: {} } },
13
13
  authentication: { token: { resources: { token: { actions: { request: action }, resources: {} } } } } }
14
14
  end
15
- let(:presentation) { Object.new.extend(described_class::Helpers) }
15
+ let(:presentation) { Object.new.extend(HaveAPI::DocumentationLocale).extend(described_class::Helpers) }
16
16
 
17
17
  def search(query, source = description)
18
18
  index = described_class::Index.new(description: source, base_path: '/v1/', version: '1', locale: 'en', visibility: 'anonymous')
@@ -1,5 +1,7 @@
1
1
  require 'spec_helper'
2
2
  require 'rack/mock'
3
+ require 'cgi'
4
+ require 'ripper'
3
5
 
4
6
  RSpec.describe HaveAPI::SharedDocumentationHelpers do
5
7
  api do
@@ -90,7 +92,7 @@ RSpec.describe HaveAPI::SharedDocumentationHelpers do
90
92
  expect(last_response.body).to include(heading, 'aria-label="Breadcrumb"',
91
93
  'href="/"', 'href="/doc"', 'href="/v1/?page=resources"')
92
94
  expect_neutral_shell(last_response.body, 'guide')
93
- breadcrumb = last_response.body.match(%r{<nav aria-label="Breadcrumb">(.*?)</nav>}m)[1]
95
+ breadcrumb = last_response.body.match(%r{<nav class="docs-breadcrumb"[^>]*>(.*?)</nav>}m)[1]
94
96
  expect(breadcrumb).to include('API versions', 'HaveAPI internals')
95
97
  expect(breadcrumb.scan(/<li\b/).length).to eq(path.match?(%r{\A/doc(?:/(?:index(?:\.md)?|readme))?\z}) ? 2 : 3)
96
98
  expect(server).not_to have_received(:describe)
@@ -121,6 +123,65 @@ RSpec.describe HaveAPI::SharedDocumentationHelpers do
121
123
  expect(pages.uniq.length).to eq(1)
122
124
  end
123
125
 
126
+ def article_samples(body)
127
+ body.scan(%r{<pre><code(?: class="([^"]*)")?>(.*?)</code></pre>}m).map do |language, source|
128
+ code = CGI.unescapeHTML(source)
129
+ if language == 'ruby'
130
+ Ripper.lex(code).reject { |(_, type, _, _)| %i[on_sp on_nl on_ignored_nl on_comment].include?(type) }
131
+ .map { |(_, type, token, _)| [type, token] }
132
+ else
133
+ code.lines.reject { |line| line.lstrip.start_with?('#', '//') }.join.strip
134
+ end
135
+ end
136
+ end
137
+
138
+ {
139
+ 'readme' => 'Serverová implementace',
140
+ 'protocol' => 'Definice protokolu',
141
+ 'create-client' => 'Definice klienta',
142
+ 'typed-input-validation' => 'Validace typovaných vstupů',
143
+ 'client-examples' => 'Klientské příklady'
144
+ }.each do |article, heading|
145
+ it "translates #{article} while preserving its English anchors and noncomment samples" do
146
+ get "/doc/#{article}", {}, 'HTTP_ACCEPT_LANGUAGE' => 'en'
147
+ english = last_response.body
148
+ expect(last_response).to be_ok
149
+ get "/doc/#{article}", {}, 'HTTP_ACCEPT_LANGUAGE' => 'cs-CZ'
150
+ czech = last_response.body
151
+ expect(last_response).to be_ok
152
+ expect(czech).to include('lang="cs"', heading)
153
+ expect(czech.scan(/<h[1-6]\b[^>]*\bid="([^"]+)"/).flatten)
154
+ .to eq(english.scan(/<h[1-6]\b[^>]*\bid="([^"]+)"/).flatten)
155
+ expect(article_samples(czech)).not_to be_empty
156
+ expect(article_samples(czech)).to eq(article_samples(english))
157
+ end
158
+ end
159
+
160
+ it 'uses identical schema code for the English and Czech presentations' do
161
+ get '/doc/json-schema', {}, 'HTTP_ACCEPT_LANGUAGE' => 'en'
162
+ english = last_response.body
163
+ get '/doc/json-schema', {}, 'HTTP_ACCEPT_LANGUAGE' => 'cs'
164
+ expect(last_response).to be_ok
165
+ expect(last_response.body).to include('<h1 id="envelope">Obálka</h1>')
166
+ expect(article_samples(last_response.body)).to eq(article_samples(english))
167
+ end
168
+
169
+ it 'keeps translated article and shared asset links mounted under Rack and API prefixes' do
170
+ server = HaveAPI::Server.new(get_opt(:api_module))
171
+ server.use_version(1)
172
+ server.mount('/api/')
173
+ request = Rack::MockRequest.new(server.app)
174
+ response = request.get('/api/doc/protocol', 'HTTP_HOST' => 'example.test',
175
+ 'SCRIPT_NAME' => '/rack', 'HTTP_ACCEPT_LANGUAGE' => 'cs')
176
+ expect(response.status).to eq(200)
177
+ expect(response.body).to include('href="/rack/api/doc/typed-input-validation"',
178
+ 'src="/rack/api/doc/protocol.png"')
179
+ response = request.get('/api/doc/client-examples', 'HTTP_HOST' => 'example.test',
180
+ 'SCRIPT_NAME' => '/rack', 'HTTP_ACCEPT_LANGUAGE' => 'cs')
181
+ expect(response.body).to include('href="/rack/api/doc/documentation-ui"',
182
+ 'href="/rack/api/doc/documentation-oauth"')
183
+ end
184
+
124
185
  it 'uses configured escaped identity or the actual host and port in neutral titles and brand' do
125
186
  server = HaveAPI::Server.new(get_opt(:api_module))
126
187
  server.use_version(1)
@@ -191,7 +252,7 @@ RSpec.describe HaveAPI::SharedDocumentationHelpers do
191
252
  expect(response['Cache-Control']).to eq('private, no-store')
192
253
  expect(response.body).to include('href="/rack/api/"', 'href="/rack/api/doc"',
193
254
  'href="/rack/api/v1/"', 'href="/rack/api/v1/?page=resources"',
194
- 'src="/rack/js/documentation.js"')
255
+ "src=\"/rack/js/documentation.js?v=#{HaveAPI::DocumentationHelpers::ASSET_DIGESTS.fetch('js/documentation.js')}\"")
195
256
  expect(response.body).not_to include('href="/doc"', 'href="/v1/"')
196
257
  end
197
258
  end