haveapi 0.31.2 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/benchmarks/documentation.rb +29 -6
  4. data/benchmarks/endpoints.rb +153 -24
  5. data/benchmarks/spec/client_examples_spec.rb +109 -37
  6. data/doc/client-examples.md +218 -45
  7. data/doc/documentation-oauth.md +157 -0
  8. data/doc/documentation-ui.md +192 -0
  9. data/doc/index.md +3 -6
  10. data/doc/performance.md +39 -12
  11. data/haveapi.gemspec +1 -1
  12. data/lib/haveapi/authentication/chain.rb +19 -0
  13. data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
  14. data/lib/haveapi/client_example.rb +143 -15
  15. data/lib/haveapi/client_examples/curl.rb +30 -20
  16. data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
  17. data/lib/haveapi/client_examples/go_client.rb +9 -2
  18. data/lib/haveapi/client_examples/http.rb +9 -4
  19. data/lib/haveapi/client_examples/js_async_client.rb +7 -0
  20. data/lib/haveapi/client_examples/js_client.rb +47 -36
  21. data/lib/haveapi/client_examples/php_client.rb +161 -75
  22. data/lib/haveapi/client_examples/python_async_client.rb +3 -0
  23. data/lib/haveapi/client_examples/python_client.rb +61 -47
  24. data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
  25. data/lib/haveapi/client_examples/ruby_client.rb +80 -31
  26. data/lib/haveapi/client_examples/rust_client.rb +20 -9
  27. data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
  28. data/lib/haveapi/client_examples/ts_client.rb +3 -0
  29. data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
  30. data/lib/haveapi/documentation_context.rb +7 -2
  31. data/lib/haveapi/documentation_helpers.rb +223 -0
  32. data/lib/haveapi/documentation_oauth.rb +399 -0
  33. data/lib/haveapi/documentation_search.rb +225 -0
  34. data/lib/haveapi/documentation_search_index.rb +79 -0
  35. data/lib/haveapi/documentation_validators.rb +179 -0
  36. data/lib/haveapi/public/css/documentation.css +343 -0
  37. data/lib/haveapi/public/js/documentation.js +992 -0
  38. data/lib/haveapi/server.rb +144 -86
  39. data/lib/haveapi/shared_documentation_helpers.rb +54 -0
  40. data/lib/haveapi/version.rb +1 -1
  41. data/lib/haveapi/views/doc_layout.erb +16 -0
  42. data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
  43. data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
  44. data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
  45. data/lib/haveapi/views/documentation/search_form.erb +10 -0
  46. data/lib/haveapi/views/documentation/search_index.erb +1 -0
  47. data/lib/haveapi/views/documentation/search_results.erb +20 -0
  48. data/lib/haveapi/views/index.erb +2 -4
  49. data/lib/haveapi/views/main_layout.erb +12 -6
  50. data/lib/haveapi/views/shared_documentation.erb +12 -0
  51. data/lib/haveapi/views/shared_sidebar.erb +25 -0
  52. data/lib/haveapi/views/version_page/auth_body.erb +12 -33
  53. data/lib/haveapi/views/version_page/client_overview.erb +40 -0
  54. data/lib/haveapi/views/version_page/example_panels.erb +18 -0
  55. data/lib/haveapi/views/version_page/resource_body.erb +91 -132
  56. data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
  57. data/lib/haveapi/views/version_page/usage.erb +7 -0
  58. data/lib/haveapi/views/version_page.erb +36 -117
  59. data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
  60. data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
  61. data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
  62. data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
  63. data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
  64. data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
  65. data/lib/haveapi/views/version_sidebar.erb +59 -48
  66. data/spec/authentication/basic_spec.rb +10 -10
  67. data/spec/authentication/oauth2_chain_spec.rb +72 -0
  68. data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
  69. data/spec/client_examples/assets/documentation_test.cjs +817 -0
  70. data/spec/client_examples/context_performance_spec.rb +5 -4
  71. data/spec/client_examples/context_spec.rb +23 -2
  72. data/spec/client_examples/detail_spec.rb +175 -0
  73. data/spec/client_examples/dynamic_acceptance.md +59 -26
  74. data/spec/client_examples/dynamic_acceptance.rb +164 -44
  75. data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
  76. data/spec/client_examples/dynamic_fixture.rb +4 -3
  77. data/spec/client_examples/dynamic_js_spec.rb +20 -11
  78. data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
  79. data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
  80. data/spec/client_examples/dynamic_python_spec.rb +62 -11
  81. data/spec/client_examples/go_spec.rb +14 -1
  82. data/spec/client_examples/presentation_spec.rb +247 -0
  83. data/spec/client_examples/profile_mount_spec.rb +40 -13
  84. data/spec/client_examples/rust_client_spec.rb +35 -1
  85. data/spec/client_examples/shell_spec.rb +268 -0
  86. data/spec/client_examples/syntax_spec.rb +192 -0
  87. data/spec/client_examples/ts_generated_client_spec.rb +15 -2
  88. data/spec/client_examples/typescript_profiles_spec.rb +1 -1
  89. data/spec/documentation/auth_filtering_spec.rb +9 -4
  90. data/spec/documentation/canonical_routes_spec.rb +89 -0
  91. data/spec/documentation/client_examples_spec.rb +39 -5
  92. data/spec/documentation/client_overview_spec.rb +145 -0
  93. data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
  94. data/spec/documentation/example_variants_spec.rb +93 -0
  95. data/spec/documentation/fixtures/search_index.json +476 -0
  96. data/spec/documentation/identity_and_guides_spec.rb +130 -0
  97. data/spec/documentation/identity_context_spec.rb +180 -0
  98. data/spec/documentation/navigation_spec.rb +296 -0
  99. data/spec/documentation/oauth_fixture_spec.rb +39 -0
  100. data/spec/documentation/oauth_spec.rb +912 -0
  101. data/spec/documentation/oauth_transport_spec.rb +45 -0
  102. data/spec/documentation/performance_spec.rb +47 -6
  103. data/spec/documentation/reference_headings_spec.rb +190 -0
  104. data/spec/documentation/search_index_routes_spec.rb +202 -0
  105. data/spec/documentation/search_index_spec.rb +85 -0
  106. data/spec/documentation/search_routes_spec.rb +199 -0
  107. data/spec/documentation/search_spec.rb +134 -0
  108. data/spec/documentation/shared_pages_spec.rb +240 -0
  109. data/spec/documentation/validators_spec.rb +123 -0
  110. data/spec/documentation_spec.rb +3 -3
  111. data/spec/packaging_spec.rb +21 -2
  112. data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
  113. data/spec/performance/documentation_shapes_spec.rb +97 -0
  114. data/spec/performance/json_activation_spec.rb +78 -1
  115. data/spec/performance/release_lineage_spec.rb +4 -3
  116. data/spec/performance/runner_spec.rb +1 -0
  117. data/test_support/all_client_performance_fixture.rb +6 -10
  118. data/test_support/documentation_oauth_fixture.rb +54 -0
  119. data/test_support/documentation_performance_fixture.rb +19 -1
  120. data/test_support/dynamic_documentation_api.rb +35 -10
  121. data/test_support/dynamic_python_login_spec.rb +3 -11
  122. metadata +52 -3
