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,192 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe HaveAPI::DocumentationHelpers do
4
+ api do
5
+ define_resource(:Widget) do
6
+ version 1
7
+ auth false
8
+ define_action(:Show) do
9
+ route ''
10
+ http_method :get
11
+ input(:hash) { string :name }
12
+ output(:hash) { string :name }
13
+ authorize { allow }
14
+ # rubocop:disable RSpec/NoExpectationExample
15
+ example 'Request' do
16
+ request(name: 'example')
17
+ response(name: 'example')
18
+ end
19
+ # rubocop:enable RSpec/NoExpectationExample
20
+
21
+ def exec
22
+ { name: 'example' }
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ let(:generated) { false }
29
+ let(:server) do
30
+ HaveAPI::Server.new(self.class::ApiModule).tap do |api|
31
+ api.use_version(1)
32
+ api.documentation_clients(1, typescript: { package_name: 'example-client' }) if generated
33
+ api.mount('/api/')
34
+ end
35
+ end
36
+ let(:app) { server.app }
37
+
38
+ def example_form
39
+ last_response.body[%r{<form\b[^>]*data-example-preferences[^>]*>.*?</form>}m]
40
+ end
41
+
42
+ def native_submit(**changes)
43
+ form = example_form
44
+ fields = form.scan(/<input\b[^>]*type="hidden"[^>]*name="([^"]+)"[^>]*value="([^"]*)"/).to_h
45
+ form.scan(%r{<select\b([^>]*)>(.*?)</select>}m).each do |attributes, options|
46
+ next if attributes.include?('disabled')
47
+
48
+ name = attributes[/name="([^"]+)"/, 1]
49
+ selected = options[/<option value="([^"]*)" selected/, 1] || options[/<option value="([^"]*)"/, 1]
50
+ fields[name] = CGI.unescapeHTML(selected)
51
+ end
52
+ changes.each { |key, value| fields[key.to_s] = value }
53
+ action = CGI.unescapeHTML(form[/action="([^"]+)"/, 1])
54
+ get "#{action}?#{URI.encode_www_form(fields)}"
55
+ end
56
+
57
+ def open_panels
58
+ last_response.body.scan(/<details\b[^>]*data-example-panel[^>]*>/).grep(/\sopen>/)
59
+ end
60
+
61
+ it 'submits a real GET form with only validated page, search and explicit locale fields' do
62
+ get '/api/v1/?resource=widget&q=name&doc_lang=cs&token=ignored&extra=ignored'
63
+ expect(example_form).to include('method="get" action="/api/v1/"', 'name="example_language"', 'name="example_client"',
64
+ 'name="example_detail"', 'data-apply-examples>')
65
+ expect(example_form).to include('name="resource" value="widget"', 'name="q" value="name"', 'name="doc_lang" value="cs"')
66
+ expect(example_form).not_to include('name="token"', 'name="extra"')
67
+ native_submit(example_language: 'javascript')
68
+ expect(last_request.params).to include('resource' => 'widget', 'q' => 'name', 'doc_lang' => 'cs')
69
+ expect(last_response.body).to include('<html lang="cs">')
70
+ expect(open_panels).to all(include('data-client="javascript-callback"', 'data-detail="simple"'))
71
+ end
72
+
73
+ it 'lets a new language win over the previously submitted client and applies its variant and detail next' do
74
+ get '/api/v1/?resource=widget'
75
+ native_submit(example_language: 'javascript')
76
+ expect(example_form).to include('value="javascript-callback" selected', 'value="javascript-async"')
77
+ native_submit(example_client: 'javascript-async', example_detail: 'thorough')
78
+ expect(open_panels).not_to be_empty
79
+ expect(open_panels).to all(include('data-client="javascript-async"', 'data-detail="thorough"'))
80
+ expect(last_response.body).to include('id="example-widget-show-0-0"', 'data-client="python-sync"')
81
+ expect(last_response.headers['Set-Cookie'].to_s).not_to include('haveapi.docs.preferences')
82
+ end
83
+
84
+ it 'infers language from a valid client and handles singleton detail with a submitted hidden value' do
85
+ get '/api/v1/?resource=widget&q=name&doc_lang=cs&example_client=python-async'
86
+ expect(example_form).to include('value="python" selected', 'value="python-async" selected')
87
+ native_submit(example_language: 'curl', example_detail: 'thorough')
88
+ expect(example_form).to include('<label data-detail-control hidden>')
89
+ expect(example_form).to match(/data-detail-select name="example_detail" disabled/)
90
+ expect(example_form).to include('type="hidden" name="example_detail" value="simple"')
91
+ expect(open_panels).to all(include('data-client="5"', 'data-detail="simple"'))
92
+ native_submit
93
+ expect(last_request.params).to include('example_detail' => 'simple', 'resource' => 'widget', 'q' => 'name', 'doc_lang' => 'cs')
94
+ expect(last_request.query_string.scan('example_detail=').length).to eq(1)
95
+ native_submit(example_language: 'ruby')
96
+ expect(example_form).to include('<label data-detail-control>')
97
+ expect(example_form).not_to include('type="hidden" name="example_detail"')
98
+ expect(example_form).not_to match(/data-detail-select[^>]*disabled/)
99
+ native_submit(example_detail: 'thorough')
100
+ expect(last_request.params['example_detail']).to eq('thorough')
101
+ expect(last_request.query_string.scan('example_detail=').length).to eq(1)
102
+ expect(open_panels).to all(include('data-client="0"', 'data-detail="thorough"'))
103
+ end
104
+
105
+ it 'falls back for unavailable profiles and unknown scalars without selecting arbitrary classes' do
106
+ get '/api/v1/?example_client=go&example_detail=unknown'
107
+ expect(open_panels).to all(include('data-client="0"', 'data-detail="simple"'))
108
+ expect(last_response.body).not_to include('data-client="go"')
109
+ get '/api/v1/?example_language=stale&example_client=python-async'
110
+ expect(example_form).to include('value="ruby" selected')
111
+ expect(open_panels).to all(include('data-client="0"'))
112
+ end
113
+
114
+ it 'rejects repeated, bracketed and invalidly encoded example selectors' do
115
+ %w[example_language=python&example_language=ruby example_client=0&example_client=2
116
+ example_detail=simple&example_detail=thorough example_language[]=ruby example_client[]=0 example_detail[x]=simple
117
+ example_language=%FF example_client=%FF example_detail=%FF example_detail=%].each do |query|
118
+ get "/api/v1/?#{query}"
119
+ expect(last_response.status).to eq(400), query
120
+ end
121
+ end
122
+
123
+ it 'uses per-group detail fallback and preserves every executable block when selection changes' do
124
+ get '/api/v1/?page=authentication&example_client=javascript-async&example_detail=thorough'
125
+ expect(last_response).to be_ok
126
+ get '/api/v1/?example_client=javascript-callback&example_detail=thorough'
127
+ expect(open_panels).to all(include('data-client="javascript-callback"', 'data-detail="simple"'))
128
+ before = last_response.body.scan(%r{<pre><code[^>]*>(.*?)</code></pre>}m)
129
+ get '/api/v1/?example_client=0'
130
+ expect(last_response.body.scan(%r{<pre><code[^>]*>(.*?)</code></pre>}m)).to eq(before)
131
+ end
132
+
133
+ it 'leaves other clients collapsed when the selected client has no result in a group' do
134
+ allow(HaveAPI::ClientExamples::RubyClient).to receive(:render).and_return(nil)
135
+ get '/api/v1/?example_client=0'
136
+ expect(last_response).to be_ok
137
+ expect(open_panels).to be_empty
138
+ expect(last_response.body).to include('data-example-unavailable', 'data-client="python-sync"')
139
+ end
140
+
141
+ it 'keeps a selected limitation panel open and its detail singleton submitted' do
142
+ allow(HaveAPI::ClientExamples::RubyClient).to receive(:render).and_return(status: :limitation, reason: 'Supplied reason')
143
+ get '/api/v1/?example_client=0&example_detail=thorough'
144
+ expect(open_panels).to all(include('data-client="0"', 'data-detail="simple"'))
145
+ expect(last_response.body).to include('class="client-limitation">Supplied reason')
146
+ expect(example_form).to include('name="example_detail" value="simple"')
147
+ end
148
+
149
+ it 'omits the native form when no clients are available' do
150
+ allow(HaveAPI::ClientExample).to receive(:clients).and_return([])
151
+ get '/api/v1/?example_client=stale'
152
+ expect(last_response).to be_ok
153
+ expect(example_form).to be_nil
154
+ expect(open_panels).to be_empty
155
+ end
156
+
157
+ it 'does not propagate detected locale or reinterpret selectors on root, actions, OPTIONS or search' do
158
+ get '/api/v1/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'cs'
159
+ expect(example_form).not_to include('name="doc_lang"')
160
+ get '/api/?example_client[]=0'
161
+ expect(last_response).to be_ok
162
+ expect(last_response.body).to include('data-page="versions"')
163
+ get '/api/v1/widgets?example_client[]=0'
164
+ expect(last_response).to be_ok
165
+ options '/api/v1/?example_client[]=0'
166
+ expect(last_response).to be_ok
167
+ get '/api/v1/?page=resources&search_index=current&example_client[]=0'
168
+ expect(last_response).to be_ok
169
+ expect(last_response.body).to include('data-documentation-search-index')
170
+ end
171
+
172
+ context 'with an available generated TypeScript client' do
173
+ let(:generated) { true }
174
+
175
+ around do |example|
176
+ path = File.expand_path('../../../../clients/typescript/lib', __dir__)
177
+ $:.unshift(path)
178
+ example.run
179
+ ensure
180
+ $:.delete(path)
181
+ end
182
+
183
+ it 'applies the real generated variant and keeps its initialization detail singleton usable' do
184
+ get '/api/v1/?example_client=typescript-generated&example_detail=thorough'
185
+ expect(last_response).to be_ok
186
+ expect(open_panels).to all(include('data-client="typescript-generated"', 'data-detail="simple"'))
187
+ expect(example_form).to include('value="typescript-generated" selected', 'name="example_detail" value="simple"')
188
+ native_submit(example_detail: 'thorough')
189
+ expect(last_response.body).to include('example-client')
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,132 @@
1
+ require 'spec_helper'
2
+
3
+ describe HaveAPI::Server do
4
+ api do
5
+ define_resource(:Ping) do
6
+ version 1
7
+ auth false
8
+ define_action(:Show) do
9
+ route 'ping'
10
+ http_method :get
11
+ output(:hash) { string :value }
12
+ authorize { allow }
13
+ def exec
14
+ { value: 'pong' }
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ let(:server) do
21
+ described_class.new(get_opt(:api_module)).tap do |api|
22
+ api.use_version(1)
23
+ api.mount('/api/')
24
+ end
25
+ end
26
+
27
+ def request(path)
28
+ Rack::MockRequest.new(server.app).get(path, 'HTTP_HOST' => 'example.test')
29
+ end
30
+
31
+ %w[/api/v1/ /api/ /api/doc/protocol].each do |path|
32
+ it "keeps a native language form above Theme on #{path}" do
33
+ response = request("#{path}?doc_lang=cs")
34
+ expect(response.status).to eq(200)
35
+ body = response.body
36
+ form = body[%r{<form data-documentation-language\b.*?</form>}m]
37
+ expect(form).to include('method="get"', "action=\"#{path}\"", '<label for="docs-language">Jazyk</label>',
38
+ '<select id="docs-language" name="doc_lang">',
39
+ '<option value="en">English</option>', '<option value="cs" selected>Česky</option>')
40
+ button = form[%r{<button\b[^>]*data-documentation-language-submit[^>]*>.*?</button>}m]
41
+ expect(button).to include('type="submit"', '>Použít jazyk</button>')
42
+ expect(button).not_to match(/\bhidden\b|display:\s*none/)
43
+ expect(form.index('</select>')).to be < form.index('<button')
44
+ expect(body.index(form)).to be < body.index('id="docs-theme-label"')
45
+ expect(form).not_to include('value="auto"')
46
+ end
47
+ end
48
+
49
+ it 'submits the native form without JavaScript while retaining the page and query' do
50
+ response = request('/api/v1/?resource=ping&q=hostname')
51
+ form = response.body[%r{<form data-documentation-language\b.*?</form>}m]
52
+ action = CGI.unescapeHTML(form[/action="([^"]+)"/, 1])
53
+ fields = form.scan(/type="hidden" name="([^"]+)" value="([^"]*)"/).map do |name, value|
54
+ [CGI.unescapeHTML(name), CGI.unescapeHTML(value)]
55
+ end
56
+ expect(fields).to include(%w[resource ping], %w[q hostname])
57
+ selected = request("#{action}?#{URI.encode_www_form(fields + [%w[doc_lang cs]])}")
58
+
59
+ expect(selected.status).to eq(200)
60
+ expect(selected.body).to include('<html lang="cs">', 'data-resource="ping"', 'name="q" value="hostname"')
61
+ expect(selected['Set-Cookie'].to_s).to include('haveapi.docs.locale=cs')
62
+ end
63
+
64
+ it 'uses short localized labels for the programming language selector' do
65
+ response = request('/api/v1/?doc_lang=cs')
66
+ expect(response.body).to include('<label>Jazyk <select data-language-select name="example_language">')
67
+ expect(response.body).not_to include('Programovací jazyk <select')
68
+ expect(request('/api/v1/?doc_lang=en').body).to include('<label>Language <select data-language-select name="example_language">')
69
+ end
70
+
71
+ it 'styles every breadcrumb with the same marker while translating its accessible name' do
72
+ { 'en' => 'Breadcrumb', 'cs' => 'Drobečková navigace' }.each do |locale, label|
73
+ ['/api/v1/?page=authentication', '/api/v1/?resource=ping', '/api/doc/protocol?'].each do |path|
74
+ separator = path.end_with?('?') ? '' : '&'
75
+ response = request("#{path}#{separator}doc_lang=#{locale}")
76
+ expect(response.status).to eq(200)
77
+ expect(response.body).to include("<nav class=\"docs-breadcrumb\" aria-label=\"#{label}\"")
78
+ expect(response.body.scan('class="docs-breadcrumb"').length).to eq(1)
79
+ end
80
+ end
81
+ end
82
+
83
+ it 'shows the API address without the redundant client link or usage permissions sentence' do
84
+ { 'en' => 'The API is available at', 'cs' => 'API je dostupné na' }.each do |locale, address|
85
+ response = request("/api/v1/?doc_lang=#{locale}")
86
+ main = response.body[%r{<main\b.*?</main>}m]
87
+ expect(main).to include("<p>#{address} <code>http://example.test/api/</code>.</p>")
88
+ expect(main).not_to include('href="https://github.com/vpsfreecz/haveapi"',
89
+ 'Each API request still checks permissions', 'Každý požadavek API nadále ověřuje oprávnění')
90
+ expect(main).to include('Projdi si všechny zdroje') if locale == 'cs'
91
+ end
92
+ end
93
+
94
+ it 'localizes generated-client labels and JS variants while preserving machine identifiers and packages' do
95
+ adapter = Object.new
96
+ def adapter.render(**)
97
+ { status: :limitation, reason: 'Fixture sample is unavailable.' }.freeze
98
+ end
99
+ profiles = %i[go rust typescript].to_h do |language|
100
+ [language, HaveAPI::ClientExample::Profile.new(language:, package_name: "#{language}-package", package_version: '1.2.3', adapter:)]
101
+ end
102
+ allow(server).to receive(:documentation_client_profiles).and_return(profiles)
103
+ {
104
+ 'en' => ['Go API client', 'Rust API client (async)', 'Rust API client (blocking)', 'TypeScript API client', 'Async', 'Blocking', 'API client'],
105
+ 'cs' => ['Klient API pro Go', 'Klient API pro Rust (asynchronní)', 'Klient API pro Rust (blokující)', 'Klient API pro TypeScript', 'Asynchronní', 'Blokující', 'Klient API']
106
+ }.each do |locale, text|
107
+ response = request("/api/v1/?doc_lang=#{locale}")
108
+ body = response.body
109
+ expect(response.status).to eq(200)
110
+ text.first(4).each { |label| expect(body).to include("<summary>#{label} · ") }
111
+ metadata = JSON.parse(body[%r{<script[^>]*data-documentation-clients>(.*?)</script>}m, 1]).to_h { |client| [client.fetch('id'), client] }
112
+ expect(metadata.fetch('go')).to include('language' => 'go', 'language_label' => 'Go', 'variant' => nil)
113
+ %w[rust-async rust-blocking typescript-generated].each_with_index do |id, index|
114
+ expect(metadata.fetch(id).fetch('variant')).to eq(text[index + 4])
115
+ expect(body).to include("data-client=\"#{id}\"")
116
+ end
117
+ expect(body).to include('data-variant="Async"', 'data-variant="Blocking"', 'data-variant="API client"',
118
+ 'go-package', 'rust-package', 'typescript-package', '1.2.3')
119
+ end
120
+ end
121
+
122
+ it 'keeps custom-renderer display labels and variants unchanged in every locale' do
123
+ helper = Object.new.extend(HaveAPI::DocumentationHelpers).extend(HaveAPI::DocumentationLocale)
124
+ custom = Struct.new(:id, :label, :variant).new('custom-generated', '<Custom & label>', '<Custom & variant>')
125
+ %i[en cs].each do |locale|
126
+ I18n.with_locale(locale) do
127
+ expect(helper.documentation_client_label(custom)).to eq(custom.label)
128
+ expect(helper.documentation_client_variant(custom)).to eq(custom.variant)
129
+ end
130
+ end
131
+ end
132
+ end