@@ -0,0 +1,199 @@
1
+ require 'spec_helper'
2
+ require 'rack/mock'
3
+ require_relative '../../test_support/dynamic_documentation_api'
4
+
5
+ module DocumentationSearchRoutesSpec
6
+ User = Struct.new(:login)
7
+
8
+ class Provider < HaveAPI::Authentication::Basic::Provider
9
+ protected
10
+
11
+ def find_user(_request, username, password)
12
+ User.new(username) if password == 'pass'
13
+ end
14
+ end
15
+ end
16
+
17
+ RSpec.describe HaveAPI::DocumentationSearch::Helpers do
18
+ api do
19
+ define_resource(:Widget) do
20
+ version 1
21
+ auth false
22
+ define_action(:Renew) do
23
+ http_method :post
24
+ route 'renew'
25
+ aliases ['extend']
26
+ authorize do |user|
27
+ input blacklist: [:private_ticket] unless user.login == 'admin'
28
+ allow
29
+ end
30
+ input(:hash) do
31
+ integer :lifetime, label: 'Lease duration'
32
+ string :private_ticket, label: 'Secret ticket', default: 'DEFAULT_SECRET'
33
+ end
34
+ output(:hash) { string :valid_to, label: 'Expiry date' }
35
+ end
36
+ define_action(:Hidden) do
37
+ http_method :post
38
+ route 'hidden'
39
+ authorize { |user| user.login == 'admin' ? allow : deny }
40
+ output(:hash) { string :private_response }
41
+ end
42
+
43
+ define_resource(:Child) do
44
+ define_action(:Show) do
45
+ http_method :get
46
+ route '{child_id}'
47
+ authorize { allow }
48
+ output(:hash) { string :name }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ default_version 1
54
+ auth_chain DocumentationSearchRoutesSpec::Provider
55
+
56
+ def results
57
+ last_response.body.match(%r{<section data-search-results.*?</section>}m)[0]
58
+ end
59
+
60
+ it 'renders ordinary GET search forms on versions, resources and neutral guides without discovery for guides' do
61
+ app
62
+ server = instance_variable_get(:@api)
63
+ allow(server).to receive(:describe_version).and_call_original
64
+ get '/doc/protocol'
65
+ expect(last_response.body).to include('data-documentation-search method="get" action="/v1/"',
66
+ 'name="page" value="resources"', 'name="q" data-search-query', 'Search API v1')
67
+ expect(last_response.body).to include('placeholder="Resource..."')
68
+ expect(server).not_to have_received(:describe_version)
69
+ get '/v1/?page=resources'
70
+ expect(last_response.body).to include('data-resource-nav', 'data-resource-link="widget"')
71
+ expect(last_response.body).not_to include('data-search-results', 'data-resource-filter')
72
+ ids = last_response.body.scan(/\bid="(docs-search-[^"]+)"/).flatten
73
+ expect(ids).to contain_exactly('docs-search-sidebar-query')
74
+ main = last_response.body.match(%r{<main[^>]*>(.*?)</main>}m)[1]
75
+ expect(main).not_to include('data-documentation-search', 'type="submit"')
76
+ end
77
+
78
+ it 'trims and escapes the query, title and results while describing exactly once' do
79
+ app
80
+ server = instance_variable_get(:@api)
81
+ allow(server).to receive(:describe_version).and_call_original
82
+ get '/v1/', page: 'resources', q: ' lease duration '
83
+ expect(last_response).to be_ok
84
+ expect(last_response.headers['Cache-Control']).to eq('private, no-store')
85
+ expect(last_response.body).to include('<title>Search API v1: lease duration', 'value="lease duration"')
86
+ expect(results).to include('Parameter:', 'lifetime', 'Input', 'Integer')
87
+ expect(server).to have_received(:describe_version).once
88
+ get '/v1/', page: 'resources', q: '<script>evil</script>'
89
+ expect(last_response.body).not_to include('<script>evil</script>')
90
+ expect(results).to include('&lt;script&gt;evil&lt;/script&gt;', 'No matches')
91
+ end
92
+
93
+ it 'renders a nested resource full path and escaped ancestor context without a duplicate leaf label' do
94
+ app
95
+ server = instance_variable_get(:@api)
96
+ allow(server).to receive(:describe_version).and_wrap_original do |original, *arguments|
97
+ original.call(*arguments).tap { |help| help[:resources]['widget'][:resources]['child'][:label] = 'Child <node>' }
98
+ end
99
+ get '/v1/', page: 'resources', q: 'widget.child'
100
+ expect(last_response).to be_ok
101
+ expect(results).to include('Resource: <code>widget.child</code>', '<span>Widget · Child &lt;node&gt;</span>',
102
+ 'href="/v1/?resource=widget.child"', '<span>widget.child · show</span>')
103
+ expect(results).not_to include('Resource: <code>child</code>', '<span>child</span>', '<span>Child &lt;node&gt;</span>', '<node>')
104
+ end
105
+
106
+ it 'does not run query matching on unqueried pages or a query supplied to another page' do
107
+ allow(HaveAPI::DocumentationSearch).to receive(:new).and_call_original
108
+ ['/v1/', '/v1/?resource=widget', '/v1/?page=resources', '/v1/?page=resources&q=++', '/v1/?q=widget'].each do |path|
109
+ get path
110
+ expect(last_response).to be_ok
111
+ end
112
+ expect(HaveAPI::DocumentationSearch).not_to have_received(:new)
113
+ end
114
+
115
+ it 'rejects duplicate, bracketed, malformed UTF-8 and overlong search queries before describing' do
116
+ app
117
+ server = instance_variable_get(:@api)
118
+ allow(server).to receive(:describe_version).and_call_original
119
+ ['q=a&q=b', 'q[]=widget', 'q[x]=widget', 'q=%FF', 'q=%C0%AF', 'q=%ZZ', "q=#{'a' * 257}"].each do |query|
120
+ response = Rack::MockRequest.new(app).get('/v1/', 'HTTP_HOST' => 'example.test', 'QUERY_STRING' => "page=resources&#{query}")
121
+ expect(response.status).to eq(400), query
122
+ end
123
+ expect(server).not_to have_received(:describe_version)
124
+ get '/v1/', page: 'resources', q: 'ž' * 256
125
+ expect(last_response).to be_ok
126
+ end
127
+
128
+ it 'links parameter results to the exact row and keeps its owning action as a containing section' do
129
+ get '/v1/', page: 'resources', q: 'lifetime'
130
+ anchor = HaveAPI::DocumentationSearch.parameter_anchor(resource_path: ['widget'], action_name: 'renew',
131
+ direction: :input, parameter_name: 'lifetime')
132
+ expect(results).to include("href=\"/v1/?resource=widget##{anchor}\"")
133
+ get '/v1/?resource=widget'
134
+ section = last_response.body.match(%r{<section data-doc-section id="root-widget-renew">(.*?)</section>}m)[1]
135
+ expect(section).to include("<tr id=\"#{anchor}\"")
136
+ end
137
+
138
+ it 'links advertised provider parameters to their actual authentication-page rows' do
139
+ request = Rack::MockRequest.new(DynamicDocumentationAPI.build.app)
140
+ response = request.get('/mount/v2/?page=resources&q=token+request+user', 'HTTP_HOST' => 'example.test')
141
+ expect(response.status).to eq(200)
142
+ anchor = HaveAPI::DocumentationSearch.parameter_anchor(resource_path: ['token'], action_name: 'request',
143
+ direction: :input, parameter_name: 'user', authentication: :token)
144
+ expect(response.body).to include("href=\"/mount/v2/?page=authentication##{anchor}\"", 'Authentication: token')
145
+ page = request.get('/mount/v2/?page=authentication', 'HTTP_HOST' => 'example.test')
146
+ expect(page.body).to include("<tr id=\"#{anchor}\"")
147
+ end
148
+
149
+ it 'keeps result links canonical under both API and Rack mounts and leaves OPTIONS query semantics unchanged' do
150
+ server = HaveAPI::Server.new(get_opt(:api_module))
151
+ server.use_version(1)
152
+ server.mount('/api/')
153
+ response = Rack::MockRequest.new(server.app).get('/api/v1/?page=resources&q=lifetime',
154
+ 'HTTP_HOST' => 'example.test', 'SCRIPT_NAME' => '/rack')
155
+ expect(response.status).to eq(200)
156
+ expect(response.body).to include('action="/rack/api/v1/"', 'href="/rack/api/v1/?resource=widget#parameter-')
157
+ call_api(:options, '/v1/?page=resources&q[]=bad&q=other')
158
+ expect(last_response).to be_ok
159
+ expect(api_response[:resources]).to have_key(:widget)
160
+ end
161
+
162
+ context 'with OAuth documentation sessions' do
163
+ let(:documentation_server) do
164
+ HaveAPI::Server.new(self.class.const_get(:ApiModule)).tap do |api|
165
+ api.use_version(1)
166
+ api.auth_chain << DocumentationSearchRoutesSpec::Provider
167
+ end
168
+ end
169
+ let(:documentation_fixture) do
170
+ require_relative '../../test_support/documentation_oauth_fixture'
171
+ DocumentationOAuthFixture.configure(documentation_server, users: {
172
+ 1 => { 'member-token' => DocumentationSearchRoutesSpec::User.new('member'),
173
+ 'admin-token' => DocumentationSearchRoutesSpec::User.new('admin') }
174
+ })
175
+ end
176
+
177
+ before do
178
+ documentation_fixture
179
+ documentation_server.mount('/')
180
+ @api = documentation_server
181
+ end
182
+
183
+ it 'uses only cookie-user accessible parameters and actions while anonymous docs retain their full view' do
184
+ get "#{documentation_fixture.origin}/v1/", page: 'resources', q: 'private_ticket'
185
+ expect(results).to include('private_ticket')
186
+ header 'Cookie', documentation_fixture.header_for(documentation_server, version: 1, token: 'member-token')
187
+ login('admin', 'pass')
188
+ get "#{documentation_fixture.origin}/v1/", page: 'resources', q: 'private_ticket'
189
+ expect(results).not_to include('<code>private_ticket</code>', 'Secret ticket', 'DEFAULT_SECRET')
190
+ get "#{documentation_fixture.origin}/v1/", page: 'resources', q: 'hidden'
191
+ expect(results).not_to include('<code>hidden</code>', 'private_response')
192
+ header 'Cookie', documentation_fixture.header_for(documentation_server, version: 1, token: 'admin-token')
193
+ login('member', 'pass')
194
+ get "#{documentation_fixture.origin}/v1/", page: 'resources', q: 'private_ticket'
195
+ expect(results).to include('<code>private_ticket</code>')
196
+ expect(documentation_fixture.lookups).to eq([[1, 'member-token'], [1, 'member-token'], [1, 'admin-token']])
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,134 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe HaveAPI::DocumentationSearch do
4
+ let(:action) do
5
+ { method: 'POST', path: '/v1/widgets/{id}/renew', aliases: ['extend'],
6
+ input: { parameters: { lifetime: { label: 'Lease duration', type: 'Integer', default: 'DEFAULT_SECRET',
7
+ validators: { include: { values: ['VALIDATOR_SECRET'] } } } } },
8
+ output: { parameters: { valid_to: { label: 'Expires at', type: 'Datetime' } } },
9
+ description: 'DESCRIPTION_SECRET', examples: [{ request: { name: 'EXAMPLE_SECRET' } }] }
10
+ end
11
+ let(:description) do
12
+ { resources: { widget: { actions: { renew: action }, resources: {} } },
13
+ authentication: { token: { resources: { token: { actions: { request: action }, resources: {} } } } } }
14
+ end
15
+ let(:presentation) { Object.new.extend(described_class::Helpers) }
16
+
17
+ def search(query, source = description)
18
+ index = described_class::Index.new(description: source, base_path: '/v1/', version: '1', locale: 'en', visibility: 'anonymous')
19
+ described_class.new(index: index.payload, query:).call
20
+ end
21
+
22
+ it 'uses complete resource identities and ancestor labels without changing the index' do
23
+ fixture = JSON.parse(File.read(File.join(__dir__, 'fixtures/search_index.json')))
24
+ index = fixture.fetch('index')
25
+ before = JSON.generate(index)
26
+ fixture.fetch('display_queries').each do |query|
27
+ results = described_class.new(index:, query: query.fetch('query')).call[:results]
28
+ display = results.map do |result|
29
+ { 'title' => "#{result[:kind].to_s.capitalize}: #{presentation.documentation_search_result_title(result)}",
30
+ 'context' => presentation.documentation_search_result_context(result) }
31
+ end
32
+ expect(display).to eq(query.fetch('expected'))
33
+ end
34
+ expect(JSON.generate(index)).to eq(before)
35
+ end
36
+
37
+ it 'keeps ancestor labels within their provider and omits an identical resource context' do
38
+ source = { resources: { token: { label: 'Public tokens', resources: { child: { label: 'Public child' } } },
39
+ same: { label: 'same' } },
40
+ authentication: { token: { resources: { token: { label: 'Private tokens',
41
+ resources: { child: { label: 'Private child' } } } } } } }
42
+ results = search('child', source)[:results]
43
+ labels = results.map { |result| [result[:authentication], result[:resource_label]] }
44
+ expect(labels).to eq([[nil, 'Public tokens · Public child'], ['token', 'Private tokens · Private child']])
45
+ same = search('same', source)[:results].first
46
+ expect(presentation.documentation_search_result_context(same)).to eq([])
47
+ end
48
+
49
+ it 'uses machine segments when a compact fixture omits an ancestor record' do
50
+ index = { resources: [{ path: %w[parent child], label: 'Child label', search: ['child'] }], actions: [], parameters: [] }
51
+ result = described_class.new(index:, query: 'child').call[:results].first
52
+ expect(result[:resource_label]).to eq('parent · Child label')
53
+ expect(presentation.documentation_search_result_title(result)).to eq('parent.child')
54
+ end
55
+
56
+ it 'matches all case-insensitive query tokens across parent and selected metadata' do
57
+ result = search('WIDGET lease INTEGER')
58
+ expect(result[:results]).to include(include(kind: :parameter, name: 'lifetime', direction: :input,
59
+ type: 'Integer', resource_path: ['widget'], action_name: 'renew'))
60
+ expect(search('extend POST')[:results]).to include(include(kind: :action, name: 'renew'))
61
+ expect(search('/v1/widgets/{id}/renew')[:results]).to include(include(kind: :action, name: 'renew'))
62
+ expect(search('Expires')[:results]).to include(include(name: 'valid_to', direction: :output, type: 'Datetime'))
63
+ end
64
+
65
+ it 'excludes examples, defaults, validators, arbitrary prose and provider credentials' do
66
+ source = Marshal.load(Marshal.dump(description))
67
+ source[:authentication][:token][:password] = 'CREDENTIAL_SECRET'
68
+ %w[DEFAULT_SECRET VALIDATOR_SECRET DESCRIPTION_SECRET EXAMPLE_SECRET CREDENTIAL_SECRET].each do |query|
69
+ expect(search(query, source)[:results]).to eq([])
70
+ end
71
+ end
72
+
73
+ it 'ranks exact machine matches before prefixes and remaining token matches with deterministic ties' do
74
+ tree = %w[widget_long mywidget widget].reverse.to_h do |name|
75
+ [name, { actions: {}, resources: {} }]
76
+ end
77
+ source = { resources: tree }
78
+ expect(search('widget', source)[:results].map { |result| result[:name] }).to eq(%w[widget widget_long mywidget])
79
+ reverse = { resources: tree.to_a.reverse.to_h }
80
+ expect(search('widget', reverse)).to eq(search('widget', source))
81
+ end
82
+
83
+ it 'prioritizes resources, actions and parameters before match strength within each kind' do
84
+ source = { resources: { mywidget: { actions: {
85
+ widget: { input: { parameters: { widget: { type: 'String' } } } },
86
+ widget_long: { input: { parameters: { widget_long: { type: 'String' } } } }
87
+ } }, widget: { actions: {} } } }
88
+ results = search('widget', source)[:results]
89
+ expect(results.map { |result| [result[:kind], result[:name]] }).to eq([
90
+ [:resource, 'widget'], [:resource, 'mywidget'],
91
+ [:action, 'widget'], [:action, 'widget_long'],
92
+ [:parameter, 'widget'], [:parameter, 'widget_long']
93
+ ])
94
+ end
95
+
96
+ it 'caps returned matches at twenty and explicitly reports more without changing the source' do
97
+ tree = 32.times.to_h { |index| ["widget#{index}", { actions: {}, resources: {} }] }
98
+ source = { resources: tree }
99
+ before = Marshal.dump(source)
100
+ result = search('widget', source)
101
+ expect(result[:results].length).to eq(20)
102
+ expect(result[:more]).to be(true)
103
+ expect(search('widget31', source)[:more]).to be(false)
104
+ expect(search('absent', source)).to eq(query: 'absent', results: [], more: false)
105
+ expect(Marshal.dump(source)).to eq(before)
106
+ end
107
+
108
+ it 'returns provider-resource matches only from the supplied filtered authentication tree' do
109
+ results = search('token lifetime')[:results]
110
+ expect(results).to contain_exactly(include(kind: :parameter, authentication: 'token', resource_path: ['token'],
111
+ action_name: 'request', name: 'lifetime'))
112
+ expect(search('token', description.merge(authentication: {}))[:results]).to eq([])
113
+ end
114
+
115
+ it 'supports serialized string keys and nested resource context without reading hidden trees' do
116
+ source = JSON.parse(JSON.generate(description))
117
+ source['resources']['parent'] = { 'actions' => {}, 'resources' => { 'child' => { 'actions' => { 'renew' => action } } } }
118
+ expect(search('parent child duration', source)[:results]).to include(include(resource_path: %w[parent child], name: 'lifetime'))
119
+ end
120
+
121
+ it 'encodes every parameter anchor component unambiguously and uses it in result records' do
122
+ arguments = { resource_path: %w[a b-c], action_name: 'show', direction: :input, parameter_name: 'x-y' }
123
+ first = described_class.parameter_anchor(**arguments)
124
+ variations = [arguments.merge(resource_path: %w[a-b c]), arguments.merge(action_name: 'show-input'),
125
+ arguments.merge(direction: :output), arguments.merge(parameter_name: 'x/y "ž"'),
126
+ arguments.merge(authentication: 'token')]
127
+ anchors = [first] + variations.map { |value| described_class.parameter_anchor(**value) }
128
+ expect(anchors.uniq.length).to eq(anchors.length)
129
+ expect(anchors).to all(match(/\Aparameter-[0-9a-f]+\z/))
130
+ record = search('widget lifetime')[:results].first
131
+ expect(record[:anchor]).to eq(described_class.parameter_anchor(resource_path: ['widget'], action_name: 'renew',
132
+ direction: :input, parameter_name: 'lifetime'))
133
+ end
134
+ end
@@ -0,0 +1,240 @@
1
+ require 'spec_helper'
2
+ require 'rack/mock'
3
+
4
+ RSpec.describe HaveAPI::SharedDocumentationHelpers do
5
+ api do
6
+ define_resource(:Widget) do
7
+ version 1
8
+ auth false
9
+
10
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
11
+ authorize { allow }
12
+ end
13
+ end
14
+
15
+ define_resource(:OtherWidget) do
16
+ version 2
17
+ auth false
18
+
19
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
20
+ authorize { allow }
21
+ end
22
+ end
23
+ end
24
+
25
+ default_version 1
26
+
27
+ def json_element(body, name)
28
+ JSON.parse(body.match(%r{<script[^>]*#{name}[^>]*>(.*?)</script>}m)[1])
29
+ end
30
+
31
+ def expect_neutral_shell(body, page)
32
+ expect(body).to include('data-docs', "data-page=\"#{page}\"", 'data-resource=""',
33
+ 'id="docs-main"', '/js/documentation.js', '/css/documentation.css',
34
+ 'Generated by')
35
+ expect(body).not_to include('/js/main.js', '/js/jquery', '/css/bootstrap', 'data-resource-nav',
36
+ 'data-language-select', 'data-documentation-login', 'docs-identity', 'docs-visibility-note',
37
+ 'class="docs-sign-in', 'class="authentication"')
38
+ expect(body).to include('<details data-documentation-menu>', 'data-documentation-menu-content')
39
+ expect(body).not_to include('data-theme-menu', 'data-account-menu')
40
+ expect(json_element(body, 'data-documentation-clients')).to eq([])
41
+ expect(json_element(body, 'data-legacy-anchors')).to eq({})
42
+ %w[auto light dark].each do |theme|
43
+ expect(body).to include("data-theme-choice=\"#{theme}\"")
44
+ end
45
+ end
46
+
47
+ it 'keeps root discovery once and renders its existing versions in the common shell' do
48
+ app
49
+ server = instance_variable_get(:@api)
50
+ allow(server).to receive(:describe).and_call_original
51
+
52
+ get '/'
53
+
54
+ expect(last_response).to be_ok
55
+ expect(last_response.headers['Cache-Control']).to eq('private, no-store')
56
+ expect(last_response.body).to include('<h1>API versions</h1>', 'href="/v1/"', 'href="/v2/"',
57
+ 'Version 1', 'Version 2', '(default)', '<title>API versions')
58
+ main = last_response.body.match(%r{<main\b[^>]*>(.*?)</main>}m)[1]
59
+ expect(main.scan(/<h1\b/).length).to eq(1)
60
+ expect(main).not_to include('API description', 'Available versions:')
61
+ expect_neutral_shell(last_response.body, 'versions')
62
+ expect(server).to have_received(:describe).once
63
+ end
64
+
65
+ {
66
+ '/doc' => 'Server-side implementation',
67
+ '/doc/index' => 'Server-side implementation',
68
+ '/doc/index.md' => 'Server-side implementation',
69
+ '/doc/readme' => 'HaveAPI',
70
+ '/doc/protocol' => 'Protocol definition',
71
+ '/doc/protocol.md' => 'Protocol definition',
72
+ '/doc/create-client' => 'Client definition',
73
+ '/doc/create-client.md' => 'Client definition',
74
+ '/doc/client-examples' => 'Client examples',
75
+ '/doc/client-examples.md' => 'Client examples',
76
+ '/doc/json-schema' => 'Envelope'
77
+ }.each do |path, heading|
78
+ it "serves #{path} in the neutral shell without API discovery or cookie authentication" do
79
+ app
80
+ server = instance_variable_get(:@api)
81
+ allow(server).to receive(:describe).and_call_original
82
+ allow(server).to receive(:describe_version).and_call_original
83
+ allow(server.auth_chain).to receive(:authenticate).and_call_original
84
+
85
+ get path
86
+
87
+ expect(last_response).to be_ok
88
+ expect(last_response.headers['Content-Type']).to include('text/html')
89
+ expect(last_response.headers['Cache-Control']).to eq('private, no-store')
90
+ expect(last_response.body).to include(heading, 'aria-label="Breadcrumb"',
91
+ 'href="/"', 'href="/doc"', 'href="/v1/?page=resources"')
92
+ expect_neutral_shell(last_response.body, 'guide')
93
+ breadcrumb = last_response.body.match(%r{<nav aria-label="Breadcrumb">(.*?)</nav>}m)[1]
94
+ expect(breadcrumb).to include('API versions', 'HaveAPI internals')
95
+ expect(breadcrumb.scan(/<li\b/).length).to eq(path.match?(%r{\A/doc(?:/(?:index(?:\.md)?|readme))?\z}) ? 2 : 3)
96
+ expect(server).not_to have_received(:describe)
97
+ expect(server).not_to have_received(:describe_version)
98
+ expect(server.auth_chain).not_to have_received(:authenticate)
99
+ end
100
+ end
101
+
102
+ it 'renders the real README for every internal landing alias with one sidebar menu' do
103
+ pages = %w[/doc /doc/index /doc/index.md /doc/readme].map do |path|
104
+ get path
105
+ body = last_response.body
106
+ main = body.match(%r{<main\b[^>]*>(.*?)</main>}m)[1]
107
+ expect(main).to include('<h1', '>HaveAPI</h1>', 'Server-side implementation', 'Server framework features',
108
+ '<pre><code class="ruby">', 'class User &lt; ActiveRecord::Base')
109
+ expect(body).to include('<title>HaveAPI internals · ')
110
+ expect(main).not_to include('href="/doc/protocol"', 'href="/doc/client-examples"')
111
+ expect(main.scan('HaveAPI internals').length).to eq(1)
112
+ menu = body.match(%r{<nav aria-label="Shared documentation">(.*?)</nav>}m)[1]
113
+ expect(menu).to include('<h2><a href="/doc">HaveAPI internals</a></h2>')
114
+ expect(menu).not_to include('>Overview</a>')
115
+ expect(menu).not_to include('href="/doc/readme"')
116
+ %w[protocol json-schema create-client client-examples].each do |article|
117
+ expect(menu.scan("href=\"/doc/#{article}\"").length).to eq(1)
118
+ end
119
+ main
120
+ end
121
+ expect(pages.uniq.length).to eq(1)
122
+ end
123
+
124
+ it 'uses configured escaped identity or the actual host and port in neutral titles and brand' do
125
+ server = HaveAPI::Server.new(get_opt(:api_module))
126
+ server.use_version(1)
127
+ server.documentation_name = '<API & site>'
128
+ server.mount('/')
129
+ request = Rack::MockRequest.new(server.app)
130
+ %w[/ /doc /doc/protocol].each do |path|
131
+ response = request.get(path, 'HTTP_HOST' => 'example.test:4567')
132
+ expect(response.status).to eq(200)
133
+ expect(response.body).to include('href="/">&lt;API &amp; site&gt;</a>', ' · &lt;API &amp; site&gt;</title>')
134
+ expect(response.body).not_to include('<API & site>')
135
+ end
136
+ fallback = HaveAPI::Server.new(get_opt(:api_module))
137
+ fallback.use_version(1)
138
+ fallback.mount('/')
139
+ response = Rack::MockRequest.new(fallback.app).get('/doc/protocol', 'HTTP_HOST' => 'example.test:4567')
140
+ expect(response.body).to include('href="/">example.test:4567</a>', ' · example.test:4567</title>')
141
+ end
142
+
143
+ %w[protocol create-client json-schema].each do |name|
144
+ it "uses actual heading IDs for every #{name} TOC target" do
145
+ get "/doc/#{name}"
146
+ body = last_response.body
147
+ headings = body.scan(/<h[1-6]\b[^>]*\bid="([^"]+)"/).flatten
148
+ navigation = body.match(%r{<nav[^>]*data-action-nav[^>]*>(.*?)</nav>}m)[1]
149
+ targets = navigation.scan(/href="#([^"]+)"/).flatten
150
+ expect(targets).not_to be_empty
151
+ expect(targets - headings).to eq([])
152
+ end
153
+ end
154
+
155
+ it 'keeps semantic shared tables in keyboard-accessible overflow regions without changing their cells' do
156
+ get '/doc/performance'
157
+ tables = last_response.body.scan(%r{<table\b[^>]*>.*?</table>}m)
158
+ expect(tables).not_to be_empty
159
+ tables.each do |table|
160
+ expect(last_response.body).to include(%(<div class="docs-table-scroll" role="region" aria-label="Documentation table" tabindex="0">#{table}</div>))
161
+ end
162
+ expect(last_response.body).to include('<th>Measurement policy</th>')
163
+ end
164
+
165
+ it 'keeps missing documents and unsupported aliases at 404' do
166
+ %w[/doc/missing /doc/missing.md /doc/readme.md /doc/json-schema.md].each do |path|
167
+ get path
168
+ expect(last_response.status).to eq(404), path
169
+ end
170
+ end
171
+
172
+ it 'escapes version labels and links from the existing index description' do
173
+ app
174
+ server = instance_variable_get(:@api)
175
+ label = '<script>version</script>'
176
+ help = '/v1/" onclick="unsafe'
177
+ allow(server).to receive(:describe).and_return(versions: { label => { help: } }, default_version: label)
178
+ get '/'
179
+ expect(last_response.body).to include('Version &lt;script&gt;version&lt;/script&gt;', 'href="/v1/&quot; onclick=&quot;unsafe"')
180
+ expect(last_response.body).not_to include(label, 'href="/v1/" onclick="unsafe"')
181
+ end
182
+
183
+ it 'keeps shared and root links mounted under both the API and Rack prefixes' do
184
+ server = HaveAPI::Server.new(get_opt(:api_module))
185
+ server.use_version(1)
186
+ server.mount('/api/')
187
+ request = Rack::MockRequest.new(server.app)
188
+ %w[/api/ /api/doc/protocol].each do |path|
189
+ response = request.get(path, 'HTTP_HOST' => 'example.test', 'SCRIPT_NAME' => '/rack')
190
+ expect(response.status).to eq(200)
191
+ expect(response['Cache-Control']).to eq('private, no-store')
192
+ expect(response.body).to include('href="/rack/api/"', 'href="/rack/api/doc"',
193
+ 'href="/rack/api/v1/"', 'href="/rack/api/v1/?page=resources"',
194
+ 'src="/rack/js/documentation.js"')
195
+ expect(response.body).not_to include('href="/doc"', 'href="/v1/"')
196
+ end
197
+ end
198
+
199
+ it 'keeps guides anonymous and omits Account when documentation OAuth is not configured' do
200
+ server = HaveAPI::Server.new(get_opt(:api_module))
201
+ server.use_version(1)
202
+ server.mount('/api/')
203
+ allow(server).to receive(:describe).and_call_original
204
+ allow(server).to receive(:describe_version).and_call_original
205
+ allow(server.auth_chain).to receive(:authenticate).and_call_original
206
+ response = Rack::MockRequest.new(server.app).get('/api/doc/protocol', 'HTTP_HOST' => 'example.test')
207
+ expect(response.status).to eq(200)
208
+ expect(response.body).not_to include('aria-label="Account"')
209
+ expect(response.body).not_to include('data-account-menu', 'data-documentation-login', 'docs-visibility-note')
210
+ expect(server).not_to have_received(:describe)
211
+ expect(server).not_to have_received(:describe_version)
212
+ expect(server.auth_chain).not_to have_received(:authenticate)
213
+ get '/doc/protocol'
214
+ expect(last_response.body).not_to include('aria-label="Account"', 'data-account-menu')
215
+ end
216
+
217
+ context 'with documentation OAuth configured' do
218
+ it 'offers a neutral Account link without authenticating or describing even with a real session cookie' do
219
+ require_relative '../../test_support/documentation_oauth_fixture'
220
+ server = HaveAPI::Server.new(self.class.const_get(:ApiModule))
221
+ server.use_version(1)
222
+ user = Struct.new(:login).new('guide-user')
223
+ fixture = DocumentationOAuthFixture.configure(server, users: { 1 => { 'guide-token' => user } }, mount_path: '/api/')
224
+ server.mount('/api/')
225
+ allow(server).to receive(:describe).and_call_original
226
+ allow(server).to receive(:describe_version).and_call_original
227
+ allow(server.auth_chain).to receive(:authenticate).and_call_original
228
+ cookie = fixture.header_for(server, version: 1, token: 'guide-token')
229
+ response = Rack::MockRequest.new(server.app).get("#{fixture.origin}/api/doc/protocol",
230
+ 'HTTP_HOST' => URI(fixture.origin).host, 'HTTP_COOKIE' => cookie)
231
+ expect(response.status).to eq(200)
232
+ expect(response.body).to include('href="/api/v1/?page=authentication" aria-label="Account"')
233
+ expect(response.body).not_to include('guide-user', 'data-documentation-login', 'docs-visibility-note')
234
+ expect(fixture.lookups).to be_empty
235
+ expect(server).not_to have_received(:describe)
236
+ expect(server).not_to have_received(:describe_version)
237
+ expect(server.auth_chain).not_to have_received(:authenticate)
238
+ end
239
+ end
240
+ end